Skip to content

Commit 20f6460

Browse files
Only clip when we need to
1 parent 33afe06 commit 20f6460

4 files changed

Lines changed: 142 additions & 10 deletions

File tree

src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) Six Labors.
22
// Licensed under the Six Labors Split License.
33

4+
using System.Numerics;
5+
using SixLabors.ImageSharp.Drawing.Utilities;
46
using SixLabors.PolygonClipper;
57

68
namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry;
@@ -57,6 +59,7 @@ public IPath[] GenerateStrokedShapes(List<PointF[]> spans, float width)
5759
// 1) Stroke each dashed span as open.
5860
this.polygonStroker.Width = width;
5961

62+
List<PointF[]> ringPoints = new(spans.Count);
6063
List<IPath> rings = new(spans.Count);
6164
foreach (PointF[] span in spans)
6265
{
@@ -71,6 +74,7 @@ public IPath[] GenerateStrokedShapes(List<PointF[]> spans, float width)
7174
continue;
7275
}
7376

77+
ringPoints.Add(stroked);
7478
rings.Add(new Polygon(new LinearLineSegment(stroked)));
7579
}
7680

@@ -80,10 +84,9 @@ public IPath[] GenerateStrokedShapes(List<PointF[]> spans, float width)
8084
return [];
8185
}
8286

83-
if (count == 1)
87+
if (!HasIntersections(ringPoints))
8488
{
85-
// Only one stroked ring. Return as-is; two-operand union requires both sides non-empty.
86-
return [rings[0]];
89+
return count == 1 ? [rings[0]] : [.. rings];
8790
}
8891

8992
// 2) Partition so the first and last are on different polygons
@@ -140,6 +143,7 @@ public IPath[] GenerateStrokedShapes(List<PointF[]> spans, float width)
140143
public IPath[] GenerateStrokedShapes(IPath path, float width)
141144
{
142145
// 1) Stroke the input path into closed rings
146+
List<PointF[]> ringPoints = [];
143147
List<IPath> rings = [];
144148
this.polygonStroker.Width = width;
145149

@@ -151,6 +155,7 @@ public IPath[] GenerateStrokedShapes(IPath path, float width)
151155
continue; // skip degenerate outputs
152156
}
153157

158+
ringPoints.Add(stroked);
154159
rings.Add(new Polygon(new LinearLineSegment(stroked)));
155160
}
156161

@@ -160,10 +165,9 @@ public IPath[] GenerateStrokedShapes(IPath path, float width)
160165
return [];
161166
}
162167

163-
if (count == 1)
168+
if (!HasIntersections(ringPoints))
164169
{
165-
// Only one stroked ring. Return as-is; two-operand union requires both sides non-empty.
166-
return [rings[0]];
170+
return count == 1 ? [rings[0]] : [.. rings];
167171
}
168172

