ImageSharp/ImageSharp.Drawing/SplitPathExtensions.cs
2026-08-03 22:31:27 +02:00

204 lines
7.4 KiB
C#

// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System;
using System.Collections.Generic;
using System.Numerics;
namespace SixLabors.ImageSharp.Drawing {
/// <summary>
/// Extensions to <see cref="IPath"/> for splitting paths into dash segments
/// without performing stroke expansion.
/// </summary>
public static class SplitPathExtensions
{
// Safety limit: if the estimated number of dash segments exceeds this threshold,
// return the original path unsplit to avoid runaway segmentation from very short
// patterns applied to very long paths.
private const int MaxPatternSegments = 10000;
/// <summary>
/// Splits the given path into dash segments based on the provided pattern.
/// Returns a composite path containing only the "on" segments as open sub-paths.
/// </summary>
/// <param name="path">The centerline path to split.</param>
/// <param name="strokeWidth">The stroke width (pattern elements are multiples of this).</param>
/// <param name="pattern">The dash pattern. Each element is a multiple of <paramref name="strokeWidth"/>.</param>
/// <returns>A path containing the "on" dash segments.</returns>
public static IPath GenerateDashes(this IPath path, float strokeWidth, ReadOnlySpan<float> pattern)
=> path.GenerateDashes(strokeWidth, pattern, startOff: false);
/// <summary>
/// Splits the given path into dash segments based on the provided pattern.
/// Returns a composite path containing only the "on" segments as open sub-paths.
/// </summary>
/// <param name="path">The centerline path to split.</param>
/// <param name="strokeWidth">The stroke width (pattern elements are multiples of this).</param>
/// <param name="pattern">The dash pattern. Each element is a multiple of <paramref name="strokeWidth"/>.</param>
/// <param name="startOff">Whether the first item in the pattern is off rather than on.</param>
/// <returns>A path containing the "on" dash segments.</returns>
public static IPath GenerateDashes(this IPath path, float strokeWidth, ReadOnlySpan<float> pattern, bool startOff)
{
if (pattern.Length < 2)
{
return path;
}
const float eps = 1e-6f;
// Compute the absolute pattern length in path units to detect degenerate patterns.
float patternLength = 0f;
for (int i = 0; i < pattern.Length; i++)
{
patternLength += MathF.Abs(pattern[i]) * strokeWidth;
}
// Fallback to the original path when the dash pattern is too small to be meaningful.
if (patternLength <= eps)
{
return path;
}
IEnumerable<ISimplePath> simplePaths = path.Flatten();
List<IPath> segments = [];
List<PointF> buffer = new(64);
foreach (ISimplePath p in simplePaths)
{
bool online = !startOff;
int patternPos = 0;
float targetLength = pattern[patternPos] * strokeWidth;
ReadOnlySpan<PointF> pts = p.Points.Span;
if (pts.Length < 2)
{
continue;
}
// Number of edges to traverse (closed paths wrap; open paths stop one short).
int edgeCount = p.IsClosed ? pts.Length : pts.Length - 1;
// Compute total path length to estimate the number of dash segments.
// This avoids runaway segmentation when a very short pattern is applied
// to a very long path.
float totalLength = 0f;
for (int j = 0; j < edgeCount; j++)
{
int nextIndex = p.IsClosed ? (j + 1) % pts.Length : j + 1;
totalLength += Vector2.Distance(pts[j], pts[nextIndex]);
}
if (totalLength > eps)
{
float estimatedSegments = (totalLength / patternLength) * pattern.Length;
if (estimatedSegments > MaxPatternSegments)
{
return path;
}
}
int ei = 0;
Vector2 current = pts[0];
while (ei < edgeCount)
{
int nextIndex = p.IsClosed ? (ei + 1) % pts.Length : ei + 1;
Vector2 next = pts[nextIndex];
float segLen = Vector2.Distance(current, next);
// Skip degenerate zero-length segments.
if (segLen <= eps)
{
current = next;
ei++;
continue;
}
// Accumulate into the current dash span when the segment is shorter
// than the remaining target length.
if (segLen + eps < targetLength)
{
if (online)
{
buffer.Add(current);
}
current = next;
ei++;
targetLength -= segLen;
continue;
}
// Close out a dash span when the segment length matches the target.
if (MathF.Abs(segLen - targetLength) <= eps)
{
if (online)
{
buffer.Add(current);
buffer.Add(next);
FlushBuffer(buffer, segments);
}
buffer.Clear();
online = !online;
current = next;
ei++;
patternPos = (patternPos + 1) % pattern.Length;
targetLength = pattern[patternPos] * strokeWidth;
continue;
}
// Split inside this segment to end the current dash span.
float t = targetLength / segLen;
Vector2 split = current + (t * (next - current));
if (online)
{
buffer.Add(current);
buffer.Add(split);
FlushBuffer(buffer, segments);
}
buffer.Clear();
online = !online;
current = split; // continue along the same geometric segment
patternPos = (patternPos + 1) % pattern.Length;
targetLength = pattern[patternPos] * strokeWidth;
}
// Flush the tail of the last dash span, if any.
if (buffer.Count > 0)
{
if (online)
{
buffer.Add(current);
FlushBuffer(buffer, segments);
}
buffer.Clear();
}
}
if (segments.Count == 0)
{
return path;
}
if (segments.Count == 1)
{
return segments[0];
}
return new ComplexPolygon(segments);
}
private static void FlushBuffer(List<PointF> buffer, List<IPath> segments)
{
if (buffer.Count >= 2 && buffer[0] != buffer[^1])
{
segments.Add(new Path(new LinearLineSegment([.. buffer])));
}
}
}
}