169173
// 2) Partition so the first and last are on different polygons
@@ -204,4 +208,92 @@ public IPath[] GenerateStrokedShapes(IPath path, float width)
204208
// 4) Return the cleaned, merged outline
205209
return clipper.GenerateClippedShapes(BooleanOperation.Union);
206210
}
211+
212+
/// <summary>
213+
/// Determines whether any of the provided rings contain self-intersections or intersect with other rings.
214+
/// </summary>
215+
/// <remarks>
216+
/// This method performs a conservative scan to detect intersections among the provided rings. It
217+
/// checks for both self-intersections within each ring and intersections between different rings. Rings are treated
218+
/// as polylines; if a ring is closed (its first and last points are equal), the closing segment is included in the
219+
/// intersection checks. This method is intended for fast intersection detection and may be used to determine
220+
/// whether further geometric processing, such as clipping, is necessary.
221+
/// </remarks>
222+
/// <param name="rings">
223+
/// A list of rings, where each ring is represented as an array of points defining its vertices. Each ring is
224+
/// expected to be a sequence of points forming a polyline or polygon.
225+
/// </param>
226+
/// <returns><see langword="true"/> if any ring self-intersects or any two rings intersect; otherwise, <see langword="false"/>.</returns>
227+
private static bool HasIntersections(List<PointF[]> rings)
228+
{
229+
// Detect whether any stroked ring self-intersects or intersects another ring.
230+
// This is a fast, conservative scan used to decide whether we can skip clipping.
231+
Vector2 intersection = default;
232+
233+
for (int r = 0; r < rings.Count; r++)
234+
{
235+
PointF[] ring = rings[r];
236+
int segmentCount = ring.Length - 1;
237+
if (segmentCount < 2)
238+
{
239+
continue;
240+
}
241+
242+
// 1) Self-intersection scan for the current ring.
243+
// Adjacent segments share a vertex and are skipped to avoid trivial hits.
244+
bool isClosed = ring[0] == ring[^1];
245+
for (int i = 0; i < segmentCount; i++)
246+
{
247+
Vector2 a0 = new(ring[i].X, ring[i].Y);
248+
Vector2 a1 = new(ring[i + 1].X, ring[i + 1].Y);
249+
250+
for (int j = i + 1; j < segmentCount; j++)
251+
{
252+
// Skip neighbors and the closing edge pair in a closed ring.
253+
if (j == i + 1 || (isClosed && i == 0 && j == segmentCount - 1))
254+
{
255+
continue;
256+
}
257+
258+
Vector2 b0 = new(ring[j].X, ring[j].Y);
259+
Vector2 b1 = new(ring[j + 1].X, ring[j + 1].Y);
260+
if (Intersect.LineSegmentToLineSegmentIgnoreCollinear(a0, a1, b0, b1, ref intersection))
261+
{
262+
return true;
263+
}
264+
}
265+
}
266+
267+
// 2) Cross-ring intersection scan against later rings only.
268+
// This avoids double work while checking all ring pairs.
269+
for (int s = r + 1; s < rings.Count; s++)
270+
{
271+
PointF[] other = rings[s];
272+
int otherSegmentCount = other.Length - 1;
273+
if (otherSegmentCount < 1)
274+
{
275+
continue;
276+
}
277+
278+
for (int i = 0; i < segmentCount; i++)
279+
{
280+
Vector2 a0 = new(ring[i].X, ring[i].Y);
281+
Vector2 a1 = new(ring[i + 1].X, ring[i + 1].Y);
282+
283+
for (int j = 0; j < otherSegmentCount; j++)
284+
{
285+
Vector2 b0 = new(other[j].X, other[j].Y);
286+
Vector2 b1 = new(other[j + 1].X, other[j + 1].Y);
287+
if (Intersect.LineSegmentToLineSegmentIgnoreCollinear(a0, a1, b0, b1, ref intersection))
288+
{
289+
return true;
290+
}
291+
}
292+
}
293+
}
294+
}
295+
296+
// No intersections detected.
297+
return false;
298+
}
207299
}

src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexDistance.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,5 +93,6 @@ public static bool CalcIntersection(double ax, double ay, double bx, double by,
9393
}
9494

9595
[MethodImpl(MethodImplOptions.AggressiveInlining)]
96-
public static double CrossProduct(double x1, double y1, double x2, double y2, double x, double y) => ((x - x2) * (y2 - y1)) - ((y - y2) * (x2 - x1));
96+
public static double CrossProduct(double x1, double y1, double x2, double y2, double x, double y)
97+
=> ((x - x2) * (y2 - y1)) - ((y - y2) * (x2 - x1));
9798
}

src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using SixLabors.Fonts;
77
using SixLabors.Fonts.Rendering;
88
using SixLabors.ImageSharp.Drawing.Processing;
9+
using SixLabors.PolygonClipper;
910

1011
namespace SixLabors.ImageSharp.Drawing.Text;
1112

@@ -220,7 +221,7 @@ void IGlyphRenderer.EndLayer()
220221

221222
ShapeOptions options = new()
222223
{
223-
ClippingOperation = ClippingOperation.Intersection,
224+
ClippingOperation = BooleanOperation.Intersection,
224225
IntersectionRule = TextUtilities.MapFillRule(this.currentLayerFillRule)
225226
};
226227

src/ImageSharp.Drawing/Utilities/Intersect.cs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,71 @@
1-
// Copyright (c) Six Labors.
1+
// Copyright (c) Six Labors.
22
// Licensed under the Six Labors Split License.
33

44
using System.Numerics;
55

66
namespace SixLabors.ImageSharp.Drawing.Utilities;
77

8+
/// <summary>
9+
/// Lightweight 2D segment intersection helpers for polygon and path processing.
10+
/// </summary>
11+
/// <remarks>
12+
/// This is intentionally small and allocation-free. It favors speed and numerical tolerance
13+
/// over exhaustive classification (e.g., collinear overlap detection), which keeps it fast
14+
/// enough for per-segment scanning in stroking or clipping preparation passes.
15+
/// </remarks>
816
internal static class Intersect
917
{
18+
// Epsilon used for floating-point tolerance. We treat values within ±Eps as zero.
19+
// This helps avoid instability when segments are nearly parallel or endpoints are
20+
// very close to the intersection boundary.
1021
private const float Eps = 1e-3f;
1122
private const float MinusEps = -Eps;
1223
private const float OnePlusEps = 1 + Eps;
1324

25+
/// <summary>
26+
/// Tests two line segments for intersection, ignoring collinear overlap.
27+
/// </summary>
28+
/// <param name="a0">Start of segment A.</param>
29+
/// <param name="a1">End of segment A.</param>
30+
/// <param name="b0">Start of segment B.</param>
31+
/// <param name="b1">End of segment B.</param>
32+
/// <param name="intersectionPoint">
33+
/// Receives the intersection point when the segments intersect within tolerance.
34+
/// When no intersection is detected, the value is left unchanged.
35+
/// </param>
36+
/// <returns>
37+
/// <see langword="true"/> if the segments intersect within their extents (including endpoints),
38+
/// <see langword="false"/> if they are disjoint or collinear.
39+
/// </returns>
40+
/// <remarks>
41+
/// The method is based on solving two parametric line equations and uses a small epsilon
42+
/// window around [0, 1] to account for floating-point error. Collinear cases are rejected
43+
/// early (crossD ≈ 0) to keep the method fast; callers that need collinear overlap detection
44+
/// must implement that separately.
45+
/// </remarks>
1446
public static bool LineSegmentToLineSegmentIgnoreCollinear(Vector2 a0, Vector2 a1, Vector2 b0, Vector2 b1, ref Vector2 intersectionPoint)
1547
{
48+
// Direction vectors of the segments.
1649
float dax = a1.X - a0.X;
1750
float day = a1.Y - a0.Y;
1851
float dbx = b1.X - b0.X;
1952
float dby = b1.Y - b0.Y;
2053

54+
// Cross product of directions. When near zero, the lines are parallel or collinear.
2155
float crossD = (-dbx * day) + (dax * dby);
2256

23-
if (crossD > MinusEps && crossD < Eps)
57+
// Reject parallel/collinear lines. Collinear overlap is intentionally ignored.
58+
if (crossD is > MinusEps and < Eps)
2459
{
2560
return false;
2661
}
2762

63+
// Solve for parameters s and t where:
64+
// a0 + t*(a1-a0) = b0 + s*(b1-b0)
2865
float s = ((-day * (a0.X - b0.X)) + (dax * (a0.Y - b0.Y))) / crossD;
2966
float t = ((dbx * (a0.Y - b0.Y)) - (dby * (a0.X - b0.X))) / crossD;
3067

68+
// If both parameters are within [0,1] (with tolerance), the segments intersect.
3169
if (s > MinusEps && s < OnePlusEps && t > MinusEps && t < OnePlusEps)
3270
{
3371
intersectionPoint.X = a0.X + (t * dax);

0 commit comments

Comments
 (0)