From 2e7641796e8a04d3356549d0ac78356be4f624da Mon Sep 17 00:00:00 2001 From: Stefan Nikolei Date: Tue, 30 Sep 2025 20:14:37 +0200 Subject: [PATCH 01/18] Quickly integrate SixLabors.PolygonClipper --- .../ImageSharp.Drawing.csproj | 1 + .../Processing/ShapeOptions.cs | 6 +- .../Shapes/ClipPathExtensions.cs | 2 +- .../Shapes/ClippingOperation.cs | 38 - .../Shapes/PolygonClipper/Clipper.cs | 68 +- .../Shapes/PolygonClipper/PolygonClipper.cs | 3432 ----------------- .../Shapes/PolygonClipper/PolygonOffsetter.cs | 56 +- .../Drawing/FillPolygonTests.cs | 3 +- .../ShapeOptionsDefaultsExtensionsTests.cs | 21 +- .../Shapes/PolygonClipper/ClipperTests.cs | 3 +- 10 files changed, 83 insertions(+), 3547 deletions(-) delete mode 100644 src/ImageSharp.Drawing/Shapes/ClippingOperation.cs delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipper.cs diff --git a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj index 8c0426e4..9d82a5ba 100644 --- a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj +++ b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj @@ -47,6 +47,7 @@ + \ No newline at end of file diff --git a/src/ImageSharp.Drawing/Processing/ShapeOptions.cs b/src/ImageSharp.Drawing/Processing/ShapeOptions.cs index 11c188d8..6f079e43 100644 --- a/src/ImageSharp.Drawing/Processing/ShapeOptions.cs +++ b/src/ImageSharp.Drawing/Processing/ShapeOptions.cs @@ -1,6 +1,8 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using SixLabors.PolygonClipper; + namespace SixLabors.ImageSharp.Drawing.Processing; /// @@ -24,9 +26,9 @@ private ShapeOptions(ShapeOptions source) /// /// Gets or sets the clipping operation. /// - /// Defaults to . + /// Defaults to . /// - public ClippingOperation ClippingOperation { get; set; } = ClippingOperation.Difference; + public BooleanOperation ClippingOperation { get; set; } = BooleanOperation.Difference; /// /// Gets or sets the rule for calculating intersection points. diff --git a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs index 690d2291..398ea09e 100644 --- a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs +++ b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs @@ -63,7 +63,7 @@ public static IPath Clip( clipper.AddPath(subjectPath, ClippingType.Subject); clipper.AddPaths(clipPaths, ClippingType.Clip); - IPath[] result = clipper.GenerateClippedShapes(options.ClippingOperation, options.IntersectionRule); + IPath[] result = clipper.GenerateClippedShapes(options.ClippingOperation); return new ComplexPolygon(result); } diff --git a/src/ImageSharp.Drawing/Shapes/ClippingOperation.cs b/src/ImageSharp.Drawing/Shapes/ClippingOperation.cs deleted file mode 100644 index 4adbfc06..00000000 --- a/src/ImageSharp.Drawing/Shapes/ClippingOperation.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -namespace SixLabors.ImageSharp.Drawing; - -/// -/// Provides options for boolean clipping operations. -/// -/// -/// All clipping operations except for Difference are commutative. -/// -public enum ClippingOperation -{ - /// - /// No clipping is performed. - /// - None, - - /// - /// Clips regions covered by both subject and clip polygons. - /// - Intersection, - - /// - /// Clips regions covered by subject or clip polygons, or both polygons. - /// - Union, - - /// - /// Clips regions covered by subject, but not clip polygons. - /// - Difference, - - /// - /// Clips regions covered by subject or clip polygons, but not both. - /// - Xor -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs index 47f090a1..2f4c89cf 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs @@ -1,6 +1,8 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using SixLabors.PolygonClipper; + namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; /// @@ -8,13 +10,8 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; /// internal class Clipper { - private readonly PolygonClipper polygonClipper; - - /// - /// Initializes a new instance of the class. - /// - public Clipper() - => this.polygonClipper = new PolygonClipper(); + private SixLabors.PolygonClipper.Polygon? subject; + private SixLabors.PolygonClipper.Polygon? clip; /// /// Generates the clipped shapes from the previously provided paths. @@ -22,38 +19,28 @@ public Clipper() /// The clipping operation. /// The intersection rule. /// The . - public IPath[] GenerateClippedShapes(ClippingOperation operation, IntersectionRule rule) + public IPath[] GenerateClippedShapes(BooleanOperation operation) { - PathsF closedPaths = []; - PathsF openPaths = []; + ArgumentNullException.ThrowIfNull(this.subject); + ArgumentNullException.ThrowIfNull(this.clip); - FillRule fillRule = rule == IntersectionRule.EvenOdd ? FillRule.EvenOdd : FillRule.NonZero; - this.polygonClipper.Execute(operation, fillRule, closedPaths, openPaths); + SixLabors.PolygonClipper.PolygonClipper polygonClipper = new(this.subject, this.clip, operation); - IPath[] shapes = new IPath[closedPaths.Count + openPaths.Count]; + SixLabors.PolygonClipper.Polygon result = polygonClipper.Run(); - int index = 0; - for (int i = 0; i < closedPaths.Count; i++) - { - PathF path = closedPaths[i]; - PointF[] points = new PointF[path.Count]; - for (int j = 0; j < path.Count; j++) - { - points[j] = path[j]; - } + IPath[] shapes = new IPath[result.Count]; - shapes[index++] = new Polygon(points); - } - - for (int i = 0; i < openPaths.Count; i++) + int index = 0; + for (int i = 0; i < result.Count; i++) { - PathF path = openPaths[i]; - PointF[] points = new PointF[path.Count]; + Contour contour = result[i]; + PointF[] points = new PointF[contour.Count]; - for (int j = 0; j < path.Count; j++) + for (int j = 0; j < contour.Count; j++) { - points[j] = path[j]; + Vertex vertex = contour[j]; + points[j] = new PointF((float)vertex.X, (float)vertex.Y); } shapes[index++] = new Polygon(points); @@ -100,12 +87,25 @@ public void AddPath(IPath path, ClippingType clippingType) internal void AddPath(ISimplePath path, ClippingType clippingType) { ReadOnlySpan vectors = path.Points.Span; - PathF points = new(vectors.Length); - for (int i = 0; i < vectors.Length; i++) + SixLabors.PolygonClipper.Polygon polygon = []; + Contour contour = new(); + polygon.Add(contour); + + foreach (PointF point in vectors) { - points.Add(vectors[i]); + contour.AddVertex(new Vertex(point.X, point.Y)); } - this.polygonClipper.AddPath(points, clippingType, !path.IsClosed); + switch (clippingType) + { + case ClippingType.Clip: + this.clip = polygon; + break; + case ClippingType.Subject: + this.subject = polygon; + break; + default: + throw new ArgumentOutOfRangeException(nameof(clippingType), clippingType, null); + } } } diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipper.cs deleted file mode 100644 index 6f4e3724..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipper.cs +++ /dev/null @@ -1,3432 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -#nullable disable - -using System.Collections; -using System.Numerics; -using System.Runtime.CompilerServices; - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; - -/// -/// Contains functions that cover most polygon boolean and offsetting needs. -/// Ported from and originally licensed -/// under -/// -internal sealed class PolygonClipper -{ - private ClippingOperation clipType; - private FillRule fillRule; - private Active actives; - private Active flaggedHorizontal; - private readonly List minimaList; - private readonly List intersectList; - private readonly List vertexList; - private readonly List outrecList; - private readonly List scanlineList; - private readonly List horzSegList; - private readonly List horzJoinList; - private int currentLocMin; - private float currentBotY; - private bool isSortedMinimaList; - private bool hasOpenPaths; - - public PolygonClipper() - { - this.minimaList = []; - this.intersectList = []; - this.vertexList = []; - this.outrecList = []; - this.scanlineList = []; - this.horzSegList = []; - this.horzJoinList = []; - this.PreserveCollinear = true; - } - - public bool PreserveCollinear { get; set; } - - public bool ReverseSolution { get; set; } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void AddSubject(PathsF paths) => this.AddPaths(paths, ClippingType.Subject); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void AddPath(PathF path, ClippingType polytype, bool isOpen = false) - { - PathsF tmp = new(1) { path }; - this.AddPaths(tmp, polytype, isOpen); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void AddPaths(PathsF paths, ClippingType polytype, bool isOpen = false) - { - if (isOpen) - { - this.hasOpenPaths = true; - } - - this.isSortedMinimaList = false; - this.AddPathsToVertexList(paths, polytype, isOpen); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Execute(ClippingOperation clipType, FillRule fillRule, PathsF solutionClosed) - => this.Execute(clipType, fillRule, solutionClosed, []); - - public void Execute(ClippingOperation clipType, FillRule fillRule, PathsF solutionClosed, PathsF solutionOpen) - { - solutionClosed.Clear(); - solutionOpen.Clear(); - - try - { - this.ExecuteInternal(clipType, fillRule); - this.BuildPaths(solutionClosed, solutionOpen); - } - catch (Exception ex) - { - throw new ClipperException("An error occurred while attempting to clip the polygon. See the inner exception for details.", ex); - } - finally - { - this.ClearSolutionOnly(); - } - } - - private void ExecuteInternal(ClippingOperation ct, FillRule fillRule) - { - if (ct == ClippingOperation.None) - { - return; - } - - this.fillRule = fillRule; - this.clipType = ct; - this.Reset(); - if (!this.PopScanline(out float y)) - { - return; - } - - while (true) - { - this.InsertLocalMinimaIntoAEL(y); - Active ae; - while (this.PopHorz(out ae)) - { - this.DoHorizontal(ae); - } - - if (this.horzSegList.Count > 0) - { - this.ConvertHorzSegsToJoins(); - this.horzSegList.Clear(); - } - - this.currentBotY = y; // bottom of scanbeam - if (!this.PopScanline(out y)) - { - break; // y new top of scanbeam - } - - this.DoIntersections(y); - this.DoTopOfScanbeam(y); - while (this.PopHorz(out ae)) - { - this.DoHorizontal(ae!); - } - } - - this.ProcessHorzJoins(); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void DoIntersections(float topY) - { - if (this.BuildIntersectList(topY)) - { - this.ProcessIntersectList(); - this.DisposeIntersectNodes(); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void DisposeIntersectNodes() - => this.intersectList.Clear(); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void AddNewIntersectNode(Active ae1, Active ae2, float topY) - { - if (!ClipperUtils.GetIntersectPt(ae1.Bot, ae1.Top, ae2.Bot, ae2.Top, out Vector2 ip)) - { - ip = new Vector2(ae1.CurX, topY); - } - - if (ip.Y > this.currentBotY || ip.Y < topY) - { - float absDx1 = MathF.Abs(ae1.Dx); - float absDx2 = MathF.Abs(ae2.Dx); - - // TODO: Check threshold here once we remove upscaling. - if (absDx1 > 100 && absDx2 > 100) - { - if (absDx1 > absDx2) - { - ip = ClipperUtils.GetClosestPtOnSegment(ip, ae1.Bot, ae1.Top); - } - else - { - ip = ClipperUtils.GetClosestPtOnSegment(ip, ae2.Bot, ae2.Top); - } - } - else if (absDx1 > 100) - { - ip = ClipperUtils.GetClosestPtOnSegment(ip, ae1.Bot, ae1.Top); - } - else if (absDx2 > 100) - { - ip = ClipperUtils.GetClosestPtOnSegment(ip, ae2.Bot, ae2.Top); - } - else - { - if (ip.Y < topY) - { - ip.Y = topY; - } - else - { - ip.Y = this.currentBotY; - } - - if (absDx1 < absDx2) - { - ip.X = TopX(ae1, ip.Y); - } - else - { - ip.X = TopX(ae2, ip.Y); - } - } - } - - IntersectNode node = new(ip, ae1, ae2); - this.intersectList.Add(node); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool SetHorzSegHeadingForward(HorzSegment hs, OutPt opP, OutPt opN) - { - if (opP.Point.X == opN.Point.X) - { - return false; - } - - if (opP.Point.X < opN.Point.X) - { - hs.LeftOp = opP; - hs.RightOp = opN; - hs.LeftToRight = true; - } - else - { - hs.LeftOp = opN; - hs.RightOp = opP; - hs.LeftToRight = false; - } - - return true; - } - - private static bool UpdateHorzSegment(HorzSegment hs) - { - OutPt op = hs.LeftOp; - OutRec outrec = GetRealOutRec(op.OutRec); - bool outrecHasEdges = outrec.FrontEdge != null; - float curr_y = op.Point.Y; - OutPt opP = op, opN = op; - if (outrecHasEdges) - { - OutPt opA = outrec.Pts!, opZ = opA.Next; - while (opP != opZ && opP.Prev.Point.Y == curr_y) - { - opP = opP.Prev; - } - - while (opN != opA && opN.Next.Point.Y == curr_y) - { - opN = opN.Next; - } - } - else - { - while (opP.Prev != opN && opP.Prev.Point.Y == curr_y) - { - opP = opP.Prev; - } - - while (opN.Next != opP && opN.Next.Point.Y == curr_y) - { - opN = opN.Next; - } - } - - bool result = SetHorzSegHeadingForward(hs, opP, opN) && hs.LeftOp.HorizSegment == null; - - if (result) - { - hs.LeftOp.HorizSegment = hs; - } - else - { - hs.RightOp = null; // (for sorting) - } - - return result; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static OutPt DuplicateOp(OutPt op, bool insert_after) - { - OutPt result = new(op.Point, op.OutRec); - if (insert_after) - { - result.Next = op.Next; - result.Next.Prev = result; - result.Prev = op; - op.Next = result; - } - else - { - result.Prev = op.Prev; - result.Prev.Next = result; - result.Next = op; - op.Prev = result; - } - - return result; - } - - private void ConvertHorzSegsToJoins() - { - int k = 0; - foreach (HorzSegment hs in this.horzSegList) - { - if (UpdateHorzSegment(hs)) - { - k++; - } - } - - if (k < 2) - { - return; - } - - this.horzSegList.Sort(default(HorzSegSorter)); - - for (int i = 0; i < k - 1; i++) - { - HorzSegment hs1 = this.horzSegList[i]; - - // for each HorzSegment, find others that overlap - for (int j = i + 1; j < k; j++) - { - HorzSegment hs2 = this.horzSegList[j]; - if ((hs2.LeftOp.Point.X >= hs1.RightOp.Point.X) || - (hs2.LeftToRight == hs1.LeftToRight) || - (hs2.RightOp.Point.X <= hs1.LeftOp.Point.X)) - { - continue; - } - - float curr_y = hs1.LeftOp.Point.Y; - if (hs1.LeftToRight) - { - while (hs1.LeftOp.Next.Point.Y == curr_y && - hs1.LeftOp.Next.Point.X <= hs2.LeftOp.Point.X) - { - hs1.LeftOp = hs1.LeftOp.Next; - } - - while (hs2.LeftOp.Prev.Point.Y == curr_y && - hs2.LeftOp.Prev.Point.X <= hs1.LeftOp.Point.X) - { - hs2.LeftOp = hs2.LeftOp.Prev; - } - - HorzJoin join = new(DuplicateOp(hs1.LeftOp, true), DuplicateOp(hs2.LeftOp, false)); - this.horzJoinList.Add(join); - } - else - { - while (hs1.LeftOp.Prev.Point.Y == curr_y && - hs1.LeftOp.Prev.Point.X <= hs2.LeftOp.Point.X) - { - hs1.LeftOp = hs1.LeftOp.Prev; - } - - while (hs2.LeftOp.Next.Point.Y == curr_y && - hs2.LeftOp.Next.Point.X <= hs1.LeftOp.Point.X) - { - hs2.LeftOp = hs2.LeftOp.Next; - } - - HorzJoin join = new(DuplicateOp(hs2.LeftOp, true), DuplicateOp(hs1.LeftOp, false)); - this.horzJoinList.Add(join); - } - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void ClearSolutionOnly() - { - while (this.actives != null) - { - this.DeleteFromAEL(this.actives); - } - - this.scanlineList.Clear(); - this.DisposeIntersectNodes(); - this.outrecList.Clear(); - this.horzSegList.Clear(); - this.horzJoinList.Clear(); - } - - private bool BuildPaths(PathsF solutionClosed, PathsF solutionOpen) - { - solutionClosed.Clear(); - solutionOpen.Clear(); - solutionClosed.Capacity = this.outrecList.Count; - solutionOpen.Capacity = this.outrecList.Count; - - int i = 0; - - // _outrecList.Count is not static here because - // CleanCollinear can indirectly add additional OutRec - while (i < this.outrecList.Count) - { - OutRec outrec = this.outrecList[i++]; - if (outrec.Pts == null) - { - continue; - } - - PathF path = []; - if (outrec.IsOpen) - { - if (BuildPath(outrec.Pts, this.ReverseSolution, true, path)) - { - solutionOpen.Add(path); - } - } - else - { - this.CleanCollinear(outrec); - - // closed paths should always return a Positive orientation - // except when ReverseSolution == true - if (BuildPath(outrec.Pts, this.ReverseSolution, false, path)) - { - solutionClosed.Add(path); - } - } - } - - return true; - } - - private static bool BuildPath(OutPt op, bool reverse, bool isOpen, PathF path) - { - if (op == null || op.Next == op || (!isOpen && op.Next == op.Prev)) - { - return false; - } - - path.Clear(); - - Vector2 lastPt; - OutPt op2; - if (reverse) - { - lastPt = op.Point; - op2 = op.Prev; - } - else - { - op = op.Next; - lastPt = op.Point; - op2 = op.Next; - } - - path.Add(lastPt); - - while (op2 != op) - { - if (op2.Point != lastPt) - { - lastPt = op2.Point; - path.Add(lastPt); - } - - if (reverse) - { - op2 = op2.Prev; - } - else - { - op2 = op2.Next; - } - } - - return path.Count != 3 || !IsVerySmallTriangle(op2); - } - - private void DoHorizontal(Active horz) - /******************************************************************************* - * Notes: Horizontal edges (HEs) at scanline intersections (i.e. at the top or * - * bottom of a scanbeam) are processed as if layered.The order in which HEs * - * are processed doesn't matter. HEs intersect with the bottom vertices of * - * other HEs[#] and with non-horizontal edges [*]. Once these intersections * - * are completed, intermediate HEs are 'promoted' to the next edge in their * - * bounds, and they in turn may be intersected[%] by other HEs. * - * * - * eg: 3 horizontals at a scanline: / | / / * - * | / | (HE3)o ========%========== o * - * o ======= o(HE2) / | / / * - * o ============#=========*======*========#=========o (HE1) * - * / | / | / * - *******************************************************************************/ - { - Vector2 pt; - bool horzIsOpen = IsOpen(horz); - float y = horz.Bot.Y; - - Vertex vertex_max = horzIsOpen ? GetCurrYMaximaVertex_Open(horz) : GetCurrYMaximaVertex(horz); - - // remove 180 deg.spikes and also simplify - // consecutive horizontals when PreserveCollinear = true - if (vertex_max != null && - !horzIsOpen && vertex_max != horz.VertexTop) - { - TrimHorz(horz, this.PreserveCollinear); - } - - bool isLeftToRight = ResetHorzDirection(horz, vertex_max, out float leftX, out float rightX); - - if (IsHotEdge(horz)) - { - OutPt op = AddOutPt(horz, new Vector2(horz.CurX, y)); - this.AddToHorzSegList(op); - } - - OutRec currOutrec = horz.Outrec; - - while (true) - { - // loops through consec. horizontal edges (if open) - Active ae = isLeftToRight ? horz.NextInAEL : horz.PrevInAEL; - - while (ae != null) - { - if (ae.VertexTop == vertex_max) - { - // do this first!! - if (IsHotEdge(horz) && IsJoined(ae!)) - { - this.Split(ae, ae.Top); - } - - if (IsHotEdge(horz)) - { - while (horz.VertexTop != vertex_max) - { - AddOutPt(horz, horz.Top); - this.UpdateEdgeIntoAEL(horz); - } - - if (isLeftToRight) - { - this.AddLocalMaxPoly(horz, ae, horz.Top); - } - else - { - this.AddLocalMaxPoly(ae, horz, horz.Top); - } - } - - this.DeleteFromAEL(ae); - this.DeleteFromAEL(horz); - return; - } - - // if horzEdge is a maxima, keep going until we reach - // its maxima pair, otherwise check for break conditions - if (vertex_max != horz.VertexTop || IsOpenEnd(horz)) - { - // otherwise stop when 'ae' is beyond the end of the horizontal line - if ((isLeftToRight && ae.CurX > rightX) || (!isLeftToRight && ae.CurX < leftX)) - { - break; - } - - if (ae.CurX == horz.Top.X && !IsHorizontal(ae)) - { - pt = NextVertex(horz).Point; - - // to maximize the possibility of putting open edges into - // solutions, we'll only break if it's past HorzEdge's end - if (IsOpen(ae) && !IsSamePolyType(ae, horz) && !IsHotEdge(ae)) - { - if ((isLeftToRight && (TopX(ae, pt.Y) > pt.X)) || - (!isLeftToRight && (TopX(ae, pt.Y) < pt.X))) - { - break; - } - } - - // otherwise for edges at horzEdge's end, only stop when horzEdge's - // outslope is greater than e's slope when heading right or when - // horzEdge's outslope is less than e's slope when heading left. - else if ((isLeftToRight && (TopX(ae, pt.Y) >= pt.X)) || (!isLeftToRight && (TopX(ae, pt.Y) <= pt.X))) - { - break; - } - } - } - - pt = new Vector2(ae.CurX, y); - - if (isLeftToRight) - { - this.IntersectEdges(horz, ae, pt); - this.SwapPositionsInAEL(horz, ae); - horz.CurX = ae.CurX; - ae = horz.NextInAEL; - } - else - { - this.IntersectEdges(ae, horz, pt); - this.SwapPositionsInAEL(ae, horz); - horz.CurX = ae.CurX; - ae = horz.PrevInAEL; - } - - if (IsHotEdge(horz) && (horz.Outrec != currOutrec)) - { - currOutrec = horz.Outrec; - this.AddToHorzSegList(GetLastOp(horz)); - } - - // we've reached the end of this horizontal - } - - // check if we've finished looping - // through consecutive horizontals - // ie open at top - if (horzIsOpen && IsOpenEnd(horz)) - { - if (IsHotEdge(horz)) - { - AddOutPt(horz, horz.Top); - if (IsFront(horz)) - { - horz.Outrec.FrontEdge = null; - } - else - { - horz.Outrec.BackEdge = null; - } - - horz.Outrec = null; - } - - this.DeleteFromAEL(horz); - return; - } - else if (NextVertex(horz).Point.Y != horz.Top.Y) - { - break; - } - - // still more horizontals in bound to process ... - if (IsHotEdge(horz)) - { - AddOutPt(horz, horz.Top); - } - - this.UpdateEdgeIntoAEL(horz); - - if (this.PreserveCollinear && !horzIsOpen && HorzIsSpike(horz)) - { - TrimHorz(horz, true); - } - - isLeftToRight = ResetHorzDirection(horz, vertex_max, out leftX, out rightX); - - // end for loop and end of (possible consecutive) horizontals - } - - if (IsHotEdge(horz)) - { - this.AddToHorzSegList(AddOutPt(horz, horz.Top)); - } - - this.UpdateEdgeIntoAEL(horz); // this is the end of an intermediate horiz. - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void DoTopOfScanbeam(float y) - { - this.flaggedHorizontal = null; // sel_ is reused to flag horizontals (see PushHorz below) - Active ae = this.actives; - while (ae != null) - { - // NB 'ae' will never be horizontal here - if (ae.Top.Y == y) - { - ae.CurX = ae.Top.X; - if (IsMaxima(ae)) - { - ae = this.DoMaxima(ae); // TOP OF BOUND (MAXIMA) - continue; - } - - // INTERMEDIATE VERTEX ... - if (IsHotEdge(ae)) - { - AddOutPt(ae, ae.Top); - } - - this.UpdateEdgeIntoAEL(ae); - if (IsHorizontal(ae)) - { - this.PushHorz(ae); // horizontals are processed later - } - } - else - { - // i.e. not the top of the edge - ae.CurX = TopX(ae, y); - } - - ae = ae.NextInAEL; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private Active DoMaxima(Active ae) - { - Active prevE; - Active nextE, maxPair; - prevE = ae.PrevInAEL; - nextE = ae.NextInAEL; - - if (IsOpenEnd(ae)) - { - if (IsHotEdge(ae)) - { - AddOutPt(ae, ae.Top); - } - - if (!IsHorizontal(ae)) - { - if (IsHotEdge(ae)) - { - if (IsFront(ae)) - { - ae.Outrec.FrontEdge = null; - } - else - { - ae.Outrec.BackEdge = null; - } - - ae.Outrec = null; - } - - this.DeleteFromAEL(ae); - } - - return nextE; - } - - maxPair = GetMaximaPair(ae); - if (maxPair == null) - { - return nextE; // eMaxPair is horizontal - } - - if (IsJoined(ae)) - { - this.Split(ae, ae.Top); - } - - if (IsJoined(maxPair)) - { - this.Split(maxPair, maxPair.Top); - } - - // only non-horizontal maxima here. - // process any edges between maxima pair ... - while (nextE != maxPair) - { - this.IntersectEdges(ae, nextE!, ae.Top); - this.SwapPositionsInAEL(ae, nextE!); - nextE = ae.NextInAEL; - } - - if (IsOpen(ae)) - { - if (IsHotEdge(ae)) - { - this.AddLocalMaxPoly(ae, maxPair, ae.Top); - } - - this.DeleteFromAEL(maxPair); - this.DeleteFromAEL(ae); - return prevE != null ? prevE.NextInAEL : this.actives; - } - - // here ae.nextInAel == ENext == EMaxPair ... - if (IsHotEdge(ae)) - { - this.AddLocalMaxPoly(ae, maxPair, ae.Top); - } - - this.DeleteFromAEL(ae); - this.DeleteFromAEL(maxPair); - return prevE != null ? prevE.NextInAEL : this.actives; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void TrimHorz(Active horzEdge, bool preserveCollinear) - { - bool wasTrimmed = false; - Vector2 pt = NextVertex(horzEdge).Point; - - while (pt.Y == horzEdge.Top.Y) - { - // always trim 180 deg. spikes (in closed paths) - // but otherwise break if preserveCollinear = true - if (preserveCollinear && (pt.X < horzEdge.Top.X) != (horzEdge.Bot.X < horzEdge.Top.X)) - { - break; - } - - horzEdge.VertexTop = NextVertex(horzEdge); - horzEdge.Top = pt; - wasTrimmed = true; - if (IsMaxima(horzEdge)) - { - break; - } - - pt = NextVertex(horzEdge).Point; - } - - if (wasTrimmed) - { - SetDx(horzEdge); // +/-infinity - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void AddToHorzSegList(OutPt op) - { - if (op.OutRec.IsOpen) - { - return; - } - - this.horzSegList.Add(new HorzSegment(op)); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static OutPt GetLastOp(Active hotEdge) - { - OutRec outrec = hotEdge.Outrec; - return (hotEdge == outrec.FrontEdge) ? outrec.Pts : outrec.Pts.Next; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vertex GetCurrYMaximaVertex_Open(Active ae) - { - Vertex result = ae.VertexTop; - if (ae.WindDx > 0) - { - while (result.Next.Point.Y == result.Point.Y && ((result.Flags & (VertexFlags.OpenEnd | VertexFlags.LocalMax)) == VertexFlags.None)) - { - result = result.Next; - } - } - else - { - while (result.Prev.Point.Y == result.Point.Y && ((result.Flags & (VertexFlags.OpenEnd | VertexFlags.LocalMax)) == VertexFlags.None)) - { - result = result.Prev; - } - } - - if (!IsMaxima(result)) - { - result = null; // not a maxima - } - - return result; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vertex GetCurrYMaximaVertex(Active ae) - { - Vertex result = ae.VertexTop; - if (ae.WindDx > 0) - { - while (result.Next.Point.Y == result.Point.Y) - { - result = result.Next; - } - } - else - { - while (result.Prev.Point.Y == result.Point.Y) - { - result = result.Prev; - } - } - - if (!IsMaxima(result)) - { - result = null; // not a maxima - } - - return result; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsVerySmallTriangle(OutPt op) - => op.Next.Next == op.Prev - && (PtsReallyClose(op.Prev.Point, op.Next.Point) - || PtsReallyClose(op.Point, op.Next.Point) - || PtsReallyClose(op.Point, op.Prev.Point)); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsValidClosedPath(OutPt op) - => op != null && op.Next != op && (op.Next != op.Prev || !IsVerySmallTriangle(op)); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static OutPt DisposeOutPt(OutPt op) - { - OutPt result = op.Next == op ? null : op.Next; - op.Prev.Next = op.Next; - op.Next.Prev = op.Prev; - - return result; - } - - private void ProcessHorzJoins() - { - foreach (HorzJoin j in this.horzJoinList) - { - OutRec or1 = GetRealOutRec(j.Op1.OutRec); - OutRec or2 = GetRealOutRec(j.Op2.OutRec); - - OutPt op1b = j.Op1.Next; - OutPt op2b = j.Op2.Prev; - j.Op1.Next = j.Op2; - j.Op2.Prev = j.Op1; - op1b.Prev = op2b; - op2b.Next = op1b; - - // 'join' is really a split - if (or1 == or2) - { - or2 = new OutRec - { - Pts = op1b - }; - - FixOutRecPts(or2); - - if (or1.Pts.OutRec == or2) - { - or1.Pts = j.Op1; - or1.Pts.OutRec = or1; - } - - or2.Owner = or1; - - this.outrecList.Add(or2); - } - else - { - or2.Pts = null; - or2.Owner = or1; - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool PtsReallyClose(Vector2 pt1, Vector2 pt2) - - // TODO: Check scale once we can remove upscaling. - => (Math.Abs(pt1.X - pt2.X) < 2F) && (Math.Abs(pt1.Y - pt2.Y) < 2F); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void CleanCollinear(OutRec outrec) - { - outrec = GetRealOutRec(outrec); - - if (outrec?.IsOpen != false) - { - return; - } - - if (!IsValidClosedPath(outrec.Pts)) - { - outrec.Pts = null; - return; - } - - OutPt startOp = outrec.Pts; - OutPt op2 = startOp; - do - { - // NB if preserveCollinear == true, then only remove 180 deg. spikes - if ((ClipperUtils.CrossProduct(op2.Prev.Point, op2.Point, op2.Next.Point) == 0) - && ((op2.Point == op2.Prev.Point) || (op2.Point == op2.Next.Point) || !this.PreserveCollinear || (ClipperUtils.DotProduct(op2.Prev.Point, op2.Point, op2.Next.Point) < 0))) - { - if (op2 == outrec.Pts) - { - outrec.Pts = op2.Prev; - } - - op2 = DisposeOutPt(op2); - if (!IsValidClosedPath(op2)) - { - outrec.Pts = null; - return; - } - - startOp = op2; - continue; - } - - op2 = op2.Next; - } - while (op2 != startOp); - - this.FixSelfIntersects(outrec); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void DoSplitOp(OutRec outrec, OutPt splitOp) - { - // splitOp.prev <=> splitOp && - // splitOp.next <=> splitOp.next.next are intersecting - OutPt prevOp = splitOp.Prev; - OutPt nextNextOp = splitOp.Next.Next; - outrec.Pts = prevOp; - - ClipperUtils.GetIntersectPoint( - prevOp.Point, splitOp.Point, splitOp.Next.Point, nextNextOp.Point, out Vector2 ip); - - float area1 = Area(prevOp); - float absArea1 = Math.Abs(area1); - - if (absArea1 < 2) - { - outrec.Pts = null; - return; - } - - float area2 = AreaTriangle(ip, splitOp.Point, splitOp.Next.Point); - float absArea2 = Math.Abs(area2); - - // de-link splitOp and splitOp.next from the path - // while inserting the intersection point - if (ip == prevOp.Point || ip == nextNextOp.Point) - { - nextNextOp.Prev = prevOp; - prevOp.Next = nextNextOp; - } - else - { - OutPt newOp2 = new(ip, outrec) - { - Prev = prevOp, - Next = nextNextOp - }; - - nextNextOp.Prev = newOp2; - prevOp.Next = newOp2; - } - - // nb: area1 is the path's area *before* splitting, whereas area2 is - // the area of the triangle containing splitOp & splitOp.next. - // So the only way for these areas to have the same sign is if - // the split triangle is larger than the path containing prevOp or - // if there's more than one self=intersection. - if (absArea2 > 1 && (absArea2 > absArea1 || ((area2 > 0) == (area1 > 0)))) - { - OutRec newOutRec = this.NewOutRec(); - newOutRec.Owner = outrec.Owner; - splitOp.OutRec = newOutRec; - splitOp.Next.OutRec = newOutRec; - - OutPt newOp = new(ip, newOutRec) { Prev = splitOp.Next, Next = splitOp }; - newOutRec.Pts = newOp; - splitOp.Prev = newOp; - splitOp.Next.Next = newOp; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void FixSelfIntersects(OutRec outrec) - { - OutPt op2 = outrec.Pts; - - // triangles can't self-intersect - while (op2.Prev != op2.Next.Next) - { - if (ClipperUtils.SegsIntersect(op2.Prev.Point, op2.Point, op2.Next.Point, op2.Next.Next.Point)) - { - this.DoSplitOp(outrec, op2); - if (outrec.Pts == null) - { - return; - } - - op2 = outrec.Pts; - continue; - } - else - { - op2 = op2.Next; - } - - if (op2 == outrec.Pts) - { - break; - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void Reset() - { - if (!this.isSortedMinimaList) - { - this.minimaList.Sort(default(LocMinSorter)); - this.isSortedMinimaList = true; - } - - this.scanlineList.Capacity = this.minimaList.Count; - for (int i = this.minimaList.Count - 1; i >= 0; i--) - { - this.scanlineList.Add(this.minimaList[i].Vertex.Point.Y); - } - - this.currentBotY = 0; - this.currentLocMin = 0; - this.actives = null; - this.flaggedHorizontal = null; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void InsertScanline(float y) - { - int index = this.scanlineList.BinarySearch(y); - if (index >= 0) - { - return; - } - - index = ~index; - this.scanlineList.Insert(index, y); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool PopScanline(out float y) - { - int cnt = this.scanlineList.Count - 1; - if (cnt < 0) - { - y = 0; - return false; - } - - y = this.scanlineList[cnt]; - this.scanlineList.RemoveAt(cnt--); - while (cnt >= 0 && y == this.scanlineList[cnt]) - { - this.scanlineList.RemoveAt(cnt--); - } - - return true; - } - - private void InsertLocalMinimaIntoAEL(float botY) - { - LocalMinima localMinima; - Active leftBound, rightBound; - - // Add any local minima (if any) at BotY - // NB horizontal local minima edges should contain locMin.vertex.prev - while (this.HasLocMinAtY(botY)) - { - localMinima = this.PopLocalMinima(); - if ((localMinima.Vertex.Flags & VertexFlags.OpenStart) != VertexFlags.None) - { - leftBound = null; - } - else - { - leftBound = new Active - { - Bot = localMinima.Vertex.Point, - CurX = localMinima.Vertex.Point.X, - WindDx = -1, - VertexTop = localMinima.Vertex.Prev, - Top = localMinima.Vertex.Prev.Point, - Outrec = null, - LocalMin = localMinima - }; - SetDx(leftBound); - } - - if ((localMinima.Vertex.Flags & VertexFlags.OpenEnd) != VertexFlags.None) - { - rightBound = null; - } - else - { - rightBound = new Active - { - Bot = localMinima.Vertex.Point, - CurX = localMinima.Vertex.Point.X, - WindDx = 1, - VertexTop = localMinima.Vertex.Next, // i.e. ascending - Top = localMinima.Vertex.Next.Point, - Outrec = null, - LocalMin = localMinima - }; - SetDx(rightBound); - } - - // Currently LeftB is just the descending bound and RightB is the ascending. - // Now if the LeftB isn't on the left of RightB then we need swap them. - if (leftBound != null && rightBound != null) - { - if (IsHorizontal(leftBound)) - { - if (IsHeadingRightHorz(leftBound)) - { - SwapActives(ref leftBound, ref rightBound); - } - } - else if (IsHorizontal(rightBound)) - { - if (IsHeadingLeftHorz(rightBound)) - { - SwapActives(ref leftBound, ref rightBound); - } - } - else if (leftBound.Dx < rightBound.Dx) - { - SwapActives(ref leftBound, ref rightBound); - } - - // so when leftBound has windDx == 1, the polygon will be oriented - // counter-clockwise in Cartesian coords (clockwise with inverted Y). - } - else if (leftBound == null) - { - leftBound = rightBound; - rightBound = null; - } - - bool contributing; - leftBound.IsLeftBound = true; - this.InsertLeftEdge(leftBound); - - if (IsOpen(leftBound)) - { - this.SetWindCountForOpenPathEdge(leftBound); - contributing = this.IsContributingOpen(leftBound); - } - else - { - this.SetWindCountForClosedPathEdge(leftBound); - contributing = this.IsContributingClosed(leftBound); - } - - if (rightBound != null) - { - rightBound.WindCount = leftBound.WindCount; - rightBound.WindCount2 = leftBound.WindCount2; - InsertRightEdge(leftBound, rightBound); /////// - - if (contributing) - { - this.AddLocalMinPoly(leftBound, rightBound, leftBound.Bot, true); - if (!IsHorizontal(leftBound)) - { - this.CheckJoinLeft(leftBound, leftBound.Bot); - } - } - - while (rightBound.NextInAEL != null && IsValidAelOrder(rightBound.NextInAEL, rightBound)) - { - this.IntersectEdges(rightBound, rightBound.NextInAEL, rightBound.Bot); - this.SwapPositionsInAEL(rightBound, rightBound.NextInAEL); - } - - if (IsHorizontal(rightBound)) - { - this.PushHorz(rightBound); - } - else - { - this.CheckJoinRight(rightBound, rightBound.Bot); - this.InsertScanline(rightBound.Top.Y); - } - } - else if (contributing) - { - this.StartOpenPath(leftBound, leftBound.Bot); - } - - if (IsHorizontal(leftBound)) - { - this.PushHorz(leftBound); - } - else - { - this.InsertScanline(leftBound.Top.Y); - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Active ExtractFromSEL(Active ae) - { - Active res = ae.NextInSEL; - if (res != null) - { - res.PrevInSEL = ae.PrevInSEL; - } - - ae.PrevInSEL.NextInSEL = res; - return res; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void Insert1Before2InSEL(Active ae1, Active ae2) - { - ae1.PrevInSEL = ae2.PrevInSEL; - if (ae1.PrevInSEL != null) - { - ae1.PrevInSEL.NextInSEL = ae1; - } - - ae1.NextInSEL = ae2; - ae2.PrevInSEL = ae1; - } - - private bool BuildIntersectList(float topY) - { - if (this.actives == null || this.actives.NextInAEL == null) - { - return false; - } - - // Calculate edge positions at the top of the current scanbeam, and from this - // we will determine the intersections required to reach these new positions. - this.AdjustCurrXAndCopyToSEL(topY); - - // Find all edge intersections in the current scanbeam using a stable merge - // sort that ensures only adjacent edges are intersecting. Intersect info is - // stored in FIntersectList ready to be processed in ProcessIntersectList. - // Re merge sorts see https://stackoverflow.com/a/46319131/359538 - Active left = this.flaggedHorizontal; - Active right; - Active lEnd; - Active rEnd; - Active currBase; - Active prevBase; - Active tmp; - - while (left.Jump != null) - { - prevBase = null; - while (left?.Jump != null) - { - currBase = left; - right = left.Jump; - lEnd = right; - rEnd = right.Jump; - left.Jump = rEnd; - while (left != lEnd && right != rEnd) - { - if (right.CurX < left.CurX) - { - tmp = right.PrevInSEL; - while (true) - { - this.AddNewIntersectNode(tmp, right, topY); - if (tmp == left) - { - break; - } - - tmp = tmp.PrevInSEL; - } - - tmp = right; - right = ExtractFromSEL(tmp); - lEnd = right; - Insert1Before2InSEL(tmp, left); - if (left == currBase) - { - currBase = tmp; - currBase.Jump = rEnd; - if (prevBase == null) - { - this.flaggedHorizontal = currBase; - } - else - { - prevBase.Jump = currBase; - } - } - } - else - { - left = left.NextInSEL; - } - } - - prevBase = currBase; - left = rEnd; - } - - left = this.flaggedHorizontal; - } - - return this.intersectList.Count > 0; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void ProcessIntersectList() - { - // We now have a list of intersections required so that edges will be - // correctly positioned at the top of the scanbeam. However, it's important - // that edge intersections are processed from the bottom up, but it's also - // crucial that intersections only occur between adjacent edges. - - // First we do a quicksort so intersections proceed in a bottom up order ... - this.intersectList.Sort(default(IntersectListSort)); - - // Now as we process these intersections, we must sometimes adjust the order - // to ensure that intersecting edges are always adjacent ... - for (int i = 0; i < this.intersectList.Count; ++i) - { - if (!EdgesAdjacentInAEL(this.intersectList[i])) - { - int j = i + 1; - while (!EdgesAdjacentInAEL(this.intersectList[j])) - { - j++; - } - - // swap - (this.intersectList[j], this.intersectList[i]) = - (this.intersectList[i], this.intersectList[j]); - } - - IntersectNode node = this.intersectList[i]; - this.IntersectEdges(node.Edge1, node.Edge2, node.Point); - this.SwapPositionsInAEL(node.Edge1, node.Edge2); - - node.Edge1.CurX = node.Point.X; - node.Edge2.CurX = node.Point.X; - this.CheckJoinLeft(node.Edge2, node.Point, true); - this.CheckJoinRight(node.Edge1, node.Point, true); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void SwapPositionsInAEL(Active ae1, Active ae2) - { - // preconditon: ae1 must be immediately to the left of ae2 - Active next = ae2.NextInAEL; - if (next != null) - { - next.PrevInAEL = ae1; - } - - Active prev = ae1.PrevInAEL; - if (prev != null) - { - prev.NextInAEL = ae2; - } - - ae2.PrevInAEL = prev; - ae2.NextInAEL = ae1; - ae1.PrevInAEL = ae2; - ae1.NextInAEL = next; - if (ae2.PrevInAEL == null) - { - this.actives = ae2; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool ResetHorzDirection(Active horz, Vertex vertexMax, out float leftX, out float rightX) - { - if (horz.Bot.X == horz.Top.X) - { - // the horizontal edge is going nowhere ... - leftX = horz.CurX; - rightX = horz.CurX; - Active ae = horz.NextInAEL; - while (ae != null && ae.VertexTop != vertexMax) - { - ae = ae.NextInAEL; - } - - return ae != null; - } - - if (horz.CurX < horz.Top.X) - { - leftX = horz.CurX; - rightX = horz.Top.X; - return true; - } - - leftX = horz.Top.X; - rightX = horz.CurX; - return false; // right to left - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool HorzIsSpike(Active horz) - { - Vector2 nextPt = NextVertex(horz).Point; - return (horz.Bot.X < horz.Top.X) != (horz.Top.X < nextPt.X); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Active FindEdgeWithMatchingLocMin(Active e) - { - Active result = e.NextInAEL; - while (result != null) - { - if (result.LocalMin == e.LocalMin) - { - return result; - } - - if (!IsHorizontal(result) && e.Bot != result.Bot) - { - result = null; - } - else - { - result = result.NextInAEL; - } - } - - result = e.PrevInAEL; - while (result != null) - { - if (result.LocalMin == e.LocalMin) - { - return result; - } - - if (!IsHorizontal(result) && e.Bot != result.Bot) - { - return null; - } - - result = result.PrevInAEL; - } - - return result; - } - - private OutPt IntersectEdges(Active ae1, Active ae2, Vector2 pt) - { - OutPt resultOp = null; - - // MANAGE OPEN PATH INTERSECTIONS SEPARATELY ... - if (this.hasOpenPaths && (IsOpen(ae1) || IsOpen(ae2))) - { - if (IsOpen(ae1) && IsOpen(ae2)) - { - return null; - } - - // the following line avoids duplicating quite a bit of code - if (IsOpen(ae2)) - { - SwapActives(ref ae1, ref ae2); - } - - if (IsJoined(ae2)) - { - this.Split(ae2, pt); // needed for safety - } - - if (this.clipType == ClippingOperation.Union) - { - if (!IsHotEdge(ae2)) - { - return null; - } - } - else if (ae2.LocalMin.Polytype == ClippingType.Subject) - { - return null; - } - - switch (this.fillRule) - { - case FillRule.Positive: - if (ae2.WindCount != 1) - { - return null; - } - - break; - case FillRule.Negative: - if (ae2.WindCount != -1) - { - return null; - } - - break; - default: - if (Math.Abs(ae2.WindCount) != 1) - { - return null; - } - - break; - } - - // toggle contribution ... - if (IsHotEdge(ae1)) - { - resultOp = AddOutPt(ae1, pt); - if (IsFront(ae1)) - { - ae1.Outrec.FrontEdge = null; - } - else - { - ae1.Outrec.BackEdge = null; - } - - ae1.Outrec = null; - } - - // horizontal edges can pass under open paths at a LocMins - else if (pt == ae1.LocalMin.Vertex.Point && !IsOpenEnd(ae1.LocalMin.Vertex)) - { - // find the other side of the LocMin and - // if it's 'hot' join up with it ... - Active ae3 = FindEdgeWithMatchingLocMin(ae1); - if (ae3 != null && IsHotEdge(ae3)) - { - ae1.Outrec = ae3.Outrec; - if (ae1.WindDx > 0) - { - SetSides(ae3.Outrec!, ae1, ae3); - } - else - { - SetSides(ae3.Outrec!, ae3, ae1); - } - - return ae3.Outrec.Pts; - } - - resultOp = this.StartOpenPath(ae1, pt); - } - else - { - resultOp = this.StartOpenPath(ae1, pt); - } - - return resultOp; - } - - // MANAGING CLOSED PATHS FROM HERE ON - if (IsJoined(ae1)) - { - this.Split(ae1, pt); - } - - if (IsJoined(ae2)) - { - this.Split(ae2, pt); - } - - // UPDATE WINDING COUNTS... - int oldE1WindCount, oldE2WindCount; - if (ae1.LocalMin.Polytype == ae2.LocalMin.Polytype) - { - if (this.fillRule == FillRule.EvenOdd) - { - oldE1WindCount = ae1.WindCount; - ae1.WindCount = ae2.WindCount; - ae2.WindCount = oldE1WindCount; - } - else - { - if (ae1.WindCount + ae2.WindDx == 0) - { - ae1.WindCount = -ae1.WindCount; - } - else - { - ae1.WindCount += ae2.WindDx; - } - - if (ae2.WindCount - ae1.WindDx == 0) - { - ae2.WindCount = -ae2.WindCount; - } - else - { - ae2.WindCount -= ae1.WindDx; - } - } - } - else - { - if (this.fillRule != FillRule.EvenOdd) - { - ae1.WindCount2 += ae2.WindDx; - } - else - { - ae1.WindCount2 = ae1.WindCount2 == 0 ? 1 : 0; - } - - if (this.fillRule != FillRule.EvenOdd) - { - ae2.WindCount2 -= ae1.WindDx; - } - else - { - ae2.WindCount2 = ae2.WindCount2 == 0 ? 1 : 0; - } - } - - switch (this.fillRule) - { - case FillRule.Positive: - oldE1WindCount = ae1.WindCount; - oldE2WindCount = ae2.WindCount; - break; - case FillRule.Negative: - oldE1WindCount = -ae1.WindCount; - oldE2WindCount = -ae2.WindCount; - break; - default: - oldE1WindCount = Math.Abs(ae1.WindCount); - oldE2WindCount = Math.Abs(ae2.WindCount); - break; - } - - bool e1WindCountIs0or1 = oldE1WindCount is 0 or 1; - bool e2WindCountIs0or1 = oldE2WindCount is 0 or 1; - - if ((!IsHotEdge(ae1) && !e1WindCountIs0or1) || (!IsHotEdge(ae2) && !e2WindCountIs0or1)) - { - return null; - } - - // NOW PROCESS THE INTERSECTION ... - - // if both edges are 'hot' ... - if (IsHotEdge(ae1) && IsHotEdge(ae2)) - { - if ((oldE1WindCount != 0 && oldE1WindCount != 1) || (oldE2WindCount != 0 && oldE2WindCount != 1) || - (ae1.LocalMin.Polytype != ae2.LocalMin.Polytype && this.clipType != ClippingOperation.Xor)) - { - resultOp = this.AddLocalMaxPoly(ae1, ae2, pt); - } - else if (IsFront(ae1) || (ae1.Outrec == ae2.Outrec)) - { - // this 'else if' condition isn't strictly needed but - // it's sensible to split polygons that ony touch at - // a common vertex (not at common edges). - resultOp = this.AddLocalMaxPoly(ae1, ae2, pt); - this.AddLocalMinPoly(ae1, ae2, pt); - } - else - { - // can't treat as maxima & minima - resultOp = AddOutPt(ae1, pt); - AddOutPt(ae2, pt); - SwapOutrecs(ae1, ae2); - } - } - - // if one or other edge is 'hot' ... - else if (IsHotEdge(ae1)) - { - resultOp = AddOutPt(ae1, pt); - SwapOutrecs(ae1, ae2); - } - else if (IsHotEdge(ae2)) - { - resultOp = AddOutPt(ae2, pt); - SwapOutrecs(ae1, ae2); - } - - // neither edge is 'hot' - else - { - float e1Wc2, e2Wc2; - switch (this.fillRule) - { - case FillRule.Positive: - e1Wc2 = ae1.WindCount2; - e2Wc2 = ae2.WindCount2; - break; - case FillRule.Negative: - e1Wc2 = -ae1.WindCount2; - e2Wc2 = -ae2.WindCount2; - break; - default: - e1Wc2 = Math.Abs(ae1.WindCount2); - e2Wc2 = Math.Abs(ae2.WindCount2); - break; - } - - if (!IsSamePolyType(ae1, ae2)) - { - resultOp = this.AddLocalMinPoly(ae1, ae2, pt); - } - else if (oldE1WindCount == 1 && oldE2WindCount == 1) - { - resultOp = null; - switch (this.clipType) - { - case ClippingOperation.Union: - if (e1Wc2 > 0 && e2Wc2 > 0) - { - return null; - } - - resultOp = this.AddLocalMinPoly(ae1, ae2, pt); - break; - - case ClippingOperation.Difference: - if (((GetPolyType(ae1) == ClippingType.Clip) && (e1Wc2 > 0) && (e2Wc2 > 0)) - || ((GetPolyType(ae1) == ClippingType.Subject) && (e1Wc2 <= 0) && (e2Wc2 <= 0))) - { - resultOp = this.AddLocalMinPoly(ae1, ae2, pt); - } - - break; - - case ClippingOperation.Xor: - resultOp = this.AddLocalMinPoly(ae1, ae2, pt); - break; - - default: // ClipType.Intersection: - if (e1Wc2 <= 0 || e2Wc2 <= 0) - { - return null; - } - - resultOp = this.AddLocalMinPoly(ae1, ae2, pt); - break; - } - } - } - - return resultOp; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void DeleteFromAEL(Active ae) - { - Active prev = ae.PrevInAEL; - Active next = ae.NextInAEL; - if (prev == null && next == null && (ae != this.actives)) - { - return; // already deleted - } - - if (prev != null) - { - prev.NextInAEL = next; - } - else - { - this.actives = next; - } - - if (next != null) - { - next.PrevInAEL = prev; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void AdjustCurrXAndCopyToSEL(float topY) - { - Active ae = this.actives; - this.flaggedHorizontal = ae; - while (ae != null) - { - ae.PrevInSEL = ae.PrevInAEL; - ae.NextInSEL = ae.NextInAEL; - ae.Jump = ae.NextInSEL; - if (ae.JoinWith == JoinWith.Left) - { - ae.CurX = ae.PrevInAEL.CurX; // this also avoids complications - } - else - { - ae.CurX = TopX(ae, topY); - } - - // NB don't update ae.curr.Y yet (see AddNewIntersectNode) - ae = ae.NextInAEL; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool HasLocMinAtY(float y) - => this.currentLocMin < this.minimaList.Count && this.minimaList[this.currentLocMin].Vertex.Point.Y == y; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private LocalMinima PopLocalMinima() - => this.minimaList[this.currentLocMin++]; - - private void AddPathsToVertexList(PathsF paths, ClippingType polytype, bool isOpen) - { - int totalVertCnt = 0; - for (int i = 0; i < paths.Count; i++) - { - PathF path = paths[i]; - totalVertCnt += path.Count; - } - - this.vertexList.Capacity = this.vertexList.Count + totalVertCnt; - - foreach (PathF path in paths) - { - Vertex v0 = null, prev_v = null, curr_v; - foreach (Vector2 pt in path) - { - if (v0 == null) - { - v0 = new Vertex(pt, VertexFlags.None, null); - this.vertexList.Add(v0); - prev_v = v0; - } - else if (prev_v.Point != pt) - { - // ie skips duplicates - curr_v = new Vertex(pt, VertexFlags.None, prev_v); - this.vertexList.Add(curr_v); - prev_v.Next = curr_v; - prev_v = curr_v; - } - } - - if (prev_v == null || prev_v.Prev == null) - { - continue; - } - - if (!isOpen && prev_v.Point == v0.Point) - { - prev_v = prev_v.Prev; - } - - prev_v.Next = v0; - v0.Prev = prev_v; - if (!isOpen && prev_v.Next == prev_v) - { - continue; - } - - // OK, we have a valid path - bool going_up, going_up0; - if (isOpen) - { - curr_v = v0.Next; - while (curr_v != v0 && curr_v.Point.Y == v0.Point.Y) - { - curr_v = curr_v.Next; - } - - going_up = curr_v.Point.Y <= v0.Point.Y; - if (going_up) - { - v0.Flags = VertexFlags.OpenStart; - this.AddLocMin(v0, polytype, true); - } - else - { - v0.Flags = VertexFlags.OpenStart | VertexFlags.LocalMax; - } - } - else - { - // closed path - prev_v = v0.Prev; - while (prev_v != v0 && prev_v.Point.Y == v0.Point.Y) - { - prev_v = prev_v.Prev; - } - - if (prev_v == v0) - { - continue; // only open paths can be completely flat - } - - going_up = prev_v.Point.Y > v0.Point.Y; - } - - going_up0 = going_up; - prev_v = v0; - curr_v = v0.Next; - while (curr_v != v0) - { - if (curr_v.Point.Y > prev_v.Point.Y && going_up) - { - prev_v.Flags |= VertexFlags.LocalMax; - going_up = false; - } - else if (curr_v.Point.Y < prev_v.Point.Y && !going_up) - { - going_up = true; - this.AddLocMin(prev_v, polytype, isOpen); - } - - prev_v = curr_v; - curr_v = curr_v.Next; - } - - if (isOpen) - { - prev_v.Flags |= VertexFlags.OpenEnd; - if (going_up) - { - prev_v.Flags |= VertexFlags.LocalMax; - } - else - { - this.AddLocMin(prev_v, polytype, isOpen); - } - } - else if (going_up != going_up0) - { - if (going_up0) - { - this.AddLocMin(prev_v, polytype, false); - } - else - { - prev_v.Flags |= VertexFlags.LocalMax; - } - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void AddLocMin(Vertex vert, ClippingType polytype, bool isOpen) - { - // make sure the vertex is added only once. - if ((vert.Flags & VertexFlags.LocalMin) != VertexFlags.None) - { - return; - } - - vert.Flags |= VertexFlags.LocalMin; - - LocalMinima lm = new(vert, polytype, isOpen); - this.minimaList.Add(lm); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void PushHorz(Active ae) - { - ae.NextInSEL = this.flaggedHorizontal; - this.flaggedHorizontal = ae; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool PopHorz(out Active ae) - { - ae = this.flaggedHorizontal; - if (this.flaggedHorizontal == null) - { - return false; - } - - this.flaggedHorizontal = this.flaggedHorizontal.NextInSEL; - return true; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private OutPt AddLocalMinPoly(Active ae1, Active ae2, Vector2 pt, bool isNew = false) - { - OutRec outrec = this.NewOutRec(); - ae1.Outrec = outrec; - ae2.Outrec = outrec; - - if (IsOpen(ae1)) - { - outrec.Owner = null; - outrec.IsOpen = true; - if (ae1.WindDx > 0) - { - SetSides(outrec, ae1, ae2); - } - else - { - SetSides(outrec, ae2, ae1); - } - } - else - { - outrec.IsOpen = false; - Active prevHotEdge = GetPrevHotEdge(ae1); - - // e.windDx is the winding direction of the **input** paths - // and unrelated to the winding direction of output polygons. - // Output orientation is determined by e.outrec.frontE which is - // the ascending edge (see AddLocalMinPoly). - if (prevHotEdge != null) - { - outrec.Owner = prevHotEdge.Outrec; - if (OutrecIsAscending(prevHotEdge) == isNew) - { - SetSides(outrec, ae2, ae1); - } - else - { - SetSides(outrec, ae1, ae2); - } - } - else - { - outrec.Owner = null; - if (isNew) - { - SetSides(outrec, ae1, ae2); - } - else - { - SetSides(outrec, ae2, ae1); - } - } - } - - OutPt op = new(pt, outrec); - outrec.Pts = op; - return op; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SetDx(Active ae) - => ae.Dx = GetDx(ae.Bot, ae.Top); - - /******************************************************************************* - * Dx: 0(90deg) * - * | * - * +inf (180deg) <--- o --. -inf (0deg) * - *******************************************************************************/ - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static float GetDx(Vector2 pt1, Vector2 pt2) - { - float dy = pt2.Y - pt1.Y; - if (dy != 0) - { - return (pt2.X - pt1.X) / dy; - } - - if (pt2.X > pt1.X) - { - return float.NegativeInfinity; - } - - return float.PositiveInfinity; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static float TopX(Active ae, float currentY) - { - Vector2 top = ae.Top; - Vector2 bottom = ae.Bot; - - if ((currentY == top.Y) || (top.X == bottom.X)) - { - return top.X; - } - - if (currentY == bottom.Y) - { - return bottom.X; - } - - return bottom.X + (ae.Dx * (currentY - bottom.Y)); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsHorizontal(Active ae) - => ae.Top.Y == ae.Bot.Y; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsHeadingRightHorz(Active ae) - => float.IsNegativeInfinity(ae.Dx); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsHeadingLeftHorz(Active ae) - => float.IsPositiveInfinity(ae.Dx); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SwapActives(ref Active ae1, ref Active ae2) - => (ae2, ae1) = (ae1, ae2); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static ClippingType GetPolyType(Active ae) - => ae.LocalMin.Polytype; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsSamePolyType(Active ae1, Active ae2) - => ae1.LocalMin.Polytype == ae2.LocalMin.Polytype; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool IsContributingClosed(Active ae) - { - switch (this.fillRule) - { - case FillRule.Positive: - if (ae.WindCount != 1) - { - return false; - } - - break; - case FillRule.Negative: - if (ae.WindCount != -1) - { - return false; - } - - break; - case FillRule.NonZero: - if (Math.Abs(ae.WindCount) != 1) - { - return false; - } - - break; - } - - switch (this.clipType) - { - case ClippingOperation.Intersection: - return this.fillRule switch - { - FillRule.Positive => ae.WindCount2 > 0, - FillRule.Negative => ae.WindCount2 < 0, - _ => ae.WindCount2 != 0, - }; - - case ClippingOperation.Union: - return this.fillRule switch - { - FillRule.Positive => ae.WindCount2 <= 0, - FillRule.Negative => ae.WindCount2 >= 0, - _ => ae.WindCount2 == 0, - }; - - case ClippingOperation.Difference: - bool result = this.fillRule switch - { - FillRule.Positive => ae.WindCount2 <= 0, - FillRule.Negative => ae.WindCount2 >= 0, - _ => ae.WindCount2 == 0, - }; - return (GetPolyType(ae) == ClippingType.Subject) ? result : !result; - - case ClippingOperation.Xor: - return true; // XOr is always contributing unless open - - default: - return false; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool IsContributingOpen(Active ae) - { - bool isInClip, isInSubj; - switch (this.fillRule) - { - case FillRule.Positive: - isInSubj = ae.WindCount > 0; - isInClip = ae.WindCount2 > 0; - break; - case FillRule.Negative: - isInSubj = ae.WindCount < 0; - isInClip = ae.WindCount2 < 0; - break; - default: - isInSubj = ae.WindCount != 0; - isInClip = ae.WindCount2 != 0; - break; - } - - bool result = this.clipType switch - { - ClippingOperation.Intersection => isInClip, - ClippingOperation.Union => !isInSubj && !isInClip, - _ => !isInClip - }; - return result; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void SetWindCountForClosedPathEdge(Active ae) - { - // Wind counts refer to polygon regions not edges, so here an edge's WindCnt - // indicates the higher of the wind counts for the two regions touching the - // edge. (nb: Adjacent regions can only ever have their wind counts differ by - // one. Also, open paths have no meaningful wind directions or counts.) - Active ae2 = ae.PrevInAEL; - - // find the nearest closed path edge of the same PolyType in AEL (heading left) - ClippingType pt = GetPolyType(ae); - while (ae2 != null && (GetPolyType(ae2) != pt || IsOpen(ae2))) - { - ae2 = ae2.PrevInAEL; - } - - if (ae2 == null) - { - ae.WindCount = ae.WindDx; - ae2 = this.actives; - } - else if (this.fillRule == FillRule.EvenOdd) - { - ae.WindCount = ae.WindDx; - ae.WindCount2 = ae2.WindCount2; - ae2 = ae2.NextInAEL; - } - else - { - // NonZero, positive, or negative filling here ... - // when e2's WindCnt is in the SAME direction as its WindDx, - // then polygon will fill on the right of 'e2' (and 'e' will be inside) - // nb: neither e2.WindCnt nor e2.WindDx should ever be 0. - if (ae2.WindCount * ae2.WindDx < 0) - { - // opposite directions so 'ae' is outside 'ae2' ... - if (Math.Abs(ae2.WindCount) > 1) - { - // outside prev poly but still inside another. - if (ae2.WindDx * ae.WindDx < 0) - { - // reversing direction so use the same WC - ae.WindCount = ae2.WindCount; - } - else - { - // otherwise keep 'reducing' the WC by 1 (i.e. towards 0) ... - ae.WindCount = ae2.WindCount + ae.WindDx; - } - } - else - { - // now outside all polys of same polytype so set own WC ... - ae.WindCount = IsOpen(ae) ? 1 : ae.WindDx; - } - } - else - { - // 'ae' must be inside 'ae2' - if (ae2.WindDx * ae.WindDx < 0) - { - // reversing direction so use the same WC - ae.WindCount = ae2.WindCount; - } - else - { - // otherwise keep 'increasing' the WC by 1 (i.e. away from 0) ... - ae.WindCount = ae2.WindCount + ae.WindDx; - } - } - - ae.WindCount2 = ae2.WindCount2; - ae2 = ae2.NextInAEL; // i.e. get ready to calc WindCnt2 - } - - // update windCount2 ... - if (this.fillRule == FillRule.EvenOdd) - { - while (ae2 != ae) - { - if (GetPolyType(ae2!) != pt && !IsOpen(ae2!)) - { - ae.WindCount2 = ae.WindCount2 == 0 ? 1 : 0; - } - - ae2 = ae2.NextInAEL; - } - } - else - { - while (ae2 != ae) - { - if (GetPolyType(ae2!) != pt && !IsOpen(ae2!)) - { - ae.WindCount2 += ae2.WindDx; - } - - ae2 = ae2.NextInAEL; - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void SetWindCountForOpenPathEdge(Active ae) - { - Active ae2 = this.actives; - if (this.fillRule == FillRule.EvenOdd) - { - int cnt1 = 0, cnt2 = 0; - while (ae2 != ae) - { - if (GetPolyType(ae2!) == ClippingType.Clip) - { - cnt2++; - } - else if (!IsOpen(ae2!)) - { - cnt1++; - } - - ae2 = ae2.NextInAEL; - } - - ae.WindCount = IsOdd(cnt1) ? 1 : 0; - ae.WindCount2 = IsOdd(cnt2) ? 1 : 0; - } - else - { - while (ae2 != ae) - { - if (GetPolyType(ae2!) == ClippingType.Clip) - { - ae.WindCount2 += ae2.WindDx; - } - else if (!IsOpen(ae2!)) - { - ae.WindCount += ae2.WindDx; - } - - ae2 = ae2.NextInAEL; - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsValidAelOrder(Active resident, Active newcomer) - { - if (newcomer.CurX != resident.CurX) - { - return newcomer.CurX > resident.CurX; - } - - // get the turning direction a1.top, a2.bot, a2.top - float d = ClipperUtils.CrossProduct(resident.Top, newcomer.Bot, newcomer.Top); - if (d != 0) - { - return d < 0; - } - - // edges must be collinear to get here - - // for starting open paths, place them according to - // the direction they're about to turn - if (!IsMaxima(resident) && (resident.Top.Y > newcomer.Top.Y)) - { - return ClipperUtils.CrossProduct(newcomer.Bot, resident.Top, NextVertex(resident).Point) <= 0; - } - - if (!IsMaxima(newcomer) && (newcomer.Top.Y > resident.Top.Y)) - { - return ClipperUtils.CrossProduct(newcomer.Bot, newcomer.Top, NextVertex(newcomer).Point) >= 0; - } - - float y = newcomer.Bot.Y; - bool newcomerIsLeft = newcomer.IsLeftBound; - - if (resident.Bot.Y != y || resident.LocalMin.Vertex.Point.Y != y) - { - return newcomer.IsLeftBound; - } - - // resident must also have just been inserted - if (resident.IsLeftBound != newcomerIsLeft) - { - return newcomerIsLeft; - } - - if (ClipperUtils.CrossProduct(PrevPrevVertex(resident).Point, resident.Bot, resident.Top) == 0) - { - return true; - } - - // compare turning direction of the alternate bound - return (ClipperUtils.CrossProduct(PrevPrevVertex(resident).Point, newcomer.Bot, PrevPrevVertex(newcomer).Point) > 0) == newcomerIsLeft; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void InsertLeftEdge(Active ae) - { - Active ae2; - - if (this.actives == null) - { - ae.PrevInAEL = null; - ae.NextInAEL = null; - this.actives = ae; - } - else if (!IsValidAelOrder(this.actives, ae)) - { - ae.PrevInAEL = null; - ae.NextInAEL = this.actives; - this.actives.PrevInAEL = ae; - this.actives = ae; - } - else - { - ae2 = this.actives; - while (ae2.NextInAEL != null && IsValidAelOrder(ae2.NextInAEL, ae)) - { - ae2 = ae2.NextInAEL; - } - - // don't separate joined edges - if (ae2.JoinWith == JoinWith.Right) - { - ae2 = ae2.NextInAEL; - } - - ae.NextInAEL = ae2.NextInAEL; - if (ae2.NextInAEL != null) - { - ae2.NextInAEL.PrevInAEL = ae; - } - - ae.PrevInAEL = ae2; - ae2.NextInAEL = ae; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void InsertRightEdge(Active ae, Active ae2) - { - ae2.NextInAEL = ae.NextInAEL; - if (ae.NextInAEL != null) - { - ae.NextInAEL.PrevInAEL = ae2; - } - - ae2.PrevInAEL = ae; - ae.NextInAEL = ae2; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vertex NextVertex(Active ae) - { - if (ae.WindDx > 0) - { - return ae.VertexTop.Next; - } - - return ae.VertexTop.Prev; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vertex PrevPrevVertex(Active ae) - { - if (ae.WindDx > 0) - { - return ae.VertexTop.Prev.Prev; - } - - return ae.VertexTop.Next.Next; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsMaxima(Vertex vertex) - => (vertex.Flags & VertexFlags.LocalMax) != VertexFlags.None; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsMaxima(Active ae) - => IsMaxima(ae.VertexTop); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Active GetMaximaPair(Active ae) - { - Active ae2; - ae2 = ae.NextInAEL; - while (ae2 != null) - { - if (ae2.VertexTop == ae.VertexTop) - { - return ae2; // Found! - } - - ae2 = ae2.NextInAEL; - } - - return null; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsOdd(int val) - => (val & 1) != 0; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsHotEdge(Active ae) - => ae.Outrec != null; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsOpen(Active ae) - => ae.LocalMin.IsOpen; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsOpenEnd(Active ae) - => ae.LocalMin.IsOpen && IsOpenEnd(ae.VertexTop); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsOpenEnd(Vertex v) - => (v.Flags & (VertexFlags.OpenStart | VertexFlags.OpenEnd)) != VertexFlags.None; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Active GetPrevHotEdge(Active ae) - { - Active prev = ae.PrevInAEL; - while (prev != null && (IsOpen(prev) || !IsHotEdge(prev))) - { - prev = prev.PrevInAEL; - } - - return prev; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void JoinOutrecPaths(Active ae1, Active ae2) - { - // join ae2 outrec path onto ae1 outrec path and then delete ae2 outrec path - // pointers. (NB Only very rarely do the joining ends share the same coords.) - OutPt p1Start = ae1.Outrec.Pts; - OutPt p2Start = ae2.Outrec.Pts; - OutPt p1End = p1Start.Next; - OutPt p2End = p2Start.Next; - if (IsFront(ae1)) - { - p2End.Prev = p1Start; - p1Start.Next = p2End; - p2Start.Next = p1End; - p1End.Prev = p2Start; - ae1.Outrec.Pts = p2Start; - - // nb: if IsOpen(e1) then e1 & e2 must be a 'maximaPair' - ae1.Outrec.FrontEdge = ae2.Outrec.FrontEdge; - if (ae1.Outrec.FrontEdge != null) - { - ae1.Outrec.FrontEdge.Outrec = ae1.Outrec; - } - } - else - { - p1End.Prev = p2Start; - p2Start.Next = p1End; - p1Start.Next = p2End; - p2End.Prev = p1Start; - - ae1.Outrec.BackEdge = ae2.Outrec.BackEdge; - if (ae1.Outrec.BackEdge != null) - { - ae1.Outrec.BackEdge.Outrec = ae1.Outrec; - } - } - - // after joining, the ae2.OutRec must contains no vertices ... - ae2.Outrec.FrontEdge = null; - ae2.Outrec.BackEdge = null; - ae2.Outrec.Pts = null; - SetOwner(ae2.Outrec, ae1.Outrec); - - if (IsOpenEnd(ae1)) - { - ae2.Outrec.Pts = ae1.Outrec.Pts; - ae1.Outrec.Pts = null; - } - - // and ae1 and ae2 are maxima and are about to be dropped from the Actives list. - ae1.Outrec = null; - ae2.Outrec = null; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static OutPt AddOutPt(Active ae, Vector2 pt) - { - // Outrec.OutPts: a circular doubly-linked-list of POutPt where ... - // opFront[.Prev]* ~~~> opBack & opBack == opFront.Next - OutRec outrec = ae.Outrec; - bool toFront = IsFront(ae); - OutPt opFront = outrec.Pts; - OutPt opBack = opFront.Next; - - if (toFront && (pt == opFront.Point)) - { - return opFront; - } - else if (!toFront && (pt == opBack.Point)) - { - return opBack; - } - - OutPt newOp = new(pt, outrec); - opBack.Prev = newOp; - newOp.Prev = opFront; - newOp.Next = opBack; - opFront.Next = newOp; - if (toFront) - { - outrec.Pts = newOp; - } - - return newOp; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private OutRec NewOutRec() - { - OutRec result = new() - { - Idx = this.outrecList.Count - }; - this.outrecList.Add(result); - return result; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private OutPt StartOpenPath(Active ae, Vector2 pt) - { - OutRec outrec = this.NewOutRec(); - outrec.IsOpen = true; - if (ae.WindDx > 0) - { - outrec.FrontEdge = ae; - outrec.BackEdge = null; - } - else - { - outrec.FrontEdge = null; - outrec.BackEdge = ae; - } - - ae.Outrec = outrec; - OutPt op = new(pt, outrec); - outrec.Pts = op; - return op; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void UpdateEdgeIntoAEL(Active ae) - { - ae.Bot = ae.Top; - ae.VertexTop = NextVertex(ae); - ae.Top = ae.VertexTop.Point; - ae.CurX = ae.Bot.X; - SetDx(ae); - - if (IsJoined(ae)) - { - this.Split(ae, ae.Bot); - } - - if (IsHorizontal(ae)) - { - return; - } - - this.InsertScanline(ae.Top.Y); - - this.CheckJoinLeft(ae, ae.Bot); - this.CheckJoinRight(ae, ae.Bot, true); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SetSides(OutRec outrec, Active startEdge, Active endEdge) - { - outrec.FrontEdge = startEdge; - outrec.BackEdge = endEdge; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SwapOutrecs(Active ae1, Active ae2) - { - OutRec or1 = ae1.Outrec; // at least one edge has - OutRec or2 = ae2.Outrec; // an assigned outrec - if (or1 == or2) - { - (or1.BackEdge, or1.FrontEdge) = (or1.FrontEdge, or1.BackEdge); - return; - } - - if (or1 != null) - { - if (ae1 == or1.FrontEdge) - { - or1.FrontEdge = ae2; - } - else - { - or1.BackEdge = ae2; - } - } - - if (or2 != null) - { - if (ae2 == or2.FrontEdge) - { - or2.FrontEdge = ae1; - } - else - { - or2.BackEdge = ae1; - } - } - - ae1.Outrec = or2; - ae2.Outrec = or1; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SetOwner(OutRec outrec, OutRec newOwner) - { - // precondition1: new_owner is never null - while (newOwner.Owner != null && newOwner.Owner.Pts == null) - { - newOwner.Owner = newOwner.Owner.Owner; - } - - // make sure that outrec isn't an owner of newOwner - OutRec tmp = newOwner; - while (tmp != null && tmp != outrec) - { - tmp = tmp.Owner; - } - - if (tmp != null) - { - newOwner.Owner = outrec.Owner; - } - - outrec.Owner = newOwner; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static float Area(OutPt op) - { - // https://en.wikipedia.org/wiki/Shoelace_formula - float area = 0; - OutPt op2 = op; - do - { - area += (op2.Prev.Point.Y + op2.Point.Y) * (op2.Prev.Point.X - op2.Point.X); - op2 = op2.Next; - } - while (op2 != op); - return area * .5F; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static float AreaTriangle(Vector2 pt1, Vector2 pt2, Vector2 pt3) - => ((pt3.Y + pt1.Y) * (pt3.X - pt1.X)) - + ((pt1.Y + pt2.Y) * (pt1.X - pt2.X)) - + ((pt2.Y + pt3.Y) * (pt2.X - pt3.X)); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static OutRec GetRealOutRec(OutRec outRec) - { - while ((outRec != null) && (outRec.Pts == null)) - { - outRec = outRec.Owner; - } - - return outRec; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void UncoupleOutRec(Active ae) - { - OutRec outrec = ae.Outrec; - if (outrec == null) - { - return; - } - - outrec.FrontEdge.Outrec = null; - outrec.BackEdge.Outrec = null; - outrec.FrontEdge = null; - outrec.BackEdge = null; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool OutrecIsAscending(Active hotEdge) - => hotEdge == hotEdge.Outrec.FrontEdge; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void SwapFrontBackSides(OutRec outrec) - { - // while this proc. is needed for open paths - // it's almost never needed for closed paths - (outrec.BackEdge, outrec.FrontEdge) = (outrec.FrontEdge, outrec.BackEdge); - outrec.Pts = outrec.Pts.Next; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool EdgesAdjacentInAEL(IntersectNode inode) - => (inode.Edge1.NextInAEL == inode.Edge2) || (inode.Edge1.PrevInAEL == inode.Edge2); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void CheckJoinLeft(Active e, Vector2 pt, bool checkCurrX = false) - { - Active prev = e.PrevInAEL; - if (prev == null - || IsOpen(e) - || IsOpen(prev) - || !IsHotEdge(e) - || !IsHotEdge(prev)) - { - return; - } - - // Avoid trivial joins - if ((pt.Y < e.Top.Y + 2 || pt.Y < prev.Top.Y + 2) - && ((e.Bot.Y > pt.Y) || (prev.Bot.Y > pt.Y))) - { - return; - } - - if (checkCurrX) - { - if (ClipperUtils.PerpendicDistFromLineSqrd(pt, prev.Bot, prev.Top) > 0.25) - { - return; - } - } - else if (e.CurX != prev.CurX) - { - return; - } - - if (ClipperUtils.CrossProduct(e.Top, pt, prev.Top) != 0) - { - return; - } - - if (e.Outrec.Idx == prev.Outrec.Idx) - { - this.AddLocalMaxPoly(prev, e, pt); - } - else if (e.Outrec.Idx < prev.Outrec.Idx) - { - JoinOutrecPaths(e, prev); - } - else - { - JoinOutrecPaths(prev, e); - } - - prev.JoinWith = JoinWith.Right; - e.JoinWith = JoinWith.Left; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void CheckJoinRight(Active e, Vector2 pt, bool checkCurrX = false) - { - Active next = e.NextInAEL; - if (IsOpen(e) - || !IsHotEdge(e) - || IsJoined(e) - || next == null - || IsOpen(next) - || !IsHotEdge(next)) - { - return; - } - - // Avoid trivial joins - if ((pt.Y < e.Top.Y + 2 || pt.Y < next.Top.Y + 2) - && ((e.Bot.Y > pt.Y) || (next.Bot.Y > pt.Y))) - { - return; - } - - if (checkCurrX) - { - if (ClipperUtils.PerpendicDistFromLineSqrd(pt, next.Bot, next.Top) > 0.25) - { - return; - } - } - else if (e.CurX != next.CurX) - { - return; - } - - if (ClipperUtils.CrossProduct(e.Top, pt, next.Top) != 0) - { - return; - } - - if (e.Outrec.Idx == next.Outrec.Idx) - { - this.AddLocalMaxPoly(e, next, pt); - } - else if (e.Outrec.Idx < next.Outrec.Idx) - { - JoinOutrecPaths(e, next); - } - else - { - JoinOutrecPaths(next, e); - } - - e.JoinWith = JoinWith.Right; - next.JoinWith = JoinWith.Left; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void FixOutRecPts(OutRec outrec) - { - OutPt op = outrec.Pts; - do - { - op.OutRec = outrec; - op = op.Next; - } - while (op != outrec.Pts); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private OutPt AddLocalMaxPoly(Active ae1, Active ae2, Vector2 pt) - { - if (IsJoined(ae1)) - { - this.Split(ae1, pt); - } - - if (IsJoined(ae2)) - { - this.Split(ae2, pt); - } - - if (IsFront(ae1) == IsFront(ae2)) - { - if (IsOpenEnd(ae1)) - { - SwapFrontBackSides(ae1.Outrec!); - } - else if (IsOpenEnd(ae2)) - { - SwapFrontBackSides(ae2.Outrec!); - } - else - { - return null; - } - } - - OutPt result = AddOutPt(ae1, pt); - if (ae1.Outrec == ae2.Outrec) - { - OutRec outrec = ae1.Outrec; - outrec.Pts = result; - UncoupleOutRec(ae1); - } - - // and to preserve the winding orientation of outrec ... - else if (IsOpen(ae1)) - { - if (ae1.WindDx < 0) - { - JoinOutrecPaths(ae1, ae2); - } - else - { - JoinOutrecPaths(ae2, ae1); - } - } - else if (ae1.Outrec.Idx < ae2.Outrec.Idx) - { - JoinOutrecPaths(ae1, ae2); - } - else - { - JoinOutrecPaths(ae2, ae1); - } - - return result; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsJoined(Active e) - => e.JoinWith != JoinWith.None; - - private void Split(Active e, Vector2 currPt) - { - if (e.JoinWith == JoinWith.Right) - { - e.JoinWith = JoinWith.None; - e.NextInAEL.JoinWith = JoinWith.None; - this.AddLocalMinPoly(e, e.NextInAEL, currPt, true); - } - else - { - e.JoinWith = JoinWith.None; - e.PrevInAEL.JoinWith = JoinWith.None; - this.AddLocalMinPoly(e.PrevInAEL, e, currPt, true); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsFront(Active ae) - => ae == ae.Outrec.FrontEdge; - - private struct LocMinSorter : IComparer - { - public readonly int Compare(LocalMinima locMin1, LocalMinima locMin2) - => locMin2.Vertex.Point.Y.CompareTo(locMin1.Vertex.Point.Y); - } - - private readonly struct LocalMinima - { - public readonly Vertex Vertex; - public readonly ClippingType Polytype; - public readonly bool IsOpen; - - public LocalMinima(Vertex vertex, ClippingType polytype, bool isOpen = false) - { - this.Vertex = vertex; - this.Polytype = polytype; - this.IsOpen = isOpen; - } - - public static bool operator ==(LocalMinima lm1, LocalMinima lm2) - - // TODO: Check this. Why ref equals. - => ReferenceEquals(lm1.Vertex, lm2.Vertex); - - public static bool operator !=(LocalMinima lm1, LocalMinima lm2) - => !(lm1 == lm2); - - public override bool Equals(object obj) - => obj is LocalMinima minima && this == minima; - - public override int GetHashCode() - => this.Vertex.GetHashCode(); - } - - // IntersectNode: a structure representing 2 intersecting edges. - // Intersections must be sorted so they are processed from the largest - // Y coordinates to the smallest while keeping edges adjacent. - private readonly struct IntersectNode - { - public readonly Vector2 Point; - public readonly Active Edge1; - public readonly Active Edge2; - - public IntersectNode(Vector2 pt, Active edge1, Active edge2) - { - this.Point = pt; - this.Edge1 = edge1; - this.Edge2 = edge2; - } - } - - private struct HorzSegSorter : IComparer - { - public readonly int Compare(HorzSegment hs1, HorzSegment hs2) - { - if (hs1 == null || hs2 == null) - { - return 0; - } - - if (hs1.RightOp == null) - { - return hs2.RightOp == null ? 0 : 1; - } - else if (hs2.RightOp == null) - { - return -1; - } - else - { - return hs1.LeftOp.Point.X.CompareTo(hs2.LeftOp.Point.X); - } - } - } - - private struct IntersectListSort : IComparer - { - public readonly int Compare(IntersectNode a, IntersectNode b) - { - if (a.Point.Y == b.Point.Y) - { - if (a.Point.X == b.Point.X) - { - return 0; - } - - return (a.Point.X < b.Point.X) ? -1 : 1; - } - - return (a.Point.Y > b.Point.Y) ? -1 : 1; - } - } - - private class HorzSegment - { - public HorzSegment(OutPt op) - { - this.LeftOp = op; - this.RightOp = null; - this.LeftToRight = true; - } - - public OutPt LeftOp { get; set; } - - public OutPt RightOp { get; set; } - - public bool LeftToRight { get; set; } - } - - private class HorzJoin - { - public HorzJoin(OutPt ltor, OutPt rtol) - { - this.Op1 = ltor; - this.Op2 = rtol; - } - - public OutPt Op1 { get; } - - public OutPt Op2 { get; } - } - - // OutPt: vertex data structure for clipping solutions - private class OutPt - { - public OutPt(Vector2 pt, OutRec outrec) - { - this.Point = pt; - this.OutRec = outrec; - this.Next = this; - this.Prev = this; - this.HorizSegment = null; - } - - public Vector2 Point { get; } - - public OutPt Next { get; set; } - - public OutPt Prev { get; set; } - - public OutRec OutRec { get; set; } - - public HorzSegment HorizSegment { get; set; } - } - - // OutRec: path data structure for clipping solutions - private class OutRec - { - public int Idx { get; set; } - - public OutRec Owner { get; set; } - - public Active FrontEdge { get; set; } - - public Active BackEdge { get; set; } - - public OutPt Pts { get; set; } - - public PolyPathF PolyPath { get; set; } - - public BoundsF Bounds { get; set; } - - public PathF Path { get; set; } = []; - - public bool IsOpen { get; set; } - - public List Splits { get; set; } - } - - private class Vertex - { - public Vertex(Vector2 pt, VertexFlags flags, Vertex prev) - { - this.Point = pt; - this.Flags = flags; - this.Next = null; - this.Prev = prev; - } - - public Vector2 Point { get; } - - public Vertex Next { get; set; } - - public Vertex Prev { get; set; } - - public VertexFlags Flags { get; set; } - } - - private class Active - { - public Vector2 Bot { get; set; } - - public Vector2 Top { get; set; } - - public float CurX { get; set; } // current (updated at every new scanline) - - public float Dx { get; set; } - - public int WindDx { get; set; } // 1 or -1 depending on winding direction - - public int WindCount { get; set; } - - public int WindCount2 { get; set; } // winding count of the opposite polytype - - public OutRec Outrec { get; set; } - - // AEL: 'active edge list' (Vatti's AET - active edge table) - // a linked list of all edges (from left to right) that are present - // (or 'active') within the current scanbeam (a horizontal 'beam' that - // sweeps from bottom to top over the paths in the clipping operation). - public Active PrevInAEL { get; set; } - - public Active NextInAEL { get; set; } - - // SEL: 'sorted edge list' (Vatti's ST - sorted table) - // linked list used when sorting edges into their new positions at the - // top of scanbeams, but also (re)used to process horizontals. - public Active PrevInSEL { get; set; } - - public Active NextInSEL { get; set; } - - public Active Jump { get; set; } - - public Vertex VertexTop { get; set; } - - public LocalMinima LocalMin { get; set; } // the bottom of an edge 'bound' (also Vatti) - - public bool IsLeftBound { get; set; } - - public JoinWith JoinWith { get; set; } - } -} - -internal class PolyPathF : IEnumerable -{ - private readonly PolyPathF parent; - private readonly List items = []; - - public PolyPathF(PolyPathF parent = null) - => this.parent = parent; - - public PathF Polygon { get; private set; } // polytree root's polygon == null - - public int Level => this.GetLevel(); - - public bool IsHole => this.GetIsHole(); - - public int Count => this.items.Count; - - public PolyPathF this[int index] => this.items[index]; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public PolyPathF AddChild(PathF p) - { - PolyPathF child = new(this) - { - Polygon = p - }; - - this.items.Add(child); - return child; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public float Area() - { - float result = this.Polygon == null ? 0 : ClipperUtils.Area(this.Polygon); - for (int i = 0; i < this.items.Count; i++) - { - PolyPathF child = this.items[i]; - result += child.Area(); - } - - return result; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Clear() => this.items.Clear(); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool GetIsHole() - { - int lvl = this.Level; - return lvl != 0 && (lvl & 1) == 0; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private int GetLevel() - { - int result = 0; - PolyPathF pp = this.parent; - while (pp != null) - { - ++result; - pp = pp.parent; - } - - return result; - } - - public IEnumerator GetEnumerator() => this.items.GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() => this.items.GetEnumerator(); -} - -internal class PolyTreeF : PolyPathF -{ -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs index 4670ddfc..17daf144 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs @@ -80,34 +80,34 @@ public void Execute(float delta, PathsF solution) return; } - // Clean up self-intersections. - PolygonClipper clipper = new() - { - PreserveCollinear = this.PreserveCollinear, - - // The solution should retain the orientation of the input - ReverseSolution = this.ReverseSolution != this.groupList[0].PathsReversed - }; - - clipper.AddSubject(this.solution); - if (this.groupList[0].PathsReversed) - { - clipper.Execute(ClippingOperation.Union, FillRule.Negative, solution); - } - else - { - clipper.Execute(ClippingOperation.Union, FillRule.Positive, solution); - } - - // PolygonClipper will throw for unhandled exceptions but if a result is empty - // we should just return the original path. - if (solution.Count == 0) - { - foreach (PathF path in this.solution) - { - solution.Add(path); - } - } + // // Clean up self-intersections. + // PolygonClipper clipper = new() + // { + // PreserveCollinear = this.PreserveCollinear, + // + // // The solution should retain the orientation of the input + // ReverseSolution = this.ReverseSolution != this.groupList[0].PathsReversed + // }; + // + // clipper.AddSubject(this.solution); + // if (this.groupList[0].PathsReversed) + // { + // clipper.Execute(ClippingOperation.Union, FillRule.Negative, solution); + // } + // else + // { + // clipper.Execute(ClippingOperation.Union, FillRule.Positive, solution); + // } + // + // // PolygonClipper will throw for unhandled exceptions but if a result is empty + // // we should just return the original path. + // if (solution.Count == 0) + // { + // foreach (PathF path in this.solution) + // { + // solution.Add(path); + // } + // } } private void ExecuteInternal(float delta) diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs b/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs index bc4963cd..f1948185 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs @@ -6,6 +6,7 @@ using SixLabors.ImageSharp.Drawing.Tests.TestUtilities.ImageComparison; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; +using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Tests.Drawing; @@ -189,7 +190,7 @@ public void FillPolygon_StarCircle_AllOperations(TestImageProvider provi Star star = new(64, 64, 5, 24, 64); // See http://www.angusj.com/clipper2/Docs/Units/Clipper/Types/ClipType.htm for reference. - foreach (ClippingOperation operation in (ClippingOperation[])Enum.GetValues(typeof(ClippingOperation))) + foreach (BooleanOperation operation in (BooleanOperation[])Enum.GetValues(typeof(BooleanOperation))) { ShapeOptions options = new() { ClippingOperation = operation }; IPath shape = star.Clip(options, circle); diff --git a/tests/ImageSharp.Drawing.Tests/Processing/ShapeOptionsDefaultsExtensionsTests.cs b/tests/ImageSharp.Drawing.Tests/Processing/ShapeOptionsDefaultsExtensionsTests.cs index 28a20662..1853828e 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/ShapeOptionsDefaultsExtensionsTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/ShapeOptionsDefaultsExtensionsTests.cs @@ -3,6 +3,7 @@ using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Tests.Processing; @@ -27,7 +28,7 @@ public void UpdateDefaultOptionsOnProcessingContext_AlwaysNewInstance() { ShapeOptions option = new() { - ClippingOperation = ClippingOperation.Intersection, + ClippingOperation = BooleanOperation.Intersection, IntersectionRule = IntersectionRule.NonZero }; Configuration config = new(); @@ -36,18 +37,18 @@ public void UpdateDefaultOptionsOnProcessingContext_AlwaysNewInstance() context.SetShapeOptions(o => { - Assert.Equal(ClippingOperation.Intersection, o.ClippingOperation); // has original values + Assert.Equal(BooleanOperation.Intersection, o.ClippingOperation); // has original values Assert.Equal(IntersectionRule.NonZero, o.IntersectionRule); - o.ClippingOperation = ClippingOperation.Xor; + o.ClippingOperation = BooleanOperation.Xor; o.IntersectionRule = IntersectionRule.EvenOdd; }); ShapeOptions returnedOption = context.GetShapeOptions(); - Assert.Equal(ClippingOperation.Xor, returnedOption.ClippingOperation); + Assert.Equal(BooleanOperation.Xor, returnedOption.ClippingOperation); Assert.Equal(IntersectionRule.EvenOdd, returnedOption.IntersectionRule); - Assert.Equal(ClippingOperation.Intersection, option.ClippingOperation); // hasn't been mutated + Assert.Equal(BooleanOperation.Intersection, option.ClippingOperation); // hasn't been mutated Assert.Equal(IntersectionRule.NonZero, option.IntersectionRule); } @@ -67,7 +68,7 @@ public void UpdateDefaultOptionsOnConfiguration_AlwaysNewInstance() { ShapeOptions option = new() { - ClippingOperation = ClippingOperation.Intersection, + ClippingOperation = BooleanOperation.Intersection, IntersectionRule = IntersectionRule.NonZero }; Configuration config = new(); @@ -75,16 +76,16 @@ public void UpdateDefaultOptionsOnConfiguration_AlwaysNewInstance() config.SetShapeOptions(o => { - Assert.Equal(ClippingOperation.Intersection, o.ClippingOperation); // has original values + Assert.Equal(BooleanOperation.Intersection, o.ClippingOperation); // has original values Assert.Equal(IntersectionRule.NonZero, o.IntersectionRule); - o.ClippingOperation = ClippingOperation.Xor; + o.ClippingOperation = BooleanOperation.Xor; o.IntersectionRule = IntersectionRule.EvenOdd; }); ShapeOptions returnedOption = config.GetShapeOptions(); - Assert.Equal(ClippingOperation.Xor, returnedOption.ClippingOperation); + Assert.Equal(BooleanOperation.Xor, returnedOption.ClippingOperation); Assert.Equal(IntersectionRule.EvenOdd, returnedOption.IntersectionRule); - Assert.Equal(ClippingOperation.Intersection, option.ClippingOperation); // hasn't been mutated + Assert.Equal(BooleanOperation.Intersection, option.ClippingOperation); // hasn't been mutated Assert.Equal(IntersectionRule.NonZero, option.IntersectionRule); } diff --git a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs index 5d85c26a..eab1aee3 100644 --- a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs @@ -4,6 +4,7 @@ using System.Numerics; using SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; using SixLabors.ImageSharp.Drawing.Tests.TestUtilities; +using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Tests.PolygonClipper; @@ -38,7 +39,7 @@ private IEnumerable Clip(IPath shape, params IPath[] hole) } } - return clipper.GenerateClippedShapes(ClippingOperation.Difference, IntersectionRule.EvenOdd); + return clipper.GenerateClippedShapes(BooleanOperation.Difference); } [Fact] From 6c6ff30a5f7382f7d1057ebc672d23b7e75a18f3 Mon Sep 17 00:00:00 2001 From: Stefan Nikolei Date: Tue, 7 Oct 2025 14:20:28 +0200 Subject: [PATCH 02/18] First draft of PolygonOffsette First draft of implementing PolygonOffsetter Also added back the IntersectionRule to GenerateClippedShapes. It is not implemented yet --- .../Shapes/ClipPathExtensions.cs | 2 +- src/ImageSharp.Drawing/Shapes/ISimplePath.cs | 23 ++++++++++ .../Shapes/PolygonClipper/Clipper.cs | 13 +----- .../Shapes/PolygonClipper/PolygonOffsetter.cs | 45 ++++++++++++++++--- .../Shapes/PolygonClipper/ClipperTests.cs | 2 +- 5 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs index 398ea09e..690d2291 100644 --- a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs +++ b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs @@ -63,7 +63,7 @@ public static IPath Clip( clipper.AddPath(subjectPath, ClippingType.Subject); clipper.AddPaths(clipPaths, ClippingType.Clip); - IPath[] result = clipper.GenerateClippedShapes(options.ClippingOperation); + IPath[] result = clipper.GenerateClippedShapes(options.ClippingOperation, options.IntersectionRule); return new ComplexPolygon(result); } diff --git a/src/ImageSharp.Drawing/Shapes/ISimplePath.cs b/src/ImageSharp.Drawing/Shapes/ISimplePath.cs index 70727c95..29b85fd2 100644 --- a/src/ImageSharp.Drawing/Shapes/ISimplePath.cs +++ b/src/ImageSharp.Drawing/Shapes/ISimplePath.cs @@ -1,6 +1,11 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices.ComTypes; +using SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +using SixLabors.PolygonClipper; + namespace SixLabors.ImageSharp.Drawing; /// @@ -17,4 +22,22 @@ public interface ISimplePath /// Gets the points that make this up as a simple linear path. /// ReadOnlyMemory Points { get; } + + /// + /// Converts to + /// + /// The converted polygon. + internal SixLabors.PolygonClipper.Polygon ToPolygon() + { + SixLabors.PolygonClipper.Polygon polygon = []; + Contour contour = new(); + polygon.Add(contour); + + foreach (PointF point in this.Points.Span) + { + contour.AddVertex(new Vertex(point.X, point.Y)); + } + + return polygon; + } } diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs index 2f4c89cf..653ea92a 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs @@ -19,7 +19,7 @@ internal class Clipper /// The clipping operation. /// The intersection rule. /// The . - public IPath[] GenerateClippedShapes(BooleanOperation operation) + public IPath[] GenerateClippedShapes(BooleanOperation operation, IntersectionRule rule) { ArgumentNullException.ThrowIfNull(this.subject); ArgumentNullException.ThrowIfNull(this.clip); @@ -28,7 +28,6 @@ public IPath[] GenerateClippedShapes(BooleanOperation operation) SixLabors.PolygonClipper.Polygon result = polygonClipper.Run(); - IPath[] shapes = new IPath[result.Count]; int index = 0; @@ -86,15 +85,7 @@ public void AddPath(IPath path, ClippingType clippingType) /// Type of the poly. internal void AddPath(ISimplePath path, ClippingType clippingType) { - ReadOnlySpan vectors = path.Points.Span; - SixLabors.PolygonClipper.Polygon polygon = []; - Contour contour = new(); - polygon.Add(contour); - - foreach (PointF point in vectors) - { - contour.AddVertex(new Vertex(point.X, point.Y)); - } + SixLabors.PolygonClipper.Polygon polygon = path.ToPolygon(); switch (clippingType) { diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs index 17daf144..31bfc71a 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs @@ -3,6 +3,7 @@ using System.Numerics; using System.Runtime.CompilerServices; +using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; @@ -101,13 +102,25 @@ public void Execute(float delta, PathsF solution) // // // PolygonClipper will throw for unhandled exceptions but if a result is empty // // we should just return the original path. - // if (solution.Count == 0) - // { - // foreach (PathF path in this.solution) - // { - // solution.Add(path); - // } - // } + SixLabors.PolygonClipper.Polygon result = SixLabors.PolygonClipper.PolygonClipper.Union(this.solution.ToPolygon(), solution.ToPolygon()); + + if (result.Count == 0) + { + foreach (PathF path in this.solution) + { + solution.Add(path); + } + } + + foreach (Contour contour in result) + { + PathF path = new(contour.Count); + solution.Add(path); + foreach (Vertex vertex in contour) + { + path.Add(new Vector2((float)vertex.X, (float)vertex.Y)); + } + } } private void ExecuteInternal(float delta) @@ -680,6 +693,24 @@ public PathsF(int capacity) : base(capacity) { } + + internal SixLabors.PolygonClipper.Polygon ToPolygon() + { + SixLabors.PolygonClipper.Polygon polygon = []; + + foreach (PathF pathF in this) + { + Contour contour = new(); + polygon.Add(contour); + + foreach (Vector2 point in pathF) + { + contour.AddVertex(new Vertex(point.X, point.Y)); + } + } + + return polygon; + } } internal class PathF : List diff --git a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs index eab1aee3..6c1a69c6 100644 --- a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs @@ -39,7 +39,7 @@ private IEnumerable Clip(IPath shape, params IPath[] hole) } } - return clipper.GenerateClippedShapes(BooleanOperation.Difference); + return clipper.GenerateClippedShapes(BooleanOperation.Difference, IntersectionRule.EvenOdd); } [Fact] From 8f9696fff2c0891e06b34e23de41c75bc50ee1fb Mon Sep 17 00:00:00 2001 From: Stefan Nikolei Date: Tue, 7 Oct 2025 15:22:40 +0200 Subject: [PATCH 03/18] Fix ClipTest.Issue250 * Initialized clip and subject with empty polygon add a contour per path --- src/ImageSharp.Drawing/Shapes/ISimplePath.cs | 6 ++-- .../Shapes/PolygonClipper/Clipper.cs | 10 +++---- .../Shapes/PolygonClipper/FillRule.cs | 23 --------------- .../Shapes/PolygonClipper/JoinWith.cs | 29 ------------------- .../Shapes/PolygonClipper/VertexFlags.cs | 14 --------- 5 files changed, 7 insertions(+), 75 deletions(-) delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/FillRule.cs delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/JoinWith.cs delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexFlags.cs diff --git a/src/ImageSharp.Drawing/Shapes/ISimplePath.cs b/src/ImageSharp.Drawing/Shapes/ISimplePath.cs index 29b85fd2..f5e0a84e 100644 --- a/src/ImageSharp.Drawing/Shapes/ISimplePath.cs +++ b/src/ImageSharp.Drawing/Shapes/ISimplePath.cs @@ -27,17 +27,15 @@ public interface ISimplePath /// Converts to /// /// The converted polygon. - internal SixLabors.PolygonClipper.Polygon ToPolygon() + internal SixLabors.PolygonClipper.Contour ToContour() { - SixLabors.PolygonClipper.Polygon polygon = []; Contour contour = new(); - polygon.Add(contour); foreach (PointF point in this.Points.Span) { contour.AddVertex(new Vertex(point.X, point.Y)); } - return polygon; + return contour; } } diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs index 653ea92a..bd968de5 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs @@ -10,8 +10,8 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; /// internal class Clipper { - private SixLabors.PolygonClipper.Polygon? subject; - private SixLabors.PolygonClipper.Polygon? clip; + private SixLabors.PolygonClipper.Polygon subject = []; + private SixLabors.PolygonClipper.Polygon clip = []; /// /// Generates the clipped shapes from the previously provided paths. @@ -85,15 +85,15 @@ public void AddPath(IPath path, ClippingType clippingType) /// Type of the poly. internal void AddPath(ISimplePath path, ClippingType clippingType) { - SixLabors.PolygonClipper.Polygon polygon = path.ToPolygon(); + Contour contour = path.ToContour(); switch (clippingType) { case ClippingType.Clip: - this.clip = polygon; + this.clip.Add(contour); break; case ClippingType.Subject: - this.subject = polygon; + this.subject.Add(contour); break; default: throw new ArgumentOutOfRangeException(nameof(clippingType), clippingType, null); diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/FillRule.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/FillRule.cs deleted file mode 100644 index a4f42b29..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/FillRule.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -using SixLabors.ImageSharp.Drawing.Shapes.Rasterization; - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; - -/// -/// By far the most widely used filling rules for polygons are EvenOdd -/// and NonZero, sometimes called Alternate and Winding respectively. -/// -/// -/// -/// TODO: This overlaps with the enum. -/// We should see if we can enhance the to support all these rules. -/// -internal enum FillRule -{ - EvenOdd, - NonZero, - Positive, - Negative -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/JoinWith.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/JoinWith.cs deleted file mode 100644 index acfbef55..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/JoinWith.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; - -internal enum JoinWith -{ - None, - Left, - Right -} - -internal enum HorzPosition -{ - Bottom, - Middle, - Top -} - -// Vertex: a pre-clipping data structure. It is used to separate polygons -// into ascending and descending 'bounds' (or sides) that start at local -// minima and ascend to a local maxima, before descending again. -[Flags] -internal enum PointInPolygonResult -{ - IsOn = 0, - IsInside = 1, - IsOutside = 2 -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexFlags.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexFlags.cs deleted file mode 100644 index 2a990ecf..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexFlags.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; - -[Flags] -internal enum VertexFlags -{ - None = 0, - OpenStart = 1, - OpenEnd = 2, - LocalMax = 4, - LocalMin = 8 -} From 087f4809b7fca02a1724897e00f851b5ef56fa4d Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 8 Oct 2025 14:22:03 +1000 Subject: [PATCH 04/18] Wire up factory --- .../Shapes/ClipPathExtensions.cs | 4 +- .../Shapes/PolygonClipper/Clipper.cs | 69 ++-- .../PolygonClipper/PolygonClipperFactory.cs | 358 ++++++++++++++++++ .../Drawing/FillPolygonTests.cs | 22 +- .../Shapes/PolygonClipper/ClipperTests.cs | 9 +- 5 files changed, 410 insertions(+), 52 deletions(-) create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs diff --git a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs index 690d2291..15812355 100644 --- a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs +++ b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs @@ -58,12 +58,12 @@ public static IPath Clip( ShapeOptions options, IEnumerable clipPaths) { - Clipper clipper = new(); + Clipper clipper = new(options.IntersectionRule); clipper.AddPath(subjectPath, ClippingType.Subject); clipper.AddPaths(clipPaths, ClippingType.Clip); - IPath[] result = clipper.GenerateClippedShapes(options.ClippingOperation, options.IntersectionRule); + IPath[] result = clipper.GenerateClippedShapes(options.ClippingOperation); return new ComplexPolygon(result); } diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs index 653ea92a..2334a6f6 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs @@ -2,31 +2,39 @@ // Licensed under the Six Labors Split License. using SixLabors.PolygonClipper; +using ClipperPolygon = SixLabors.PolygonClipper.Polygon; +using PolygonClipperAction = SixLabors.PolygonClipper.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; /// -/// Library to clip polygons. +/// Performs polygon clipping operations. /// -internal class Clipper +internal sealed class Clipper { - private SixLabors.PolygonClipper.Polygon? subject; - private SixLabors.PolygonClipper.Polygon? clip; + private ClipperPolygon? subject; + private ClipperPolygon? clip; + private readonly IntersectionRule rule; + + /// + /// Initializes a new instance of the class. + /// + /// The intersection rule. + public Clipper(IntersectionRule rule) => this.rule = rule; /// /// Generates the clipped shapes from the previously provided paths. /// /// The clipping operation. - /// The intersection rule. /// The . - public IPath[] GenerateClippedShapes(BooleanOperation operation, IntersectionRule rule) + public IPath[] GenerateClippedShapes(BooleanOperation operation) { ArgumentNullException.ThrowIfNull(this.subject); ArgumentNullException.ThrowIfNull(this.clip); - SixLabors.PolygonClipper.PolygonClipper polygonClipper = new(this.subject, this.clip, operation); + PolygonClipperAction polygonClipper = new(this.subject, this.clip, operation); - SixLabors.PolygonClipper.Polygon result = polygonClipper.Run(); + ClipperPolygon result = polygonClipper.Run(); IPath[] shapes = new IPath[result.Count]; @@ -49,7 +57,7 @@ public IPath[] GenerateClippedShapes(BooleanOperation operation, IntersectionRul } /// - /// Adds the shapes. + /// Adds the collection of paths. /// /// The paths. /// The clipping type. @@ -57,9 +65,21 @@ public void AddPaths(IEnumerable paths, ClippingType clippingType) { Guard.NotNull(paths, nameof(paths)); - foreach (IPath p in paths) + // Accumulate all paths of the complex shape into a single polygon. + ClipperPolygon polygon = []; + + foreach (IPath path in paths) { - this.AddPath(p, clippingType); + polygon = PolygonClipperFactory.FromSimplePaths(path.Flatten(), this.rule, polygon); + } + + if (clippingType == ClippingType.Clip) + { + this.clip = polygon; + } + else + { + this.subject = polygon; } } @@ -72,31 +92,14 @@ public void AddPath(IPath path, ClippingType clippingType) { Guard.NotNull(path, nameof(path)); - foreach (ISimplePath p in path.Flatten()) + ClipperPolygon polygon = PolygonClipperFactory.FromSimplePaths(path.Flatten(), this.rule); + if (clippingType == ClippingType.Clip) { - this.AddPath(p, clippingType); + this.clip = polygon; } - } - - /// - /// Adds the path. - /// - /// The path. - /// Type of the poly. - internal void AddPath(ISimplePath path, ClippingType clippingType) - { - SixLabors.PolygonClipper.Polygon polygon = path.ToPolygon(); - - switch (clippingType) + else { - case ClippingType.Clip: - this.clip = polygon; - break; - case ClippingType.Subject: - this.subject = polygon; - break; - default: - throw new ArgumentOutOfRangeException(nameof(clippingType), clippingType, null); + this.subject = polygon; } } } diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs new file mode 100644 index 00000000..4b50d7d1 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs @@ -0,0 +1,358 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.PolygonClipper; +using ClipperPolygon = SixLabors.PolygonClipper.Polygon; + +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; + +/// +/// Builders for from ImageSharp paths. +/// PolygonClipper requires explicit orientation and nesting of contours ImageSharp polygons do not contain that information +/// so we must derive that from the input. +/// +internal static class PolygonClipperFactory +{ + /// + /// Builds a from closed rings. + /// + /// + /// Pipeline: + /// 1) Filter to closed paths with ≥3 unique points, copy to rings. + /// 2) Compute signed area via the shoelace formula to get orientation and magnitude. + /// 3) For each ring, pick its lexicographic bottom-left vertex. + /// 4) Parent assignment: for ring i, shoot a conceptual vertical ray downward from its bottom-left point + /// and test containment against all other rings using the selected . + /// The parent is the smallest-area ring that contains the point. + /// 5) Depth is the number of ancestors by repeated parent lookup. + /// 6) Materialize s, enforce even depth CCW and odd depth CW, + /// set and , add to and wire holes. + /// Notes: + /// - Step 4 mirrors the parent-detection approach formalized in Martínez–Rueda 2013. + /// - Containment uses Even-Odd or Non-Zero consistently, so glyph-like inputs can use Non-Zero. + /// - Boundary handling: points exactly on edges are not special-cased here, which is typical for nesting. + /// + /// Closed simple paths. + /// Containment rule for nesting, or . + /// Optional existing polygon to populate. + /// The constructed . + public static ClipperPolygon FromSimplePaths(IEnumerable paths, IntersectionRule rule, ClipperPolygon? polygon = null) + { + // Gather rings as Vertex lists (explicitly closed), plus per-ring metadata. + List> rings = []; + List areas = []; + List bottomLeft = []; + + foreach (ISimplePath p in paths) + { + if (!p.IsClosed) + { + // TODO: could append first point to close, but that fabricates geometry. + continue; + } + + ReadOnlySpan s = p.Points.Span; + int n = s.Length; + + // Need at least 3 points to form area. + if (n < 3) + { + continue; + } + + // Copy all points as-is. + List ring = new(n); + for (int i = 0; i < n; i++) + { + ring.Add(new Vertex(s[i].X, s[i].Y)); + } + + // Ensure explicit closure: start == end. + if (ring.Count > 0) + { + Vertex first = ring[0]; + Vertex last = ring[^1]; + if (first.X != last.X || first.Y != last.Y) + { + ring.Add(first); + } + } + + // After closure, still require at least 3 unique vertices. + if (ring.Count < 4) // 3 unique + repeated first == last + { + continue; + } + + rings.Add(ring); + + // SignedArea must handle a closed ring (last == first). + areas.Add(SignedArea(ring)); + + // Choose lexicographic bottom-left vertex index for nesting test. + bottomLeft.Add(IndexOfBottomLeft(ring)); + } + + int m = rings.Count; + if (m == 0) + { + return []; + } + + // Parent assignment: pick the smallest-area ring that contains the bottom-left vertex. + int[] parent = new int[m]; + Array.Fill(parent, -1); + + for (int i = 0; i < m; i++) + { + Vertex q = rings[i][bottomLeft[i]]; + int best = -1; + double bestArea = double.MaxValue; + + for (int j = 0; j < m; j++) + { + if (i == j) + { + continue; + } + + if (IsPointInPolygon(q, rings[j], rule)) + { + double a = Math.Abs(areas[j]); + if (a < bestArea) + { + bestArea = a; + best = j; + } + } + } + + parent[i] = best; + } + + // Depth = number of ancestors by following Parent links. + int[] depth = new int[m]; + for (int i = 0; i < m; i++) + { + int d = 0; + for (int pIdx = parent[i]; pIdx >= 0; pIdx = parent[pIdx]) + { + d++; + } + + depth[i] = d; + } + + // Emit contours, enforce orientation by depth, and wire into polygon. + polygon ??= []; + for (int i = 0; i < m; i++) + { + Contour c = new(); + + // Stream vertices into the contour. Ring is already explicitly closed. + foreach (Vertex v in rings[i]) + { + c.AddVertex(v); + } + + // Orientation convention: even depth = outer => CCW, odd depth = hole => CW. + if ((depth[i] & 1) == 0) + { + c.SetCounterClockwise(); + } + else + { + c.SetClockwise(); + } + + // Topology annotations. + c.ParentIndex = parent[i] >= 0 ? parent[i] : null; + c.Depth = depth[i]; + + polygon.Add(c); + } + + // Record hole indices for parents now that indices are stable. + for (int i = 0; i < m; i++) + { + int pIdx = parent[i]; + if (pIdx >= 0) + { + polygon[pIdx].AddHoleIndex(i); + } + } + + return polygon; + } + + /// + /// Computes the signed area of a closed ring using the shoelace formula. + /// + /// Ring of vertices. + /// + /// Formula: + /// + /// A = 0.5 * Σ cross(v[j], v[i]) with j = (i - 1) mod n + /// + /// where cross(a,b) = a.X * b.Y - a.Y * b.X. + /// Interpretation: + /// - A > 0 means counter-clockwise orientation. + /// - A < 0 means clockwise orientation. + /// + private static double SignedArea(List r) + { + double area = 0d; + + for (int i = 0, j = r.Count - 1; i < r.Count; j = i, i++) + { + area += Vertex.Cross(r[j], r[i]); + } + + return 0.5d * area; + } + + /// + /// Returns the index of the lexicographically bottom-left vertex. + /// + /// Ring of vertices. + /// + /// Lexicographic order (X then Y) yields a unique seed for nesting tests and matches + /// common parent-detection proofs that cast a ray from the lowest-leftmost point. + /// + private static int IndexOfBottomLeft(List r) + { + int k = 0; + + for (int i = 1; i < r.Count; i++) + { + Vertex a = r[i]; + Vertex b = r[k]; + + if (a.X < b.X || (a.X == b.X && a.Y < b.Y)) + { + k = i; + } + } + + return k; + } + + /// + /// Dispatches to the selected point-in-polygon implementation. + /// + /// Query point. + /// Closed ring. + /// Fill rule. + private static bool IsPointInPolygon(in Vertex p, List ring, IntersectionRule rule) + { + if (rule == IntersectionRule.EvenOdd) + { + return PointInPolygonEvenOdd(p, ring); + } + + return PointInPolygonNonZero(p, ring); + } + + /// + /// Even-odd point-in-polygon via ray casting. + /// + /// Query point. + /// Closed ring. + /// + /// Let a horizontal ray start at and extend to +∞ in X. + /// For each edge (a→b), count an intersection if the edge straddles the ray’s Y + /// and the ray’s X is strictly less than the edge’s X at that Y: + /// + /// intersects = ((b.Y > p.Y) != (a.Y > p.Y)) amp;& p.X < x_at_pY(a,b) + /// + /// Parity of the count determines interior. + /// Horizontal edges contribute zero because the straddle test excludes equal Y. + /// Using a half-open interval on Y prevents double-counting shared vertices. + /// + private static bool PointInPolygonEvenOdd(in Vertex p, List ring) + { + bool inside = false; + int n = ring.Count; + int j = n - 1; + + for (int i = 0; i < n; j = i, i++) + { + Vertex a = ring[j]; + Vertex b = ring[i]; + + bool straddles = (b.Y > p.Y) != (a.Y > p.Y); + + if (straddles) + { + double ySpan = a.Y - b.Y; + double xAtPY = (((a.X - b.X) * (p.Y - b.Y)) / (ySpan == 0d ? double.Epsilon : ySpan)) + b.X; + + if (p.X < xAtPY) + { + inside = !inside; + } + } + } + + return inside; + } + + /// + /// Non-zero winding point-in-polygon. + /// + /// Query point. + /// Closed ring. + /// + /// Scan all edges (a→b). + /// - If the edge crosses the scanline upward (a.Y ≤ p.Y && b.Y > p.Y) and + /// lies strictly to the left of the edge, increment the winding. + /// - If it crosses downward (a.Y > p.Y && b.Y ≤ p.Y) and + /// lies strictly to the right, decrement the winding. + /// The point is inside iff the winding number is non-zero. + /// Left/right is decided by the sign of the cross product of vectors a→b and a→p. + /// + private static bool PointInPolygonNonZero(in Vertex p, List ring) + { + int winding = 0; + int n = ring.Count; + + for (int i = 0, j = n - 1; i < n; j = i, i++) + { + Vertex a = ring[j]; + Vertex b = ring[i]; + + if (a.Y <= p.Y) + { + if (b.Y > p.Y && IsLeft(a, b, p)) + { + winding++; + } + } + else if (b.Y <= p.Y && !IsLeft(a, b, p)) + { + winding--; + } + } + + return winding != 0; + } + + /// + /// Returns true if is strictly left of the directed edge a→b. + /// + /// Edge start. + /// Edge end. + /// Query point. + /// + /// Tests the sign of the 2D cross product: + /// + /// cross = (b - a) × (p - a) = (b.X - a.X)*(p.Y - a.Y) - (b.Y - a.Y)*(p.X - a.X) + /// + /// Left if cross > 0, right if cross < 0, collinear if cross == 0. + /// + private static bool IsLeft(Vertex a, Vertex b, Vertex p) + { + double cross = ((b.X - a.X) * (p.Y - a.Y)) - ((b.Y - a.Y) * (p.X - a.X)); + return cross > 0d; + } +} diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs b/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs index f1948185..f4f45bc9 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs @@ -28,9 +28,9 @@ public void FillPolygon_Solid_Basic(TestImageProvider provider, c => c.SetGraphicsOptions(options) .FillPolygon(Color.White, polygon1) .FillPolygon(Color.White, polygon2), + testOutputDetails: $"aa{antialias}", appendPixelTypeToFileName: false, - appendSourceFileOrDescription: false, - testOutputDetails: $"aa{antialias}"); + appendSourceFileOrDescription: false); } [Theory] @@ -178,8 +178,8 @@ public void FillPolygon_StarCircle(TestImageProvider provider) provider.RunValidatingProcessorTest( c => c.Fill(Color.White, shape), comparer: ImageComparer.TolerantPercentage(0.01f), - appendSourceFileOrDescription: false, - appendPixelTypeToFileName: false); + appendPixelTypeToFileName: false, + appendSourceFileOrDescription: false); } [Theory] @@ -197,10 +197,10 @@ public void FillPolygon_StarCircle_AllOperations(TestImageProvider provi provider.RunValidatingProcessorTest( c => c.Fill(Color.DeepPink, circle).Fill(Color.LightGray, star).Fill(Color.ForestGreen, shape), - comparer: ImageComparer.TolerantPercentage(0.01F), testOutputDetails: operation.ToString(), - appendSourceFileOrDescription: false, - appendPixelTypeToFileName: false); + comparer: ImageComparer.TolerantPercentage(0.01F), + appendPixelTypeToFileName: false, + appendSourceFileOrDescription: false); } } @@ -301,8 +301,8 @@ public void Fill_RegularPolygon(TestImageProvider provider, int provider.RunValidatingProcessorTest( c => c.Fill(color, polygon), testOutput, - appendSourceFileOrDescription: false, - appendPixelTypeToFileName: false); + appendPixelTypeToFileName: false, + appendSourceFileOrDescription: false); } public static readonly TheoryData Fill_EllipsePolygon_Data = @@ -337,8 +337,8 @@ public void Fill_EllipsePolygon(TestImageProvider provider, bool c.Fill(color, polygon); }, testOutputDetails: $"Reverse({reverse})_IntersectionRule({intersectionRule})", - appendSourceFileOrDescription: false, - appendPixelTypeToFileName: false); + appendPixelTypeToFileName: false, + appendSourceFileOrDescription: false); } [Theory] diff --git a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs index 6c1a69c6..2c5961d7 100644 --- a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs @@ -28,18 +28,15 @@ public class ClipperTests private IEnumerable Clip(IPath shape, params IPath[] hole) { - Clipper clipper = new(); + Clipper clipper = new(IntersectionRule.EvenOdd); clipper.AddPath(shape, ClippingType.Subject); if (hole != null) { - foreach (IPath s in hole) - { - clipper.AddPath(s, ClippingType.Clip); - } + clipper.AddPaths(hole, ClippingType.Clip); } - return clipper.GenerateClippedShapes(BooleanOperation.Difference, IntersectionRule.EvenOdd); + return clipper.GenerateClippedShapes(BooleanOperation.Difference); } [Fact] From ef89dd068b6cb20f8712dc70f49128a7754834bd Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 8 Oct 2025 21:49:42 +1000 Subject: [PATCH 05/18] Use Vertex.Cross --- .../Shapes/PolygonClipper/PolygonClipperFactory.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs index 4b50d7d1..8a26ae42 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Runtime.CompilerServices; using SixLabors.PolygonClipper; using ClipperPolygon = SixLabors.PolygonClipper.Polygon; @@ -100,6 +101,7 @@ public static ClipperPolygon FromSimplePaths(IEnumerable paths, Int } // Parent assignment: pick the smallest-area ring that contains the bottom-left vertex. + // TODO: We can use pooling here if we care about large numbers of rings. int[] parent = new int[m]; Array.Fill(parent, -1); @@ -131,6 +133,7 @@ public static ClipperPolygon FromSimplePaths(IEnumerable paths, Int } // Depth = number of ancestors by following Parent links. + // TODO: We can pool this if we care about large numbers of rings. int[] depth = new int[m]; for (int i = 0; i < m; i++) { @@ -350,9 +353,6 @@ private static bool PointInPolygonNonZero(in Vertex p, List ring) /// /// Left if cross > 0, right if cross < 0, collinear if cross == 0. /// - private static bool IsLeft(Vertex a, Vertex b, Vertex p) - { - double cross = ((b.X - a.X) * (p.Y - a.Y)) - ((b.Y - a.Y) * (p.X - a.X)); - return cross > 0d; - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsLeft(Vertex a, Vertex b, Vertex p) => Vertex.Cross(b - a, p - a) > 0d; } From 5b2179b5447a79fcd88a8e2a690b7a4dded39b07 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Fri, 31 Oct 2025 23:18:37 +1000 Subject: [PATCH 06/18] Replace PolygonOffsetter with PolygonStroker from AGG --- ImageSharp.Drawing.sln | 10 +- .../Shapes/ClipPathExtensions.cs | 6 +- src/ImageSharp.Drawing/Shapes/EndCapStyle.cs | 17 + src/ImageSharp.Drawing/Shapes/IPath.cs | 10 +- src/ImageSharp.Drawing/Shapes/JointStyle.cs | 53 ++ .../Shapes/OutlinePathExtensions.cs | 154 ++-- src/ImageSharp.Drawing/Shapes/Polygon.cs | 14 + .../Shapes/PolygonClipper/BoundsF.cs | 90 --- .../{Clipper.cs => ClippedShapeGenerator.cs} | 50 +- .../Shapes/PolygonClipper/ClipperException.cs | 37 - .../Shapes/PolygonClipper/ClipperOffset.cs | 84 -- .../Shapes/PolygonClipper/ClipperUtils.cs | 236 ------ .../PolygonClipper/PolygonClipperFactory.cs | 26 + .../Shapes/PolygonClipper/PolygonOffsetter.cs | 731 ------------------ .../Shapes/PolygonClipper/PolygonStroker.cs | 70 +- .../PolygonClipper/StrokedShapeGenerator.cs | 207 +++++ .../Shapes/PolygonClipper/VertexDistance.cs | 2 + .../Shapes/PolygonClipper/ClipperTests.cs | 2 +- .../RectangularPolygonValueComparer.cs | 4 +- 19 files changed, 493 insertions(+), 1310 deletions(-) delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/BoundsF.cs rename src/ImageSharp.Drawing/Shapes/PolygonClipper/{Clipper.cs => ClippedShapeGenerator.cs} (55%) delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperException.cs delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperOffset.cs delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperUtils.cs delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonClipper/StrokedShapeGenerator.cs diff --git a/ImageSharp.Drawing.sln b/ImageSharp.Drawing.sln index 3f753f6c..74e8e154 100644 --- a/ImageSharp.Drawing.sln +++ b/ImageSharp.Drawing.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11123.170 d18.0 +VisualStudioVersion = 18.0.11123.170 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_root", "_root", "{C317F1B1-D75E-4C6D-83EB-80367343E0D7}" ProjectSection(SolutionItems) = preProject @@ -359,14 +359,6 @@ Global {5493F024-0A3F-420C-AC2D-05B77A36025B}.Debug|Any CPU.Build.0 = Debug|Any CPU {5493F024-0A3F-420C-AC2D-05B77A36025B}.Release|Any CPU.ActiveCfg = Release|Any CPU {5493F024-0A3F-420C-AC2D-05B77A36025B}.Release|Any CPU.Build.0 = Release|Any CPU - {FCEDD229-22BC-4B82-87DE-786BBFC52EDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FCEDD229-22BC-4B82-87DE-786BBFC52EDE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FCEDD229-22BC-4B82-87DE-786BBFC52EDE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FCEDD229-22BC-4B82-87DE-786BBFC52EDE}.Release|Any CPU.Build.0 = Release|Any CPU - {5490DFAF-0891-535F-08B4-2BF03C2BB778}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5490DFAF-0891-535F-08B4-2BF03C2BB778}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5490DFAF-0891-535F-08B4-2BF03C2BB778}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5490DFAF-0891-535F-08B4-2BF03C2BB778}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs index 15812355..37506211 100644 --- a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs +++ b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs @@ -17,7 +17,6 @@ public static class ClipPathExtensions /// The subject path. /// The clipping paths. /// The clipped . - /// Thrown when an error occurred while attempting to clip the polygon. public static IPath Clip(this IPath subjectPath, params IPath[] clipPaths) => subjectPath.Clip((IEnumerable)clipPaths); @@ -28,7 +27,6 @@ public static IPath Clip(this IPath subjectPath, params IPath[] clipPaths) /// The shape options. /// The clipping paths. /// The clipped . - /// Thrown when an error occurred while attempting to clip the polygon. public static IPath Clip( this IPath subjectPath, ShapeOptions options, @@ -41,7 +39,6 @@ public static IPath Clip( /// The subject path. /// The clipping paths. /// The clipped . - /// Thrown when an error occurred while attempting to clip the polygon. public static IPath Clip(this IPath subjectPath, IEnumerable clipPaths) => subjectPath.Clip(new ShapeOptions(), clipPaths); @@ -52,13 +49,12 @@ public static IPath Clip(this IPath subjectPath, IEnumerable clipPaths) /// The shape options. /// The clipping paths. /// The clipped . - /// Thrown when an error occurred while attempting to clip the polygon. public static IPath Clip( this IPath subjectPath, ShapeOptions options, IEnumerable clipPaths) { - Clipper clipper = new(options.IntersectionRule); + ClippedShapeGenerator clipper = new(options.IntersectionRule); clipper.AddPath(subjectPath, ClippingType.Subject); clipper.AddPaths(clipPaths, ClippingType.Clip); diff --git a/src/ImageSharp.Drawing/Shapes/EndCapStyle.cs b/src/ImageSharp.Drawing/Shapes/EndCapStyle.cs index 50607e20..f5d8d0f5 100644 --- a/src/ImageSharp.Drawing/Shapes/EndCapStyle.cs +++ b/src/ImageSharp.Drawing/Shapes/EndCapStyle.cs @@ -34,9 +34,26 @@ public enum EndCapStyle Joined = 4 } +/// +/// Specifies the shape to be used at the ends of open lines or paths when stroking. +/// internal enum LineCap { + /// + /// The stroke ends exactly at the endpoint. + /// No extension is added beyond the path's end coordinates. + /// Butt, + + /// + /// The stroke extends beyond the endpoint by half the line width, + /// producing a square edge. + /// Square, + + /// + /// The stroke ends with a semicircular cap, + /// extending beyond the endpoint by half the line width. + /// Round } diff --git a/src/ImageSharp.Drawing/Shapes/IPath.cs b/src/ImageSharp.Drawing/Shapes/IPath.cs index 755f53d7..4e8be584 100644 --- a/src/ImageSharp.Drawing/Shapes/IPath.cs +++ b/src/ImageSharp.Drawing/Shapes/IPath.cs @@ -13,29 +13,29 @@ public interface IPath /// /// Gets a value indicating whether this instance is closed, open or a composite path with a mixture of open and closed figures. /// - PathTypes PathType { get; } + public PathTypes PathType { get; } /// /// Gets the bounds enclosing the path. /// - RectangleF Bounds { get; } + public RectangleF Bounds { get; } /// /// Converts the into a simple linear path. /// /// Returns the current as simple linear path. - IEnumerable Flatten(); + public IEnumerable Flatten(); /// /// Transforms the path using the specified matrix. /// /// The matrix. /// A new path with the matrix applied to it. - IPath Transform(Matrix3x2 matrix); + public IPath Transform(Matrix3x2 matrix); /// /// Returns this path with all figures closed. /// /// A new close . - IPath AsClosedPath(); + public IPath AsClosedPath(); } diff --git a/src/ImageSharp.Drawing/Shapes/JointStyle.cs b/src/ImageSharp.Drawing/Shapes/JointStyle.cs index c1464824..d3d4d58e 100644 --- a/src/ImageSharp.Drawing/Shapes/JointStyle.cs +++ b/src/ImageSharp.Drawing/Shapes/JointStyle.cs @@ -24,19 +24,72 @@ public enum JointStyle Miter = 2 } +/// +/// Specifies how the connection between two consecutive line segments (a join) +/// is rendered when stroking paths or polygons. +/// internal enum LineJoin { + /// + /// Joins lines by extending their outer edges until they meet at a sharp corner. + /// The miter length is limited by the miter limit; if exceeded, the join may fall back to a bevel. + /// MiterJoin = 0, + + /// + /// Joins lines by extending their outer edges to form a miter, + /// but if the miter length exceeds the miter limit, the join is truncated + /// at the limit distance rather than falling back to a bevel. + /// MiterJoinRevert = 1, + + /// + /// Joins lines by connecting them with a circular arc centered at the join point, + /// producing a smooth, rounded corner. + /// RoundJoin = 2, + + /// + /// Joins lines by connecting the outer corners directly with a straight line, + /// forming a flat edge at the join point. + /// BevelJoin = 3, + + /// + /// Joins lines by forming a miter, but if the miter limit is exceeded, + /// the join falls back to a round join instead of a bevel. + /// MiterJoinRound = 4 } +/// +/// Specifies how inner corners of a stroked path or polygon are rendered +/// when the path turns sharply inward. These settings control how the interior +/// edge of the stroke is joined at such corners. +/// internal enum InnerJoin { + /// + /// Joins inner corners by connecting the edges with a straight line, + /// producing a flat, beveled appearance. + /// InnerBevel, + + /// + /// Joins inner corners by extending the inner edges until they meet at a sharp point. + /// This can create long, narrow joins for acute angles. + /// InnerMiter, + + /// + /// Joins inner corners with a notched appearance, + /// forming a small cut or indentation at the join. + /// InnerJag, + + /// + /// Joins inner corners using a circular arc between the edges, + /// creating a smooth, rounded interior transition. + /// InnerRound } diff --git a/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs b/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs index 29213304..fa88e5c4 100644 --- a/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs +++ b/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs @@ -2,7 +2,6 @@ // Licensed under the Six Labors Split License. using System.Numerics; -using System.Runtime.InteropServices; using SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; namespace SixLabors.ImageSharp.Drawing; @@ -16,38 +15,12 @@ public static class OutlinePathExtensions private const JointStyle DefaultJointStyle = JointStyle.Square; private const EndCapStyle DefaultEndCapStyle = EndCapStyle.Butt; - /// - /// Calculates the scaling matrixes tha tmust be applied to the inout and output paths of for successful clipping. - /// - /// the requested width - /// The matrix to apply to the input path - /// The matrix to apply to the output path - /// The final width to use internally to outlining - private static float CalculateScalingMatrix(float width, out Matrix3x2 scaleUpMartrix, out Matrix3x2 scaleDownMartrix) - { - // when the thickness is below a 0.5 threshold we need to scale - // the source path (up) and result path (down) by a factor to ensure - // the offest is greater than 0.5 to ensure offsetting isn't skipped. - scaleUpMartrix = Matrix3x2.Identity; - scaleDownMartrix = Matrix3x2.Identity; - if (width < 0.5) - { - float scale = 1 / width; - scaleUpMartrix = Matrix3x2.CreateScale(scale); - scaleDownMartrix = Matrix3x2.CreateScale(width); - width = 1; - } - - return width; - } - /// /// Generates an outline of the path. /// /// The path to outline /// The outline width. /// A new representing the outline. - /// Thrown when an offset cannot be calculated. public static IPath GenerateOutline(this IPath path, float width) => GenerateOutline(path, width, DefaultJointStyle, DefaultEndCapStyle); @@ -59,7 +32,6 @@ public static IPath GenerateOutline(this IPath path, float width) /// The style to apply to the joints. /// The style to apply to the end caps. /// A new representing the outline. - /// Thrown when an offset cannot be calculated. public static IPath GenerateOutline(this IPath path, float width, JointStyle jointStyle, EndCapStyle endCapStyle) { if (width <= 0) @@ -67,14 +39,8 @@ public static IPath GenerateOutline(this IPath path, float width, JointStyle joi return Path.Empty; } - width = CalculateScalingMatrix(width, out Matrix3x2 scaleUpMartrix, out Matrix3x2 scaleDownMartrix); - - ClipperOffset offset = new(MiterOffsetDelta); - - // transform is noop for Matrix3x2.Identity - offset.AddPath(path.Transform(scaleUpMartrix), jointStyle, endCapStyle); - - return offset.Execute(width).Transform(scaleDownMartrix); + StrokedShapeGenerator generator = new(MiterOffsetDelta); + return new ComplexPolygon(generator.GenerateStrokedShapes(path, width)); } /// @@ -84,7 +50,6 @@ public static IPath GenerateOutline(this IPath path, float width, JointStyle joi /// The outline width. /// The pattern made of multiples of the width. /// A new representing the outline. - /// Thrown when an offset cannot be calculated. public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan pattern) => path.GenerateOutline(width, pattern, false); @@ -96,7 +61,6 @@ public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpanThe pattern made of multiples of the width. /// Whether the first item in the pattern is on or off. /// A new representing the outline. - /// Thrown when an offset cannot be calculated. public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan pattern, bool startOff) => GenerateOutline(path, width, pattern, startOff, DefaultJointStyle, DefaultEndCapStyle); @@ -109,7 +73,6 @@ public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpanThe style to apply to the joints. /// The style to apply to the end caps. /// A new representing the outline. - /// Thrown when an offset cannot be calculated. public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan pattern, JointStyle jointStyle, EndCapStyle endCapStyle) => GenerateOutline(path, width, pattern, false, jointStyle, endCapStyle); @@ -123,7 +86,6 @@ public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpanThe style to apply to the joints. /// The style to apply to the end caps. /// A new representing the outline. - /// Thrown when an offset cannot be calculated. public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan pattern, bool startOff, JointStyle jointStyle, EndCapStyle endCapStyle) { if (width <= 0) @@ -136,88 +98,110 @@ public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan paths = path.Flatten(); - IEnumerable paths = path.Transform(scaleUpMartrix).Flatten(); + List outlines = []; + List buffer = new(64); // arbitrary initial capacity hint. - ClipperOffset offset = new(MiterOffsetDelta); - List buffer = []; foreach (ISimplePath p in paths) { bool online = !startOff; - float targetLength = pattern[0] * width; int patternPos = 0; - ReadOnlySpan points = p.Points.Span; + float targetLength = pattern[patternPos] * width; - // Create a new list of points representing the new outline - int pCount = points.Length; - if (!p.IsClosed) + ReadOnlySpan pts = p.Points.Span; + if (pts.Length < 2) { - pCount--; + continue; } + // number of edges to traverse (no wrap for open paths) + int edgeCount = p.IsClosed ? pts.Length : pts.Length - 1; + int i = 0; - Vector2 currentPoint = points[0]; + Vector2 current = pts[0]; - while (i < pCount) + while (i < edgeCount) { - int next = (i + 1) % points.Length; - Vector2 targetPoint = points[next]; - float distToNext = Vector2.Distance(currentPoint, targetPoint); - if (distToNext > targetLength) + int nextIndex = p.IsClosed ? (i + 1) % pts.Length : i + 1; + Vector2 next = pts[nextIndex]; + float segLen = Vector2.Distance(current, next); + + if (segLen <= eps) { - // find a point between the 2 - float t = targetLength / distToNext; + current = next; + i++; + continue; + } + + if (segLen + eps < targetLength) + { + buffer.Add(current); + current = next; + i++; + targetLength -= segLen; + continue; + } - Vector2 point = (currentPoint * (1 - t)) + (targetPoint * t); - buffer.Add(currentPoint); - buffer.Add(point); + if (MathF.Abs(segLen - targetLength) <= eps) + { + buffer.Add(current); + buffer.Add(next); - // we now inset a line joining - if (online) + if (online && buffer.Count >= 2 && buffer[0] != buffer[^1]) { - offset.AddPath(CollectionsMarshal.AsSpan(buffer), jointStyle, endCapStyle); + outlines.Add([.. buffer]); } - online = !online; - buffer.Clear(); + online = !online; - currentPoint = point; - - // next length + current = next; + i++; patternPos = (patternPos + 1) % pattern.Length; targetLength = pattern[patternPos] * width; + continue; } - else if (distToNext <= targetLength) + + // split inside this segment + float t = targetLength / segLen; // 0 < t < 1 here + Vector2 split = current + (t * (next - current)); + + buffer.Add(current); + buffer.Add(split); + + if (online && buffer.Count >= 2 && buffer[0] != buffer[^1]) { - buffer.Add(currentPoint); - currentPoint = targetPoint; - i++; - targetLength -= distToNext; + outlines.Add([.. buffer]); } + + buffer.Clear(); + online = !online; + + current = split; // continue along the same geometric segment + + patternPos = (patternPos + 1) % pattern.Length; + targetLength = pattern[patternPos] * width; } + // flush tail of the last dash span, if any if (buffer.Count > 0) { - if (p.IsClosed) - { - buffer.Add(points[0]); - } - else - { - buffer.Add(points[^1]); - } + buffer.Add(current); // terminate at the true end position - if (online) + if (online && buffer.Count >= 2 && buffer[0] != buffer[^1]) { - offset.AddPath(CollectionsMarshal.AsSpan(buffer), jointStyle, endCapStyle); + outlines.Add([.. buffer]); } buffer.Clear(); } } - return offset.Execute(width).Transform(scaleDownMartrix); + // Each outline span is stroked as an open polyline; the union cleans overlaps. + StrokedShapeGenerator generator = new(MiterOffsetDelta); + return new ComplexPolygon(generator.GenerateStrokedShapes(outlines, width)); } } diff --git a/src/ImageSharp.Drawing/Shapes/Polygon.cs b/src/ImageSharp.Drawing/Shapes/Polygon.cs index a4f60e24..e928d32e 100644 --- a/src/ImageSharp.Drawing/Shapes/Polygon.cs +++ b/src/ImageSharp.Drawing/Shapes/Polygon.cs @@ -55,6 +55,20 @@ internal Polygon(Path path) { } + /// + /// Initializes a new instance of the class using the specified line segments. + /// + /// + /// If owned is set to , modifications to the segments array after construction may affect + /// the Polygon instance. If owned is , the segments are copied to ensure the Polygon is not affected by + /// external changes. + /// + /// An array of line segments that define the edges of the polygon. The order of segments determines the shape of + /// the polygon. + /// + /// to indicate that the Polygon instance takes ownership of the segments array; + /// to create a copy of the array. + /// internal Polygon(ILineSegment[] segments, bool owned) : base(owned ? segments : [.. segments]) { diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/BoundsF.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/BoundsF.cs deleted file mode 100644 index 9d48889a..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/BoundsF.cs +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -using System.Numerics; - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; - -internal struct BoundsF -{ - public float Left; - public float Top; - public float Right; - public float Bottom; - - public BoundsF(float l, float t, float r, float b) - { - this.Left = l; - this.Top = t; - this.Right = r; - this.Bottom = b; - } - - public BoundsF(BoundsF bounds) - { - this.Left = bounds.Left; - this.Top = bounds.Top; - this.Right = bounds.Right; - this.Bottom = bounds.Bottom; - } - - public BoundsF(bool isValid) - { - if (isValid) - { - this.Left = 0; - this.Top = 0; - this.Right = 0; - this.Bottom = 0; - } - else - { - this.Left = float.MaxValue; - this.Top = float.MaxValue; - this.Right = -float.MaxValue; - this.Bottom = -float.MaxValue; - } - } - - public float Width - { - readonly get => this.Right - this.Left; - set => this.Right = this.Left + value; - } - - public float Height - { - readonly get => this.Bottom - this.Top; - set => this.Bottom = this.Top + value; - } - - public readonly bool IsEmpty() - => this.Bottom <= this.Top || this.Right <= this.Left; - - public readonly Vector2 MidPoint() - => new Vector2(this.Left + this.Right, this.Top + this.Bottom) * .5F; - - public readonly bool Contains(Vector2 pt) - => pt.X > this.Left - && pt.X < this.Right - && pt.Y > this.Top && pt.Y < this.Bottom; - - public readonly bool Contains(BoundsF bounds) - => bounds.Left >= this.Left - && bounds.Right <= this.Right - && bounds.Top >= this.Top - && bounds.Bottom <= this.Bottom; - - public readonly bool Intersects(BoundsF bounds) - => (Math.Max(this.Left, bounds.Left) < Math.Min(this.Right, bounds.Right)) - && (Math.Max(this.Top, bounds.Top) < Math.Min(this.Bottom, bounds.Bottom)); - - public readonly PathF AsPath() - => new(4) - { - new Vector2(this.Left, this.Top), - new Vector2(this.Right, this.Top), - new Vector2(this.Right, this.Bottom), - new Vector2(this.Left, this.Bottom) - }; -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClippedShapeGenerator.cs similarity index 55% rename from src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs rename to src/ImageSharp.Drawing/Shapes/PolygonClipper/ClippedShapeGenerator.cs index 2334a6f6..5e723c90 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClippedShapeGenerator.cs @@ -8,25 +8,36 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; /// -/// Performs polygon clipping operations. +/// Generates clipped shapes from one or more input paths using polygon boolean operations. /// -internal sealed class Clipper +/// +/// This class provides a high-level wrapper around the low-level . +/// It accumulates subject and clip polygons, applies the specified , +/// and converts the resulting polygon contours back into instances suitable +/// for rendering or further processing. +/// +internal sealed class ClippedShapeGenerator { private ClipperPolygon? subject; private ClipperPolygon? clip; private readonly IntersectionRule rule; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The intersection rule. - public Clipper(IntersectionRule rule) => this.rule = rule; + public ClippedShapeGenerator(IntersectionRule rule) => this.rule = rule; /// - /// Generates the clipped shapes from the previously provided paths. + /// Generates the final clipped shapes from the previously provided subject and clip paths. /// - /// The clipping operation. - /// The . + /// + /// The boolean operation to perform, such as , + /// , or . + /// + /// + /// An array of instances representing the result of the boolean operation. + /// public IPath[] GenerateClippedShapes(BooleanOperation operation) { ArgumentNullException.ThrowIfNull(this.subject); @@ -57,21 +68,20 @@ public IPath[] GenerateClippedShapes(BooleanOperation operation) } /// - /// Adds the collection of paths. + /// Adds a collection of paths to the current clipping operation. /// - /// The paths. - /// The clipping type. + /// + /// The paths to add. Each path may represent a simple or complex polygon. + /// + /// + /// Determines whether the paths are assigned to the subject or clip polygon. + /// public void AddPaths(IEnumerable paths, ClippingType clippingType) { Guard.NotNull(paths, nameof(paths)); // Accumulate all paths of the complex shape into a single polygon. - ClipperPolygon polygon = []; - - foreach (IPath path in paths) - { - polygon = PolygonClipperFactory.FromSimplePaths(path.Flatten(), this.rule, polygon); - } + ClipperPolygon polygon = PolygonClipperFactory.FromPaths(paths, this.rule); if (clippingType == ClippingType.Clip) { @@ -84,10 +94,12 @@ public void AddPaths(IEnumerable paths, ClippingType clippingType) } /// - /// Adds the path. + /// Adds a single path to the current clipping operation. /// - /// The path. - /// The clipping type. + /// The path to add. + /// + /// Determines whether the path is assigned to the subject or clip polygon. + /// public void AddPath(IPath path, ClippingType clippingType) { Guard.NotNull(path, nameof(path)); diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperException.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperException.cs deleted file mode 100644 index 39ddcfa0..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperException.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; - -/// -/// The exception that is thrown when an error occurs clipping a polygon. -/// -public class ClipperException : Exception -{ - /// - /// Initializes a new instance of the class. - /// - public ClipperException() - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The message that describes the error. - public ClipperException(string message) - : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message and a - /// reference to the inner exception that is the cause of this exception. - /// The error message that explains the reason for the exception. - /// The exception that is the cause of the current exception, or a - /// reference if no inner exception is specified. - public ClipperException(string message, Exception innerException) - : base(message, innerException) - { - } -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperOffset.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperOffset.cs deleted file mode 100644 index 4c94f641..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperOffset.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; - -/// -/// Wrapper for clipper offset -/// -internal class ClipperOffset -{ - private readonly PolygonOffsetter polygonClipperOffset; - - /// - /// Initializes a new instance of the class. - /// - /// meter limit - /// arc tolerance - public ClipperOffset(float meterLimit = 2F, float arcTolerance = .25F) - => this.polygonClipperOffset = new PolygonOffsetter(meterLimit, arcTolerance); - - /// - /// Calculates an offset polygon based on the given path and width. - /// - /// Width - /// path offset - public ComplexPolygon Execute(float width) - { - PathsF solution = []; - this.polygonClipperOffset.Execute(width, solution); - - Polygon[] polygons = new Polygon[solution.Count]; - for (int i = 0; i < solution.Count; i++) - { - PathF pt = solution[i]; - PointF[] points = pt.ToArray(); - - polygons[i] = new Polygon(points); - } - - return new ComplexPolygon(polygons); - } - - /// - /// Adds the path points - /// - /// The path points - /// Joint Style - /// Endcap Style - public void AddPath(ReadOnlySpan pathPoints, JointStyle jointStyle, EndCapStyle endCapStyle) - { - PathF points = new(pathPoints.Length); - points.AddRange(pathPoints); - - this.polygonClipperOffset.AddPath(points, jointStyle, endCapStyle); - } - - /// - /// Adds the path. - /// - /// The path. - /// Joint Style - /// Endcap Style - public void AddPath(IPath path, JointStyle jointStyle, EndCapStyle endCapStyle) - { - Guard.NotNull(path, nameof(path)); - - foreach (ISimplePath p in path.Flatten()) - { - this.AddPath(p, jointStyle, endCapStyle); - } - } - - /// - /// Adds the path. - /// - /// The path. - /// Joint Style - /// Endcap Style - private void AddPath(ISimplePath path, JointStyle jointStyle, EndCapStyle endCapStyle) - { - ReadOnlySpan vectors = path.Points.Span; - this.AddPath(vectors, jointStyle, path.IsClosed ? EndCapStyle.Joined : endCapStyle); - } -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperUtils.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperUtils.cs deleted file mode 100644 index 39114d8b..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClipperUtils.cs +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -using System.Numerics; -using System.Runtime.CompilerServices; - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; - -internal static class ClipperUtils -{ - public const float DefaultArcTolerance = .25F; - public const float FloatingPointTolerance = 1e-05F; - public const float DefaultMinimumEdgeLength = .1F; - - // TODO: rename to Pow2? - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float Sqr(float value) => value * value; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float Area(PathF path) - { - // https://en.wikipedia.org/wiki/Shoelace_formula - float a = 0F; - if (path.Count < 3) - { - return a; - } - - Vector2 prevPt = path[path.Count - 1]; - for (int i = 0; i < path.Count; i++) - { - Vector2 pt = path[i]; - a += (prevPt.Y + pt.Y) * (prevPt.X - pt.X); - prevPt = pt; - } - - return a * .5F; - } - - public static PathF StripDuplicates(PathF path, bool isClosedPath) - { - int cnt = path.Count; - PathF result = new(cnt); - if (cnt == 0) - { - return result; - } - - PointF lastPt = path[0]; - result.Add(lastPt); - for (int i = 1; i < cnt; i++) - { - if (lastPt != path[i]) - { - lastPt = path[i]; - result.Add(lastPt); - } - } - - if (isClosedPath && lastPt == result[0]) - { - result.RemoveAt(result.Count - 1); - } - - return result; - } - - public static PathF Ellipse(Vector2 center, float radiusX, float radiusY = 0, int steps = 0) - { - if (radiusX <= 0) - { - return []; - } - - if (radiusY <= 0) - { - radiusY = radiusX; - } - - if (steps <= 2) - { - steps = (int)MathF.Ceiling(MathF.PI * MathF.Sqrt((radiusX + radiusY) * .5F)); - } - - float si = MathF.Sin(2 * MathF.PI / steps); - float co = MathF.Cos(2 * MathF.PI / steps); - float dx = co, dy = si; - PathF result = new(steps) { new Vector2(center.X + radiusX, center.Y) }; - Vector2 radiusXY = new(radiusX, radiusY); - for (int i = 1; i < steps; ++i) - { - result.Add(center + (radiusXY * new Vector2(dx, dy))); - float x = (dx * co) - (dy * si); - dy = (dy * co) + (dx * si); - dx = x; - } - - return result; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float DotProduct(Vector2 vec1, Vector2 vec2) - => Vector2.Dot(vec1, vec2); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float CrossProduct(Vector2 vec1, Vector2 vec2) - => (vec1.Y * vec2.X) - (vec2.Y * vec1.X); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float CrossProduct(Vector2 pt1, Vector2 pt2, Vector2 pt3) - => ((pt2.X - pt1.X) * (pt3.Y - pt2.Y)) - ((pt2.Y - pt1.Y) * (pt3.X - pt2.X)); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float DotProduct(Vector2 pt1, Vector2 pt2, Vector2 pt3) - => Vector2.Dot(pt2 - pt1, pt3 - pt2); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool IsAlmostZero(float value) - => MathF.Abs(value) <= FloatingPointTolerance; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float PerpendicDistFromLineSqrd(Vector2 pt, Vector2 line1, Vector2 line2) - { - Vector2 ab = pt - line1; - Vector2 cd = line2 - line1; - if (cd == Vector2.Zero) - { - return 0; - } - - return Sqr(CrossProduct(cd, ab)) / DotProduct(cd, cd); - } - - public static bool SegsIntersect(Vector2 seg1a, Vector2 seg1b, Vector2 seg2a, Vector2 seg2b, bool inclusive = false) - { - if (inclusive) - { - float res1 = CrossProduct(seg1a, seg2a, seg2b); - float res2 = CrossProduct(seg1b, seg2a, seg2b); - if (res1 * res2 > 0) - { - return false; - } - - float res3 = CrossProduct(seg2a, seg1a, seg1b); - float res4 = CrossProduct(seg2b, seg1a, seg1b); - if (res3 * res4 > 0) - { - return false; - } - - // ensure NOT collinear - return res1 != 0 || res2 != 0 || res3 != 0 || res4 != 0; - } - - return (CrossProduct(seg1a, seg2a, seg2b) * CrossProduct(seg1b, seg2a, seg2b) < 0) - && (CrossProduct(seg2a, seg1a, seg1b) * CrossProduct(seg2b, seg1a, seg1b) < 0); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static bool GetIntersectPt(Vector2 ln1a, Vector2 ln1b, Vector2 ln2a, Vector2 ln2b, out Vector2 ip) - { - Vector2 dxy1 = ln1b - ln1a; - Vector2 dxy2 = ln2b - ln2a; - float cp = CrossProduct(dxy1, dxy2); - if (cp == 0F) - { - ip = default; - return false; - } - - float qx = CrossProduct(ln1a, dxy1); - float qy = CrossProduct(ln2a, dxy2); - - ip = ((dxy1 * qy) - (dxy2 * qx)) / cp; - return ip != new Vector2(float.MaxValue); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool GetIntersectPoint(Vector2 ln1a, Vector2 ln1b, Vector2 ln2a, Vector2 ln2b, out Vector2 ip) - { - Vector2 dxy1 = ln1b - ln1a; - Vector2 dxy2 = ln2b - ln2a; - float det = CrossProduct(dxy1, dxy2); - if (det == 0F) - { - ip = default; - return false; - } - - float t = (((ln1a.X - ln2a.X) * dxy2.Y) - ((ln1a.Y - ln2a.Y) * dxy2.X)) / det; - if (t <= 0F) - { - ip = ln1a; - } - else if (t >= 1F) - { - ip = ln1b; - } - else - { - ip = ln1a + (t * dxy1); - } - - return true; - } - - public static Vector2 GetClosestPtOnSegment(Vector2 offPt, Vector2 seg1, Vector2 seg2) - { - if (seg1 == seg2) - { - return seg1; - } - - Vector2 dxy = seg2 - seg1; - Vector2 oxy = (offPt - seg1) * dxy; - float q = (oxy.X + oxy.Y) / DotProduct(dxy, dxy); - - if (q < 0) - { - q = 0; - } - else if (q > 1) - { - q = 1; - } - - return seg1 + (dxy * q); - } - - public static PathF ReversePath(PathF path) - { - path.Reverse(); - return path; - } -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs index 8a26ae42..0f0e6063 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs @@ -14,6 +14,32 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; /// internal static class PolygonClipperFactory { + /// + /// Creates a new polygon by combining multiple paths using the specified intersection rule. + /// + /// Use this method to construct complex polygons from multiple input paths, such as when + /// importing shapes from vector graphics or combining user-drawn segments. The resulting polygon's structure + /// depends on the order and geometry of the input paths as well as the chosen intersection rule. + /// + /// + /// A collection of paths that define the shapes to be combined into a single polygon. Each path is expected to + /// represent a simple or complex shape. + /// + /// Containment rule for nesting, or . + /// A representing the union of all input paths, combined according to the specified intersection rule. + public static ClipperPolygon FromPaths(IEnumerable paths, IntersectionRule rule) + { + // Accumulate all paths of the complex shape into a single polygon. + ClipperPolygon polygon = []; + + foreach (IPath path in paths) + { + polygon = FromSimplePaths(path.Flatten(), rule, polygon); + } + + return polygon; + } + /// /// Builds a from closed rings. /// diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs deleted file mode 100644 index 9feb302c..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonOffsetter.cs +++ /dev/null @@ -1,731 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -using System.Numerics; -using System.Runtime.CompilerServices; -using SixLabors.PolygonClipper; - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; - -/// -/// Contains functions to offset paths (inflate/shrink). -/// Ported from and originally licensed -/// under -/// -internal sealed class PolygonOffsetter -{ - private const float Tolerance = 1.0E-6F; - private readonly List groupList = []; - private readonly PathF normals = []; - private readonly PathsF solution = []; - private float groupDelta; // *0.5 for open paths; *-1.0 for negative areas - private float delta; - private float absGroupDelta; - private float mitLimSqr; - private float stepsPerRad; - private float stepSin; - private float stepCos; - private JointStyle joinType; - private EndCapStyle endType; - - public PolygonOffsetter( - float miterLimit = 2F, - float arcTolerance = 0F, - bool preserveCollinear = false, - bool reverseSolution = false) - { - this.MiterLimit = miterLimit; - this.ArcTolerance = arcTolerance; - this.MergeGroups = true; - this.PreserveCollinear = preserveCollinear; - this.ReverseSolution = reverseSolution; - } - - public float ArcTolerance { get; } - - public bool MergeGroups { get; } - - public float MiterLimit { get; } - - public bool PreserveCollinear { get; } - - public bool ReverseSolution { get; } - - public void AddPath(PathF path, JointStyle joinType, EndCapStyle endType) - { - if (path.Count == 0) - { - return; - } - - PathsF pp = new(1) { path }; - this.AddPaths(pp, joinType, endType); - } - - public void AddPaths(PathsF paths, JointStyle joinType, EndCapStyle endType) - { - if (paths.Count == 0) - { - return; - } - - this.groupList.Add(new Group(paths, joinType, endType)); - } - - public void Execute(float delta, PathsF solution) - { - solution.Clear(); - this.ExecuteInternal(delta); - if (this.groupList.Count == 0) - { - return; - } - - // // Clean up self-intersections. - // PolygonClipper clipper = new() - // { - // PreserveCollinear = this.PreserveCollinear, - // - // // The solution should retain the orientation of the input - // ReverseSolution = this.ReverseSolution != this.groupList[0].PathsReversed - // }; - // - // clipper.AddSubject(this.solution); - // if (this.groupList[0].PathsReversed) - // { - // clipper.Execute(ClippingOperation.Union, FillRule.Negative, solution); - // } - // else - // { - // clipper.Execute(ClippingOperation.Union, FillRule.Positive, solution); - // } - // - // // PolygonClipper will throw for unhandled exceptions but if a result is empty - // // we should just return the original path. - SixLabors.PolygonClipper.Polygon result = SixLabors.PolygonClipper.PolygonClipper.Union(this.solution.ToPolygon(), solution.ToPolygon()); - - if (result.Count == 0) - { - foreach (PathF path in this.solution) - { - solution.Add(path); - } - } - - foreach (Contour contour in result) - { - PathF path = new(contour.Count); - solution.Add(path); - foreach (Vertex vertex in contour) - { - path.Add(new Vector2((float)vertex.X, (float)vertex.Y)); - } - } - } - - private void ExecuteInternal(float delta) - { - this.solution.Clear(); - if (this.groupList.Count == 0) - { - return; - } - - if (MathF.Abs(delta) < .5F) - { - foreach (Group group in this.groupList) - { - foreach (PathF path in group.InPaths) - { - this.solution.Add(path); - } - } - } - else - { - this.delta = delta; - this.mitLimSqr = this.MiterLimit <= 1 ? 2F : 2F / ClipperUtils.Sqr(this.MiterLimit); - foreach (Group group in this.groupList) - { - this.DoGroupOffset(group); - } - } - } - - private void DoGroupOffset(Group group) - { - if (group.EndType == EndCapStyle.Polygon) - { - // The lowermost polygon must be an outer polygon. So we can use that as the - // designated orientation for outer polygons (needed for tidy-up clipping). - GetBoundsAndLowestPolyIdx(group.InPaths, out int lowestIdx, out _); - if (lowestIdx < 0) - { - return; - } - - float area = ClipperUtils.Area(group.InPaths[lowestIdx]); - group.PathsReversed = area < 0; - if (group.PathsReversed) - { - this.groupDelta = -this.delta; - } - else - { - this.groupDelta = this.delta; - } - } - else - { - group.PathsReversed = false; - this.groupDelta = MathF.Abs(this.delta) * .5F; - } - - this.absGroupDelta = MathF.Abs(this.groupDelta); - this.joinType = group.JoinType; - this.endType = group.EndType; - - // Calculate a sensible number of steps (for 360 deg for the given offset). - if (group.JoinType == JointStyle.Round || group.EndType == EndCapStyle.Round) - { - // arcTol - when fArcTolerance is undefined (0), the amount of - // curve imprecision that's allowed is based on the size of the - // offset (delta). Obviously very large offsets will almost always - // require much less precision. See also offset_triginometry2.svg - float arcTol = this.ArcTolerance > 0.01F - ? this.ArcTolerance - : (float)Math.Log10(2 + this.absGroupDelta) * ClipperUtils.DefaultArcTolerance; - float stepsPer360 = MathF.PI / (float)Math.Acos(1 - (arcTol / this.absGroupDelta)); - this.stepSin = MathF.Sin(2 * MathF.PI / stepsPer360); - this.stepCos = MathF.Cos(2 * MathF.PI / stepsPer360); - - if (this.groupDelta < 0) - { - this.stepSin = -this.stepSin; - } - - this.stepsPerRad = stepsPer360 / (2 * MathF.PI); - } - - bool isJoined = group.EndType is EndCapStyle.Joined or EndCapStyle.Polygon; - - foreach (PathF p in group.InPaths) - { - PathF path = ClipperUtils.StripDuplicates(p, isJoined); - int cnt = path.Count; - if ((cnt == 0) || ((cnt < 3) && (this.endType == EndCapStyle.Polygon))) - { - continue; - } - - if (cnt == 1) - { - group.OutPath = []; - - // Single vertex so build a circle or square. - if (group.EndType == EndCapStyle.Round) - { - float r = this.absGroupDelta; - group.OutPath = ClipperUtils.Ellipse(path[0], r, r); - } - else - { - float d = this.groupDelta; - Vector2 xy = path[0]; - BoundsF r = new(xy.X - d, xy.Y - d, xy.X + d, xy.Y + d); - group.OutPath = r.AsPath(); - } - - group.OutPaths.Add(group.OutPath); - } - else - { - if (cnt == 2 && group.EndType == EndCapStyle.Joined) - { - if (group.JoinType == JointStyle.Round) - { - this.endType = EndCapStyle.Round; - } - else - { - this.endType = EndCapStyle.Square; - } - } - - this.BuildNormals(path); - - if (this.endType == EndCapStyle.Polygon) - { - this.OffsetPolygon(group, path); - } - else if (this.endType == EndCapStyle.Joined) - { - this.OffsetOpenJoined(group, path); - } - else - { - this.OffsetOpenPath(group, path); - } - } - } - - this.solution.AddRange(group.OutPaths); - group.OutPaths.Clear(); - } - - private static void GetBoundsAndLowestPolyIdx(PathsF paths, out int index, out BoundsF bounds) - { - // TODO: default? - bounds = new BoundsF(false); // ie invalid rect - float pX = float.MinValue; - index = -1; - for (int i = 0; i < paths.Count; i++) - { - foreach (Vector2 pt in paths[i]) - { - if (pt.Y >= bounds.Bottom) - { - if (pt.Y > bounds.Bottom || pt.X < pX) - { - index = i; - pX = pt.X; - bounds.Bottom = pt.Y; - } - } - else if (pt.Y < bounds.Top) - { - bounds.Top = pt.Y; - } - - if (pt.X > bounds.Right) - { - bounds.Right = pt.X; - } - else if (pt.X < bounds.Left) - { - bounds.Left = pt.X; - } - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void BuildNormals(PathF path) - { - int cnt = path.Count; - this.normals.Clear(); - this.normals.EnsureCapacity(cnt); - - for (int i = 0; i < cnt - 1; i++) - { - this.normals.Add(GetUnitNormal(path[i], path[i + 1])); - } - - this.normals.Add(GetUnitNormal(path[cnt - 1], path[0])); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OffsetOpenJoined(Group group, PathF path) - { - this.OffsetPolygon(group, path); - - // TODO: Just reverse inline? - path = ClipperUtils.ReversePath(path); - this.BuildNormals(path); - this.OffsetPolygon(group, path); - } - - private void OffsetOpenPath(Group group, PathF path) - { - group.OutPath = new PathF(path.Count); - int highI = path.Count - 1; - - // Further reduced extraneous vertices in solutions (#499) - if (MathF.Abs(this.groupDelta) < Tolerance) - { - group.OutPath.Add(path[0]); - } - else - { - // do the line start cap - switch (this.endType) - { - case EndCapStyle.Butt: - group.OutPath.Add(path[0] - (this.normals[0] * this.groupDelta)); - group.OutPath.Add(this.GetPerpendic(path[0], this.normals[0])); - break; - case EndCapStyle.Round: - this.DoRound(group, path, 0, 0, MathF.PI); - break; - default: - this.DoSquare(group, path, 0, 0); - break; - } - } - - // offset the left side going forward - for (int i = 1, k = 0; i < highI; i++) - { - this.OffsetPoint(group, path, i, ref k); - } - - // reverse normals ... - for (int i = highI; i > 0; i--) - { - this.normals[i] = Vector2.Negate(this.normals[i - 1]); - } - - this.normals[0] = this.normals[highI]; - - // do the line end cap - switch (this.endType) - { - case EndCapStyle.Butt: - group.OutPath.Add(new Vector2( - path[highI].X - (this.normals[highI].X * this.groupDelta), - path[highI].Y - (this.normals[highI].Y * this.groupDelta))); - group.OutPath.Add(this.GetPerpendic(path[highI], this.normals[highI])); - break; - case EndCapStyle.Round: - this.DoRound(group, path, highI, highI, MathF.PI); - break; - default: - this.DoSquare(group, path, highI, highI); - break; - } - - // offset the left side going back - for (int i = highI, k = 0; i > 0; i--) - { - this.OffsetPoint(group, path, i, ref k); - } - - group.OutPaths.Add(group.OutPath); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector2 GetUnitNormal(Vector2 pt1, Vector2 pt2) - { - Vector2 dxy = pt2 - pt1; - if (dxy == Vector2.Zero) - { - return default; - } - - dxy *= 1F / MathF.Sqrt(ClipperUtils.DotProduct(dxy, dxy)); - return new Vector2(dxy.Y, -dxy.X); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OffsetPolygon(Group group, PathF path) - { - // Dereference the current outpath. - group.OutPath = new PathF(path.Count); - int cnt = path.Count, prev = cnt - 1; - for (int i = 0; i < cnt; i++) - { - this.OffsetPoint(group, path, i, ref prev); - } - - group.OutPaths.Add(group.OutPath); - } - - private void OffsetPoint(Group group, PathF path, int j, ref int k) - { - // Further reduced extraneous vertices in solutions (#499) - if (MathF.Abs(this.groupDelta) < Tolerance) - { - group.OutPath.Add(path[j]); - return; - } - - // Let A = change in angle where edges join - // A == 0: ie no change in angle (flat join) - // A == PI: edges 'spike' - // sin(A) < 0: right turning - // cos(A) < 0: change in angle is more than 90 degree - float sinA = ClipperUtils.CrossProduct(this.normals[j], this.normals[k]); - float cosA = ClipperUtils.DotProduct(this.normals[j], this.normals[k]); - if (sinA > 1F) - { - sinA = 1F; - } - else if (sinA < -1F) - { - sinA = -1F; - } - - // almost straight - less than 1 degree (#424) - if (cosA > 0.99F) - { - this.DoMiter(group, path, j, k, cosA); - } - else if (cosA > -0.99F && (sinA * this.groupDelta < 0F)) - { - // is concave - group.OutPath.Add(this.GetPerpendic(path[j], this.normals[k])); - - // this extra point is the only (simple) way to ensure that - // path reversals are fully cleaned with the trailing clipper - group.OutPath.Add(path[j]); // (#405) - group.OutPath.Add(this.GetPerpendic(path[j], this.normals[j])); - } - else if (this.joinType == JointStyle.Miter) - { - // miter unless the angle is so acute the miter would exceeds ML - if (cosA > this.mitLimSqr - 1) - { - this.DoMiter(group, path, j, k, cosA); - } - else - { - this.DoSquare(group, path, j, k); - } - } - else if (this.joinType == JointStyle.Square) - { - // angle less than 8 degrees or a squared join - this.DoSquare(group, path, j, k); - } - else - { - this.DoRound(group, path, j, k, MathF.Atan2(sinA, cosA)); - } - - k = j; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private Vector2 GetPerpendic(Vector2 pt, Vector2 norm) - => pt + (norm * this.groupDelta); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void DoSquare(Group group, PathF path, int j, int k) - { - Vector2 vec; - if (j == k) - { - vec = new Vector2(this.normals[0].Y, -this.normals[0].X); - } - else - { - vec = GetAvgUnitVector( - new Vector2(-this.normals[k].Y, this.normals[k].X), - new Vector2(this.normals[j].Y, -this.normals[j].X)); - } - - // now offset the original vertex delta units along unit vector - Vector2 ptQ = path[j]; - ptQ = TranslatePoint(ptQ, this.absGroupDelta * vec.X, this.absGroupDelta * vec.Y); - - // get perpendicular vertices - Vector2 pt1 = TranslatePoint(ptQ, this.groupDelta * vec.Y, this.groupDelta * -vec.X); - Vector2 pt2 = TranslatePoint(ptQ, this.groupDelta * -vec.Y, this.groupDelta * vec.X); - - // get 2 vertices along one edge offset - Vector2 pt3 = this.GetPerpendic(path[k], this.normals[k]); - - if (j == k) - { - Vector2 pt4 = pt3 + (vec * this.groupDelta); - Vector2 pt = IntersectPoint(pt1, pt2, pt3, pt4); - - // get the second intersect point through reflecion - group.OutPath.Add(ReflectPoint(pt, ptQ)); - group.OutPath.Add(pt); - } - else - { - Vector2 pt4 = this.GetPerpendic(path[j], this.normals[k]); - Vector2 pt = IntersectPoint(pt1, pt2, pt3, pt4); - - group.OutPath.Add(pt); - - // Get the second intersect point through reflecion - group.OutPath.Add(ReflectPoint(pt, ptQ)); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void DoMiter(Group group, PathF path, int j, int k, float cosA) - { - float q = this.groupDelta / (cosA + 1); - Vector2 pv = path[j]; - Vector2 nk = this.normals[k]; - Vector2 nj = this.normals[j]; - group.OutPath.Add(pv + ((nk + nj) * q)); - } - - private void DoRound(Group group, PathF path, int j, int k, float angle) - { - Vector2 pt = path[j]; - Vector2 offsetVec = this.normals[k] * new Vector2(this.groupDelta); - if (j == k) - { - offsetVec = Vector2.Negate(offsetVec); - } - - group.OutPath.Add(pt + offsetVec); - - // avoid 180deg concave - if (angle > -MathF.PI + .01F) - { - int steps = Math.Max(2, (int)Math.Ceiling(this.stepsPerRad * MathF.Abs(angle))); - - // ie 1 less than steps - for (int i = 1; i < steps; i++) - { - offsetVec = new Vector2((offsetVec.X * this.stepCos) - (this.stepSin * offsetVec.Y), (offsetVec.X * this.stepSin) + (offsetVec.Y * this.stepCos)); - - group.OutPath.Add(pt + offsetVec); - } - } - - group.OutPath.Add(this.GetPerpendic(pt, this.normals[j])); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector2 TranslatePoint(Vector2 pt, float dx, float dy) - => pt + new Vector2(dx, dy); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector2 ReflectPoint(Vector2 pt, Vector2 pivot) - => pivot + (pivot - pt); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector2 IntersectPoint(Vector2 pt1a, Vector2 pt1b, Vector2 pt2a, Vector2 pt2b) - { - // vertical - if (ClipperUtils.IsAlmostZero(pt1a.X - pt1b.X)) - { - if (ClipperUtils.IsAlmostZero(pt2a.X - pt2b.X)) - { - return default; - } - - float m2 = (pt2b.Y - pt2a.Y) / (pt2b.X - pt2a.X); - float b2 = pt2a.Y - (m2 * pt2a.X); - return new Vector2(pt1a.X, (m2 * pt1a.X) + b2); - } - - // vertical - if (ClipperUtils.IsAlmostZero(pt2a.X - pt2b.X)) - { - float m1 = (pt1b.Y - pt1a.Y) / (pt1b.X - pt1a.X); - float b1 = pt1a.Y - (m1 * pt1a.X); - return new Vector2(pt2a.X, (m1 * pt2a.X) + b1); - } - else - { - float m1 = (pt1b.Y - pt1a.Y) / (pt1b.X - pt1a.X); - float b1 = pt1a.Y - (m1 * pt1a.X); - float m2 = (pt2b.Y - pt2a.Y) / (pt2b.X - pt2a.X); - float b2 = pt2a.Y - (m2 * pt2a.X); - if (ClipperUtils.IsAlmostZero(m1 - m2)) - { - return default; - } - - float x = (b2 - b1) / (m1 - m2); - return new Vector2(x, (m1 * x) + b1); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector2 GetAvgUnitVector(Vector2 vec1, Vector2 vec2) - => NormalizeVector(vec1 + vec2); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static float Hypotenuse(Vector2 vector) - => MathF.Sqrt(Vector2.Dot(vector, vector)); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static Vector2 NormalizeVector(Vector2 vector) - { - float h = Hypotenuse(vector); - if (ClipperUtils.IsAlmostZero(h)) - { - return default; - } - - float inverseHypot = 1 / h; - return vector * inverseHypot; - } - - private class Group - { - public Group(PathsF paths, JointStyle joinType, EndCapStyle endType = EndCapStyle.Polygon) - { - this.InPaths = paths; - this.JoinType = joinType; - this.EndType = endType; - this.OutPath = []; - this.OutPaths = []; - this.PathsReversed = false; - } - - public PathF OutPath { get; set; } - - public PathsF OutPaths { get; } - - public JointStyle JoinType { get; } - - public EndCapStyle EndType { get; set; } - - public bool PathsReversed { get; set; } - - public PathsF InPaths { get; } - } -} - -internal class PathsF : List -{ - public PathsF() - { - } - - public PathsF(IEnumerable items) - : base(items) - { - } - - public PathsF(int capacity) - : base(capacity) - { - } - - internal SixLabors.PolygonClipper.Polygon ToPolygon() - { - SixLabors.PolygonClipper.Polygon polygon = []; - - foreach (PathF pathF in this) - { - Contour contour = new(); - polygon.Add(contour); - - foreach (Vector2 point in pathF) - { - contour.AddVertex(new Vertex(point.X, point.Y)); - } - } - - return polygon; - } -} - -internal class PathF : List -{ - public PathF() - { - } - - public PathF(IEnumerable items) - : base(items) - { - } - - public PathF(int capacity) - : base(capacity) - { - } -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonStroker.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonStroker.cs index 4061d300..7b962812 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonStroker.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonStroker.cs @@ -3,10 +3,31 @@ using System.Runtime.CompilerServices; +#pragma warning disable SA1201 // Elements should appear in the correct order namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; -#pragma warning disable SA1201 // Elements should appear in the correct order +/// +/// Generates polygonal stroke outlines for vector paths using analytic joins and caps. +/// +/// +/// +/// This class performs geometric stroking of input paths, producing an explicit polygonal +/// outline suitable for filling or clipping. It replicates the behavior of analytic stroking +/// as implemented in vector renderers (e.g., AGG, Skia), without relying on rasterization. +/// +/// +/// The stroker supports multiple join and cap styles, adjustable miter limits, and an +/// approximation scale for arc and round joins. It operates entirely in double precision +/// for numerical stability, emitting coordinates for downstream use +/// in polygon merging or clipping operations. +/// +/// +/// Used by higher-level utility to produce consistent, +/// merged outlines for stroked paths and dashed spans. +/// +/// internal sealed class PolygonStroker + { private ArrayBuilder outVertices = new(1); private ArrayBuilder srcVertices = new(16); @@ -53,8 +74,13 @@ public double Width } } - public PathF ProcessPath(ReadOnlySpan linePoints, bool isClosed) + public PointF[] ProcessPath(ReadOnlySpan linePoints, bool isClosed) { + if (linePoints.Length < 2) + { + return []; + } + this.Reset(); this.AddLinePath(linePoints); @@ -63,9 +89,9 @@ public PathF ProcessPath(ReadOnlySpan linePoints, bool isClosed) this.ClosePath(); } - PathF results = new(linePoints.Length * 3); + List results = new(linePoints.Length * 3); this.FinishPath(results); - return results; + return [.. results]; } public void AddLinePath(ReadOnlySpan linePoints) @@ -79,7 +105,9 @@ public void AddLinePath(ReadOnlySpan linePoints) public void ClosePath() { - this.AddVertex(0, 0, PathCommand.EndPoly | (PathCommand)PathFlags.Close); + // Mark the current src path as closed; no geometry is pushed here. + this.closed = (int)PathFlags.Close; + this.status = Status.Initial; } public void FinishPath(List results) @@ -327,6 +355,8 @@ private void CloseVertexPath(bool closed) this.srcVertices.RemoveLast(); } + // Remove the tail pair (vd2 and its predecessor vd1) and re-add the tail 't'. + // Re-adding forces a fresh Measure() against the new predecessor, collapsing zero-length edges. if (this.srcVertices.Length != 0) { this.srcVertices.RemoveLast(); @@ -340,6 +370,7 @@ private void CloseVertexPath(bool closed) return; } + // TODO: Why check again? Doesn't the while loop above already ensure this? while (this.srcVertices.Length > 1) { ref VertexDistance vd1 = ref this.srcVertices[^1]; @@ -489,6 +520,15 @@ private void CalcCap(ref VertexDistance v0, ref VertexDistance v1, double len) { this.outVertices.Clear(); + if (len < Constants.Misc.VertexDistanceEpsilon) + { + // Degenerate cap: emit a symmetric butt cap of zero span. + // This avoids div-by-zero in direction computation. + this.AddPoint(v0.X, v0.Y); + this.AddPoint(v1.X, v1.Y); + return; + } + double dx1 = (v1.Y - v0.Y) / len; double dy1 = (v1.X - v0.X) / len; double dx2 = 0; @@ -544,6 +584,26 @@ private void CalcCap(ref VertexDistance v0, ref VertexDistance v1, double len) private void CalcJoin(ref VertexDistance v0, ref VertexDistance v1, ref VertexDistance v2, double len1, double len2) { + const double eps = Constants.Misc.VertexDistanceEpsilon; + if (len1 < eps || len2 < eps) + { + // Degenerate join: reuse the non-degenerate edge length for both offsets + // to emit a simple bevel and avoid unstable direction math. + this.outVertices.Clear(); + + double l1 = len1 >= eps ? len1 : len2; + double l2 = len2 >= eps ? len2 : len1; + + double offX1 = this.strokeWidth * (v1.Y - v0.Y) / l1; + double offY1 = this.strokeWidth * (v1.X - v0.X) / l1; + double offX2 = this.strokeWidth * (v2.Y - v1.Y) / l2; + double offY2 = this.strokeWidth * (v2.X - v1.X) / l2; + + this.AddPoint(v1.X + offX1, v1.Y - offY1); + this.AddPoint(v1.X + offX2, v1.Y - offY2); + return; + } + double dx1 = this.strokeWidth * (v1.Y - v0.Y) / len1; double dy1 = this.strokeWidth * (v1.X - v0.X) / len1; double dx2 = this.strokeWidth * (v2.Y - v1.Y) / len2; diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/StrokedShapeGenerator.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/StrokedShapeGenerator.cs new file mode 100644 index 00000000..4fddefe1 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/StrokedShapeGenerator.cs @@ -0,0 +1,207 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.PolygonClipper; + +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; + +/// +/// Generates stroked and merged shapes using polygon stroking and boolean clipping. +/// +internal sealed class StrokedShapeGenerator +{ + private readonly PolygonStroker polygonStroker; + + /// + /// Initializes a new instance of the class. + /// + /// meter limit + /// arc tolerance + public StrokedShapeGenerator(float meterLimit = 2F, float arcTolerance = .25F) + { + // TODO: We need to consume the joint type properties here. + // to do so we need to replace the existing ones with our new enums and update + // the overloads and pens. + this.polygonStroker = new PolygonStroker(); + } + + /// + /// Strokes a collection of dashed polyline spans and returns a merged outline. + /// + /// + /// The input spans. Each array is treated as an open polyline + /// and is stroked using the current stroker settings. + /// Spans that are null or contain fewer than 2 points are ignored. + /// + /// The stroke width in the caller’s coordinate space. + /// + /// An array of closed paths representing the stroked outline after boolean merge. + /// Returns an empty array when no valid spans are provided. Returns a single path + /// when only one valid stroked ring is produced. + /// + /// + /// This method streams each dashed span through the internal stroker as an open polyline, + /// producing closed stroke rings. To clean self overlaps, the rings are split between + /// subject and clip sets and a is performed. + /// The split ensures at least two operands so the union resolves overlaps. + /// The union uses to preserve winding density. + /// + public IPath[] GenerateStrokedShapes(List spans, float width) + { + // PolygonClipper is not designed to clean up self-intersecting geometry within a single polygon. + // It operates strictly on two polygon operands (subject and clip) and only resolves overlaps + // between them. To force cleanup of dashed stroke overlaps, we alternate assigning each + // stroked segment to subject or clip, ensuring at least two operands exist so the union + // operation performs a true merge rather than a no-op on a single polygon. + + // 1) Stroke each dashed span as open. + this.polygonStroker.Width = width; + + List rings = new(spans.Count); + foreach (PointF[] span in spans) + { + if (span == null || span.Length < 2) + { + continue; + } + + PointF[] stroked = this.polygonStroker.ProcessPath(span, isClosed: false); + if (stroked.Length < 3) + { + continue; + } + + rings.Add(new Polygon(new LinearLineSegment(stroked))); + } + + int count = rings.Count; + if (count == 0) + { + return []; + } + + if (count == 1) + { + // Only one stroked ring. Return as-is; two-operand union requires both sides non-empty. + return [rings[0]]; + } + + // 2) Partition so the first and last are on different polygons + List subjectRings = new(count); + List clipRings = new(count); + + // First => subject + subjectRings.Add(rings[0]); + + // Middle by alternation using a single bool flag + bool assignToSubject = false; // start with clip for i=1 + for (int i = 1; i < count - 1; i++) + { + if (assignToSubject) + { + subjectRings.Add(rings[i]); + } + else + { + clipRings.Add(rings[i]); + } + + assignToSubject = !assignToSubject; + } + + // Last => opposite of first (i.e., clip) + clipRings.Add(rings[count - 1]); + + // 3) Union subject vs clip + ClippedShapeGenerator clipper = new(IntersectionRule.NonZero); + clipper.AddPaths(subjectRings, ClippingType.Subject); + clipper.AddPaths(clipRings, ClippingType.Clip); + return clipper.GenerateClippedShapes(BooleanOperation.Union); + } + + /// + /// Strokes a path and returns a merged outline from its flattened segments. + /// + /// The source path. It is flattened using the current flattening settings. + /// The stroke width in the caller’s coordinate space. + /// + /// An array of closed paths representing the stroked outline after boolean merge. + /// Returns an empty array when no valid rings are produced. Returns a single path + /// when only one valid stroked ring exists. + /// + /// + /// Each flattened simple path is streamed through the internal stroker as open or closed + /// according to . The resulting stroke rings are split + /// between subject and clip sets and combined using . + /// This split is required because the Martinez based clipper resolves overlaps only between + /// two operands. Using preserves fill across overlaps + /// and prevents unintended holes in the merged outline. + /// + public IPath[] GenerateStrokedShapes(IPath path, float width) + { + // 1) Stroke the input path into closed rings + List rings = []; + this.polygonStroker.Width = width; + + foreach (ISimplePath p in path.Flatten()) + { + PointF[] stroked = this.polygonStroker.ProcessPath(p.Points.Span, p.IsClosed); + if (stroked.Length < 3) + { + continue; // skip degenerate outputs + } + + rings.Add(new Polygon(new LinearLineSegment(stroked))); + } + + int count = rings.Count; + if (count == 0) + { + return []; + } + + if (count == 1) + { + // Only one stroked ring. Return as-is; two-operand union requires both sides non-empty. + return [rings[0]]; + } + + // 2) Partition so the first and last are on different polygons + // PolygonClipper is not designed to clean up self-intersecting geometry within a single polygon. + // It operates strictly on two polygon operands (subject and clip) and only resolves overlaps + // between them. To force cleanup of overlaps, we alternate assigning each stroked ring to + // subject or clip, ensuring at least two operands exist so the union performs a true merge. + List subjectRings = new(count); + List clipRings = new(count); + + // First => subject + subjectRings.Add(rings[0]); + + // Middle by alternation using a single bool flag + bool assignToSubject = false; // start with clip for i=1 + for (int i = 1; i < count - 1; i++) + { + if (assignToSubject) + { + subjectRings.Add(rings[i]); + } + else + { + clipRings.Add(rings[i]); + } + + assignToSubject = !assignToSubject; + } + + // Last => opposite of first (i.e., clip) + clipRings.Add(rings[count - 1]); + + // 3) Union subject vs clip + ClippedShapeGenerator clipper = new(IntersectionRule.NonZero); + clipper.AddPaths(subjectRings, ClippingType.Subject); + clipper.AddPaths(clipRings, ClippingType.Clip); + + // 4) Return the cleaned, merged outline + return clipper.GenerateClippedShapes(BooleanOperation.Union); + } +} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexDistance.cs b/src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexDistance.cs index 89383756..b625d5cd 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexDistance.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexDistance.cs @@ -5,6 +5,8 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +// TODO: We can improve the performance of some of the operations here by using unsafe casting to Vector128 +// Like we do in PolygonClipper. internal struct VertexDistance { private const double Dd = 1.0 / Constants.Misc.VertexDistanceEpsilon; diff --git a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs index 2c5961d7..6524c7c8 100644 --- a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs @@ -28,7 +28,7 @@ public class ClipperTests private IEnumerable Clip(IPath shape, params IPath[] hole) { - Clipper clipper = new(IntersectionRule.EvenOdd); + ClippedShapeGenerator clipper = new(IntersectionRule.EvenOdd); clipper.AddPath(shape, ClippingType.Subject); if (hole != null) diff --git a/tests/ImageSharp.Drawing.Tests/TestUtilities/RectangularPolygonValueComparer.cs b/tests/ImageSharp.Drawing.Tests/TestUtilities/RectangularPolygonValueComparer.cs index 65583e1d..91f64607 100644 --- a/tests/ImageSharp.Drawing.Tests/TestUtilities/RectangularPolygonValueComparer.cs +++ b/tests/ImageSharp.Drawing.Tests/TestUtilities/RectangularPolygonValueComparer.cs @@ -1,8 +1,6 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; - namespace SixLabors.ImageSharp.Drawing.Tests.TestUtilities; /// @@ -10,7 +8,7 @@ namespace SixLabors.ImageSharp.Drawing.Tests.TestUtilities; /// internal static class RectangularPolygonValueComparer { - public const float DefaultTolerance = ClipperUtils.FloatingPointTolerance; + public const float DefaultTolerance = 1e-05F; public static bool Equals(RectangularPolygon x, RectangularPolygon y, float epsilon = DefaultTolerance) => Math.Abs(x.Left - y.Left) < epsilon From bf57cfe83168e53714f99856d82d229defd7692e Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Fri, 31 Oct 2025 23:29:58 +1000 Subject: [PATCH 07/18] Update ISimplePath.cs --- src/ImageSharp.Drawing/Shapes/ISimplePath.cs | 25 ++------------------ 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/src/ImageSharp.Drawing/Shapes/ISimplePath.cs b/src/ImageSharp.Drawing/Shapes/ISimplePath.cs index f5e0a84e..cabea969 100644 --- a/src/ImageSharp.Drawing/Shapes/ISimplePath.cs +++ b/src/ImageSharp.Drawing/Shapes/ISimplePath.cs @@ -1,11 +1,6 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices.ComTypes; -using SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; -using SixLabors.PolygonClipper; - namespace SixLabors.ImageSharp.Drawing; /// @@ -16,26 +11,10 @@ public interface ISimplePath /// /// Gets a value indicating whether this instance is a closed path. /// - bool IsClosed { get; } + public bool IsClosed { get; } /// /// Gets the points that make this up as a simple linear path. /// - ReadOnlyMemory Points { get; } - - /// - /// Converts to - /// - /// The converted polygon. - internal SixLabors.PolygonClipper.Contour ToContour() - { - Contour contour = new(); - - foreach (PointF point in this.Points.Span) - { - contour.AddVertex(new Vertex(point.X, point.Y)); - } - - return contour; - } + public ReadOnlyMemory Points { get; } } From f9763530782d7592bd68f0cbebc3f4d6c09b38a1 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Fri, 31 Oct 2025 23:41:09 +1000 Subject: [PATCH 08/18] Update namespace --- src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs | 2 +- .../Shapes/{PolygonClipper => Helpers}/ArrayBuilder{T}.cs | 2 +- src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs | 2 +- .../ClippedShapeGenerator.cs | 2 +- .../{PolygonClipper => PolygonGeometry}/ClippingType.cs | 2 +- .../PolygonClipperFactory.cs | 2 +- .../{PolygonClipper => PolygonGeometry}/PolygonStroker.cs | 5 +++-- .../StrokedShapeGenerator.cs | 2 +- .../{PolygonClipper => PolygonGeometry}/VertexDistance.cs | 2 +- .../Shapes/PolygonClipper/ClipperTests.cs | 2 +- 10 files changed, 12 insertions(+), 11 deletions(-) rename src/ImageSharp.Drawing/Shapes/{PolygonClipper => Helpers}/ArrayBuilder{T}.cs (98%) rename src/ImageSharp.Drawing/Shapes/{PolygonClipper => PolygonGeometry}/ClippedShapeGenerator.cs (98%) rename src/ImageSharp.Drawing/Shapes/{PolygonClipper => PolygonGeometry}/ClippingType.cs (86%) rename src/ImageSharp.Drawing/Shapes/{PolygonClipper => PolygonGeometry}/PolygonClipperFactory.cs (99%) rename src/ImageSharp.Drawing/Shapes/{PolygonClipper => PolygonGeometry}/PolygonStroker.cs (99%) rename src/ImageSharp.Drawing/Shapes/{PolygonClipper => PolygonGeometry}/StrokedShapeGenerator.cs (99%) rename src/ImageSharp.Drawing/Shapes/{PolygonClipper => PolygonGeometry}/VertexDistance.cs (97%) diff --git a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs index 37506211..498126da 100644 --- a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs +++ b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs @@ -2,7 +2,7 @@ // Licensed under the Six Labors Split License. using SixLabors.ImageSharp.Drawing.Processing; -using SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +using SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; namespace SixLabors.ImageSharp.Drawing; diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ArrayBuilder{T}.cs b/src/ImageSharp.Drawing/Shapes/Helpers/ArrayBuilder{T}.cs similarity index 98% rename from src/ImageSharp.Drawing/Shapes/PolygonClipper/ArrayBuilder{T}.cs rename to src/ImageSharp.Drawing/Shapes/Helpers/ArrayBuilder{T}.cs index 916592fd..c8e7cc26 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ArrayBuilder{T}.cs +++ b/src/ImageSharp.Drawing/Shapes/Helpers/ArrayBuilder{T}.cs @@ -4,7 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +namespace SixLabors.ImageSharp.Drawing.Shapes.Helpers; /// /// A helper type for avoiding allocations while building arrays. diff --git a/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs b/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs index fa88e5c4..7533df08 100644 --- a/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs +++ b/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs @@ -2,7 +2,7 @@ // Licensed under the Six Labors Split License. using System.Numerics; -using SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +using SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; namespace SixLabors.ImageSharp.Drawing; diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClippedShapeGenerator.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippedShapeGenerator.cs similarity index 98% rename from src/ImageSharp.Drawing/Shapes/PolygonClipper/ClippedShapeGenerator.cs rename to src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippedShapeGenerator.cs index 5e723c90..9a6ac206 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClippedShapeGenerator.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippedShapeGenerator.cs @@ -5,7 +5,7 @@ using ClipperPolygon = SixLabors.PolygonClipper.Polygon; using PolygonClipperAction = SixLabors.PolygonClipper.PolygonClipper; -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; /// /// Generates clipped shapes from one or more input paths using polygon boolean operations. diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClippingType.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippingType.cs similarity index 86% rename from src/ImageSharp.Drawing/Shapes/PolygonClipper/ClippingType.cs rename to src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippingType.cs index 00aa96a4..f2e252f2 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/ClippingType.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippingType.cs @@ -1,7 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; /// /// Defines the polygon clipping type. diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipperFactory.cs similarity index 99% rename from src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs rename to src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipperFactory.cs index 0f0e6063..f904629e 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonClipperFactory.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipperFactory.cs @@ -5,7 +5,7 @@ using SixLabors.PolygonClipper; using ClipperPolygon = SixLabors.PolygonClipper.Polygon; -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; /// /// Builders for from ImageSharp paths. diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonStroker.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs similarity index 99% rename from src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonStroker.cs rename to src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs index 7b962812..9d5ac054 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/PolygonStroker.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs @@ -2,9 +2,10 @@ // Licensed under the Six Labors Split License. using System.Runtime.CompilerServices; +using SixLabors.ImageSharp.Drawing.Shapes.Helpers; #pragma warning disable SA1201 // Elements should appear in the correct order -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; /// /// Generates polygonal stroke outlines for vector paths using analytic joins and caps. @@ -47,7 +48,7 @@ internal sealed class PolygonStroker public double ApproximationScale { get; set; } = 1.0; - public LineJoin LineJoin { get; set; } = LineJoin.MiterJoin; + public LineJoin LineJoin { get; set; } = LineJoin.BevelJoin; public LineCap LineCap { get; set; } = LineCap.Butt; diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/StrokedShapeGenerator.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs similarity index 99% rename from src/ImageSharp.Drawing/Shapes/PolygonClipper/StrokedShapeGenerator.cs rename to src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs index 4fddefe1..a2277acb 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/StrokedShapeGenerator.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs @@ -3,7 +3,7 @@ using SixLabors.PolygonClipper; -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; /// /// Generates stroked and merged shapes using polygon stroking and boolean clipping. diff --git a/src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexDistance.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexDistance.cs similarity index 97% rename from src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexDistance.cs rename to src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexDistance.cs index b625d5cd..c27a5658 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonClipper/VertexDistance.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexDistance.cs @@ -3,7 +3,7 @@ using System.Runtime.CompilerServices; -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; // TODO: We can improve the performance of some of the operations here by using unsafe casting to Vector128 // Like we do in PolygonClipper. diff --git a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs index 6524c7c8..6a48e823 100644 --- a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs @@ -2,7 +2,7 @@ // Licensed under the Six Labors Split License. using System.Numerics; -using SixLabors.ImageSharp.Drawing.Shapes.PolygonClipper; +using SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; using SixLabors.ImageSharp.Drawing.Tests.TestUtilities; using SixLabors.PolygonClipper; From ac98cc6fce77a9ae2e1ebf5e65699ea0f55bdfdb Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 4 Feb 2026 15:23:43 +1000 Subject: [PATCH 09/18] Update ImageSharp.Drawing.sln to Visual Studio 18 Removed Visual Studio Version 17 entry from solution file. --- ImageSharp.Drawing.sln | 2 -- 1 file changed, 2 deletions(-) diff --git a/ImageSharp.Drawing.sln b/ImageSharp.Drawing.sln index b3b0c3cc..74e8e154 100644 --- a/ImageSharp.Drawing.sln +++ b/ImageSharp.Drawing.sln @@ -2,8 +2,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.0.11123.170 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36623.8 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_root", "_root", "{C317F1B1-D75E-4C6D-83EB-80367343E0D7}" ProjectSection(SolutionItems) = preProject From 33afe06a8e075e56465232ebb5aee2edb93a6bff Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 4 Feb 2026 15:24:42 +1000 Subject: [PATCH 10/18] Update package references in ImageSharp.Drawing.csproj --- src/ImageSharp.Drawing/ImageSharp.Drawing.csproj | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj index e172d5f4..d0a1b489 100644 --- a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj +++ b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj @@ -44,11 +44,9 @@ - - - + - \ No newline at end of file + From 20f646075c86aa5fcac6bd0b64e881f6de4d7d70 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 4 Feb 2026 16:49:30 +1000 Subject: [PATCH 11/18] Only clip when we need to --- .../PolygonGeometry/StrokedShapeGenerator.cs | 104 +++++++++++++++++- .../Shapes/PolygonGeometry/VertexDistance.cs | 3 +- .../Shapes/Text/BaseGlyphBuilder.cs | 3 +- src/ImageSharp.Drawing/Utilities/Intersect.cs | 42 ++++++- 4 files changed, 142 insertions(+), 10 deletions(-) diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs index a2277acb..917ccb96 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs @@ -1,6 +1,8 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Numerics; +using SixLabors.ImageSharp.Drawing.Utilities; using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; @@ -57,6 +59,7 @@ public IPath[] GenerateStrokedShapes(List spans, float width) // 1) Stroke each dashed span as open. this.polygonStroker.Width = width; + List ringPoints = new(spans.Count); List rings = new(spans.Count); foreach (PointF[] span in spans) { @@ -71,6 +74,7 @@ public IPath[] GenerateStrokedShapes(List spans, float width) continue; } + ringPoints.Add(stroked); rings.Add(new Polygon(new LinearLineSegment(stroked))); } @@ -80,10 +84,9 @@ public IPath[] GenerateStrokedShapes(List spans, float width) return []; } - if (count == 1) + if (!HasIntersections(ringPoints)) { - // Only one stroked ring. Return as-is; two-operand union requires both sides non-empty. - return [rings[0]]; + return count == 1 ? [rings[0]] : [.. rings]; } // 2) Partition so the first and last are on different polygons @@ -140,6 +143,7 @@ public IPath[] GenerateStrokedShapes(List spans, float width) public IPath[] GenerateStrokedShapes(IPath path, float width) { // 1) Stroke the input path into closed rings + List ringPoints = []; List rings = []; this.polygonStroker.Width = width; @@ -151,6 +155,7 @@ public IPath[] GenerateStrokedShapes(IPath path, float width) continue; // skip degenerate outputs } + ringPoints.Add(stroked); rings.Add(new Polygon(new LinearLineSegment(stroked))); } @@ -160,10 +165,9 @@ public IPath[] GenerateStrokedShapes(IPath path, float width) return []; } - if (count == 1) + if (!HasIntersections(ringPoints)) { - // Only one stroked ring. Return as-is; two-operand union requires both sides non-empty. - return [rings[0]]; + return count == 1 ? [rings[0]] : [.. rings]; } // 2) Partition so the first and last are on different polygons @@ -204,4 +208,92 @@ public IPath[] GenerateStrokedShapes(IPath path, float width) // 4) Return the cleaned, merged outline return clipper.GenerateClippedShapes(BooleanOperation.Union); } + + /// + /// Determines whether any of the provided rings contain self-intersections or intersect with other rings. + /// + /// + /// This method performs a conservative scan to detect intersections among the provided rings. It + /// checks for both self-intersections within each ring and intersections between different rings. Rings are treated + /// as polylines; if a ring is closed (its first and last points are equal), the closing segment is included in the + /// intersection checks. This method is intended for fast intersection detection and may be used to determine + /// whether further geometric processing, such as clipping, is necessary. + /// + /// + /// A list of rings, where each ring is represented as an array of points defining its vertices. Each ring is + /// expected to be a sequence of points forming a polyline or polygon. + /// + /// if any ring self-intersects or any two rings intersect; otherwise, . + private static bool HasIntersections(List rings) + { + // Detect whether any stroked ring self-intersects or intersects another ring. + // This is a fast, conservative scan used to decide whether we can skip clipping. + Vector2 intersection = default; + + for (int r = 0; r < rings.Count; r++) + { + PointF[] ring = rings[r]; + int segmentCount = ring.Length - 1; + if (segmentCount < 2) + { + continue; + } + + // 1) Self-intersection scan for the current ring. + // Adjacent segments share a vertex and are skipped to avoid trivial hits. + bool isClosed = ring[0] == ring[^1]; + for (int i = 0; i < segmentCount; i++) + { + Vector2 a0 = new(ring[i].X, ring[i].Y); + Vector2 a1 = new(ring[i + 1].X, ring[i + 1].Y); + + for (int j = i + 1; j < segmentCount; j++) + { + // Skip neighbors and the closing edge pair in a closed ring. + if (j == i + 1 || (isClosed && i == 0 && j == segmentCount - 1)) + { + continue; + } + + Vector2 b0 = new(ring[j].X, ring[j].Y); + Vector2 b1 = new(ring[j + 1].X, ring[j + 1].Y); + if (Intersect.LineSegmentToLineSegmentIgnoreCollinear(a0, a1, b0, b1, ref intersection)) + { + return true; + } + } + } + + // 2) Cross-ring intersection scan against later rings only. + // This avoids double work while checking all ring pairs. + for (int s = r + 1; s < rings.Count; s++) + { + PointF[] other = rings[s]; + int otherSegmentCount = other.Length - 1; + if (otherSegmentCount < 1) + { + continue; + } + + for (int i = 0; i < segmentCount; i++) + { + Vector2 a0 = new(ring[i].X, ring[i].Y); + Vector2 a1 = new(ring[i + 1].X, ring[i + 1].Y); + + for (int j = 0; j < otherSegmentCount; j++) + { + Vector2 b0 = new(other[j].X, other[j].Y); + Vector2 b1 = new(other[j + 1].X, other[j + 1].Y); + if (Intersect.LineSegmentToLineSegmentIgnoreCollinear(a0, a1, b0, b1, ref intersection)) + { + return true; + } + } + } + } + } + + // No intersections detected. + return false; + } } diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexDistance.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexDistance.cs index c27a5658..8dd0e724 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexDistance.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexDistance.cs @@ -93,5 +93,6 @@ public static bool CalcIntersection(double ax, double ay, double bx, double by, } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static double CrossProduct(double x1, double y1, double x2, double y2, double x, double y) => ((x - x2) * (y2 - y1)) - ((y - y2) * (x2 - x1)); + public static double CrossProduct(double x1, double y1, double x2, double y2, double x, double y) + => ((x - x2) * (y2 - y1)) - ((y - y2) * (x2 - x1)); } diff --git a/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs b/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs index 8cc45fbe..8734440e 100644 --- a/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs +++ b/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs @@ -6,6 +6,7 @@ using SixLabors.Fonts; using SixLabors.Fonts.Rendering; using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Text; @@ -220,7 +221,7 @@ void IGlyphRenderer.EndLayer() ShapeOptions options = new() { - ClippingOperation = ClippingOperation.Intersection, + ClippingOperation = BooleanOperation.Intersection, IntersectionRule = TextUtilities.MapFillRule(this.currentLayerFillRule) }; diff --git a/src/ImageSharp.Drawing/Utilities/Intersect.cs b/src/ImageSharp.Drawing/Utilities/Intersect.cs index 624f0953..e20ed9eb 100644 --- a/src/ImageSharp.Drawing/Utilities/Intersect.cs +++ b/src/ImageSharp.Drawing/Utilities/Intersect.cs @@ -1,33 +1,71 @@ -// Copyright (c) Six Labors. +// Copyright (c) Six Labors. // Licensed under the Six Labors Split License. using System.Numerics; namespace SixLabors.ImageSharp.Drawing.Utilities; +/// +/// Lightweight 2D segment intersection helpers for polygon and path processing. +/// +/// +/// This is intentionally small and allocation-free. It favors speed and numerical tolerance +/// over exhaustive classification (e.g., collinear overlap detection), which keeps it fast +/// enough for per-segment scanning in stroking or clipping preparation passes. +/// internal static class Intersect { + // Epsilon used for floating-point tolerance. We treat values within ±Eps as zero. + // This helps avoid instability when segments are nearly parallel or endpoints are + // very close to the intersection boundary. private const float Eps = 1e-3f; private const float MinusEps = -Eps; private const float OnePlusEps = 1 + Eps; + /// + /// Tests two line segments for intersection, ignoring collinear overlap. + /// + /// Start of segment A. + /// End of segment A. + /// Start of segment B. + /// End of segment B. + /// + /// Receives the intersection point when the segments intersect within tolerance. + /// When no intersection is detected, the value is left unchanged. + /// + /// + /// if the segments intersect within their extents (including endpoints), + /// if they are disjoint or collinear. + /// + /// + /// The method is based on solving two parametric line equations and uses a small epsilon + /// window around [0, 1] to account for floating-point error. Collinear cases are rejected + /// early (crossD ≈ 0) to keep the method fast; callers that need collinear overlap detection + /// must implement that separately. + /// public static bool LineSegmentToLineSegmentIgnoreCollinear(Vector2 a0, Vector2 a1, Vector2 b0, Vector2 b1, ref Vector2 intersectionPoint) { + // Direction vectors of the segments. float dax = a1.X - a0.X; float day = a1.Y - a0.Y; float dbx = b1.X - b0.X; float dby = b1.Y - b0.Y; + // Cross product of directions. When near zero, the lines are parallel or collinear. float crossD = (-dbx * day) + (dax * dby); - if (crossD > MinusEps && crossD < Eps) + // Reject parallel/collinear lines. Collinear overlap is intentionally ignored. + if (crossD is > MinusEps and < Eps) { return false; } + // Solve for parameters s and t where: + // a0 + t*(a1-a0) = b0 + s*(b1-b0) float s = ((-day * (a0.X - b0.X)) + (dax * (a0.Y - b0.Y))) / crossD; float t = ((dbx * (a0.Y - b0.Y)) - (dby * (a0.X - b0.X))) / crossD; + // If both parameters are within [0,1] (with tolerance), the segments intersect. if (s > MinusEps && s < OnePlusEps && t > MinusEps && t < OnePlusEps) { intersectionPoint.X = a0.X + (t * dax); From 1174d908611d075832cdefed7f4003cc25ca1497 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 4 Feb 2026 23:19:34 +1000 Subject: [PATCH 12/18] Do not use polygonclipper --- samples/DrawShapesWithImageSharp/Program.cs | 47 +- .../ImageSharp.Drawing.csproj | 1 - .../Processing/PathGradientBrush.cs | 2 +- .../Processing/PatternPen.cs | 2 +- src/ImageSharp.Drawing/Processing/Pen.cs | 16 +- .../Processing/PenOptions.cs | 12 +- .../Processing/ShapeOptions.cs | 6 +- src/ImageSharp.Drawing/Processing/SolidPen.cs | 2 +- .../Processing/StrokeOptions.cs | 63 + .../Shapes/BooleanOperation.cs | 31 + .../Shapes/ClipPathExtensions.cs | 2 +- src/ImageSharp.Drawing/Shapes/EndCapStyle.cs | 59 - src/ImageSharp.Drawing/Shapes/InnerJoin.cs | 36 + src/ImageSharp.Drawing/Shapes/JointStyle.cs | 95 - src/ImageSharp.Drawing/Shapes/LineCap.cs | 28 + src/ImageSharp.Drawing/Shapes/LineJoin.cs | 42 + .../Shapes/OutlinePathExtensions.cs | 78 +- .../Shapes/PolygonGeometry/ArrayBuilder{T}.cs | 156 + .../Shapes/PolygonGeometry/BoundsF.cs | 90 + .../PolygonGeometry/ClippedShapeGenerator.cs | 85 +- .../Shapes/PolygonGeometry/Clipper.cs | 111 + .../PolygonGeometry/ClipperException.cs | 37 + .../Shapes/PolygonGeometry/ClipperFillRule.cs | 23 + .../Shapes/PolygonGeometry/ClipperUtils.cs | 236 ++ .../Shapes/PolygonGeometry/JoinWith.cs | 29 + .../Shapes/PolygonGeometry/PolygonClipper.cs | 3461 +++++++++++++++++ .../PolygonGeometry/PolygonClipperFactory.cs | 384 -- .../Shapes/PolygonGeometry/PolygonStroker.cs | 109 +- .../PolygonGeometry/StrokedShapeGenerator.cs | 107 +- .../Shapes/PolygonGeometry/VertexFlags.cs | 14 + .../Shapes/Text/BaseGlyphBuilder.cs | 3 +- .../Drawing/DrawLinesTests.cs | 30 +- .../Drawing/FillPolygonTests.cs | 3 +- .../Drawing/Paths/DrawBezier.cs | 8 +- .../Drawing/Paths/DrawLine.cs | 8 +- .../Drawing/Paths/DrawPath.cs | 8 +- .../Drawing/Paths/DrawPathCollection.cs | 10 +- .../Drawing/Paths/DrawPolygon.cs | 26 +- .../Drawing/Paths/DrawRectangle.cs | 4 +- .../Issues/Issue_323.cs | 22 +- .../ShapeOptionsDefaultsExtensionsTests.cs | 21 +- .../Shapes/PolygonClipper/ClipperTests.cs | 1 - 42 files changed, 4684 insertions(+), 824 deletions(-) create mode 100644 src/ImageSharp.Drawing/Processing/StrokeOptions.cs create mode 100644 src/ImageSharp.Drawing/Shapes/BooleanOperation.cs delete mode 100644 src/ImageSharp.Drawing/Shapes/EndCapStyle.cs create mode 100644 src/ImageSharp.Drawing/Shapes/InnerJoin.cs delete mode 100644 src/ImageSharp.Drawing/Shapes/JointStyle.cs create mode 100644 src/ImageSharp.Drawing/Shapes/LineCap.cs create mode 100644 src/ImageSharp.Drawing/Shapes/LineJoin.cs create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/ArrayBuilder{T}.cs create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/BoundsF.cs create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/Clipper.cs create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperException.cs create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperFillRule.cs create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperUtils.cs create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/JoinWith.cs create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipperFactory.cs create mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexFlags.cs diff --git a/samples/DrawShapesWithImageSharp/Program.cs b/samples/DrawShapesWithImageSharp/Program.cs index e2862d48..04497dc9 100644 --- a/samples/DrawShapesWithImageSharp/Program.cs +++ b/samples/DrawShapesWithImageSharp/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Globalization; using System.Numerics; using SixLabors.Fonts; using SixLabors.ImageSharp; @@ -26,13 +27,13 @@ public static void Main(string[] args) private static void OutputStars() { - OutputStarOutline(5, 150, 250, width: 20, jointStyle: JointStyle.Miter); - OutputStarOutline(5, 150, 250, width: 20, jointStyle: JointStyle.Round); - OutputStarOutline(5, 150, 250, width: 20, jointStyle: JointStyle.Square); + OutputStarOutline(5, 150, 250, width: 20, jointStyle: LineJoin.Miter); + OutputStarOutline(5, 150, 250, width: 20, jointStyle: LineJoin.Round); + OutputStarOutline(5, 150, 250, width: 20, jointStyle: LineJoin.Bevel); - OutputStarOutlineDashed(5, 150, 250, width: 20, jointStyle: JointStyle.Square, cap: EndCapStyle.Butt); - OutputStarOutlineDashed(5, 150, 250, width: 20, jointStyle: JointStyle.Round, cap: EndCapStyle.Round); - OutputStarOutlineDashed(5, 150, 250, width: 20, jointStyle: JointStyle.Square, cap: EndCapStyle.Square); + OutputStarOutlineDashed(5, 150, 250, width: 20, jointStyle: LineJoin.Bevel, cap: LineCap.Butt); + OutputStarOutlineDashed(5, 150, 250, width: 20, jointStyle: LineJoin.Round, cap: LineCap.Round); + OutputStarOutlineDashed(5, 150, 250, width: 20, jointStyle: LineJoin.Bevel, cap: LineCap.Square); OutputStar(3, 5); OutputStar(4); @@ -103,15 +104,13 @@ private static void DrawSerializedOPenSansLetterShape_a() const string path = @"36.57813x49.16406 35.41797x43.67969 35.41797x43.67969 35.13672x43.67969 35.13672x43.67969 34.41629x44.54843 33.69641x45.34412 32.97708x46.06674 32.2583x46.71631 31.54007x47.29282 30.82239x47.79626 30.10526x48.22665 29.38867x48.58398 29.38867x48.58398 28.65012x48.88474 27.86707x49.14539 27.03952x49.36594 26.16748x49.54639 25.25095x49.68674 24.28992x49.78699 23.28439x49.84714 22.23438x49.86719 22.23438x49.86719 21.52775x49.85564 20.84048x49.82104 20.17258x49.76337 19.52405x49.68262 18.28506x49.4519 17.12354x49.12891 16.03946x48.71362 15.03284x48.20605 14.10367x47.6062 13.25195x46.91406 13.25195x46.91406 12.48978x46.13678 11.82922x45.28149 11.27029x44.34821 10.81299x43.33691 10.45731x42.24762 10.20325x41.08032 10.05081x39.83502 10.0127x39.18312 10x38.51172 10x38.51172 10.01823x37.79307 10.07292x37.09613 10.16407x36.42088 10.29169x35.76733 10.6563x34.52533 11.16675x33.37012 11.82304x32.3017 12.62518x31.32007 13.57317x30.42523 14.10185x30.01036 14.66699x29.61719 15.2686x29.24571 15.90666x28.89594 16.58119x28.56786 17.29218x28.26147 18.03962x27.97679 18.82353x27.71381 19.6439x27.47252 20.50073x27.25293 22.32378x26.87885 24.29266x26.59155 26.40739x26.39105 28.66797x26.27734 28.66797x26.27734 35.20703x26.06641 35.20703x26.06641 35.20703x23.67578 35.20703x23.67578 35.17654x22.57907 35.08508x21.55652 34.93265x20.60812 34.71924x19.73389 34.44485x18.93381 34.1095x18.20789 33.71317x17.55612 33.25586x16.97852 33.25586x16.97852 32.73154x16.47177 32.13416x16.03259 31.46371x15.66098 30.72021x15.35693 29.90366x15.12045 29.01404x14.95154 28.05136x14.85019 27.01563x14.81641 27.01563x14.81641 25.79175x14.86255 24.52832x15.00098 23.88177x15.1048 23.22534x15.23169 21.88281x15.55469 20.50073x15.96997 19.0791x16.47754 17.61792x17.07739 16.11719x17.76953 16.11719x17.76953 14.32422x13.30469 14.32422x13.30469 15.04465x12.92841 15.7821x12.573 17.30811x11.9248 18.90222x11.36011 20.56445x10.87891 20.56445x10.87891 22.26184x10.49438 23.96143x10.21973 24.81204x10.1236 25.66321x10.05493 26.51492x10.01373 27.36719x10 27.36719x10 29.03409x10.04779 29.82572x10.10753 30.58948x10.19116 31.32536x10.29869 32.03336x10.43011 32.71348x10.58543 33.36572x10.76465 34.58658x11.19476 35.69592x11.72046 36.69376x12.34174 37.58008x13.05859 37.58008x13.05859 38.35873x13.88092 39.03357x14.8186 39.60458x15.87164 40.07178x17.04004 40.26644x17.6675 40.43515x18.32379 40.5779x19.00893 40.6947x19.7229 40.78555x20.46571 40.85043x21.23737 40.88937x22.03786 40.90234x22.86719 40.90234x22.86719 40.90234x49.16406 23.39453x45.05078 24.06655x45.03911 24.72031x45.00409 25.97302x44.86401 27.15268x44.63055 28.25928x44.30371 29.29282x43.88348 30.2533x43.36987 31.14072x42.76288 31.95508x42.0625 31.95508x42.0625 32.6843x41.27808 33.31628x40.41895 33.85104x39.48511 34.28857x38.47656 34.62888x37.39331 34.87195x36.23535 35.01779x35.00269 35.06641x33.69531 35.06641x33.69531 35.06641x30.21484 35.06641x30.21484 29.23047x30.46094 29.23047x30.46094 27.55093x30.54855 25.9928x30.68835 24.55606x30.88034 23.24072x31.12451 22.04678x31.42087 20.97424x31.76941 20.0231x32.17014 19.19336x32.62305 19.19336x32.62305 18.47238x33.13528 17.84753x33.71399 17.31882x34.35916 16.88623x35.0708 16.54977x35.84891 16.30945x36.69348 16.16525x37.60452 16.11719x38.58203 16.11719x38.58203 16.14713x39.34943 16.23694x40.06958 16.38663x40.74249 16.59619x41.36816 17.19495x42.47778 18.0332x43.39844 18.0332x43.39844 19.08679x44.12134 19.68527x44.40533 20.33154x44.6377 21.0256x44.81842 21.76746x44.94751 22.5571x45.02496 23.39453x45.05078"; string[] paths = path.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries); - Polygon[] polys = paths.Select(line => + Polygon[] polys = [.. paths.Select(line => { string[] pl = line.Split([' '], StringSplitOptions.RemoveEmptyEntries); - PointF[] points = pl.Select(p => p.Split('x')) - .Select(p => new PointF(float.Parse(p[0]), float.Parse(p[1]))) - .ToArray(); + PointF[] points = [.. pl.Select(p => p.Split('x')).Select(p => new PointF(float.Parse(p[0], CultureInfo.InvariantCulture), float.Parse(p[1], CultureInfo.InvariantCulture)))]; return new Polygon(points); - }).ToArray(); + })]; ComplexPolygon complex = new(polys); complex.SaveImage("letter", "a.png"); @@ -122,16 +121,14 @@ private static void DrawSerializedOPenSansLetterShape_o() const string path = @"45.40234x29.93359 45.3838x31.09519 45.32819x32.22452 45.23549x33.32157 45.10571x34.38635 44.93886x35.41886 44.73492x36.4191 44.49391x37.38706 44.21582x38.32275 43.90065x39.22617 43.5484x40.09732 43.15907x40.9362 42.73267x41.7428 42.26918x42.51713 41.76862x43.25919 41.23097x43.96897 40.65625x44.64648 40.65625x44.64648 40.04884x45.28719 39.41315x45.88657 38.74916x46.4446 38.05688x46.9613 37.33632x47.43667 36.58746x47.8707 35.81032x48.26339 35.00488x48.61475 34.17116x48.92477 33.30914x49.19345 32.41884x49.4208 31.50024x49.60681 30.55336x49.75149 29.57819x49.85483 28.57472x49.91683 27.54297x49.9375 27.54297x49.9375 26.2691x49.8996 25.03149x49.78589 23.83014x49.59637 22.66504x49.33105 21.53619x48.98993 20.4436x48.573 19.38727x48.08026 18.36719x47.51172 18.36719x47.51172 17.3938x46.87231 16.47754x46.16699 15.61841x45.39575 14.81641x44.55859 14.07153x43.65552 13.38379x42.68652 12.75317x41.65161 12.17969x40.55078 12.17969x40.55078 11.66882x39.39282 11.22607x38.18652 10.85144x36.93188 10.54492x35.62891 10.30652x34.27759 10.13623x32.87793 10.03406x31.42993 10x29.93359 10x29.93359 10.0184x28.77213 10.07361x27.64322 10.16562x26.54685 10.29443x25.48303 10.46005x24.45176 10.66248x23.45303 10.9017x22.48685 11.17773x21.55322 11.49057x20.65214 11.84021x19.7836 12.22665x18.94761 12.6499x18.14417 13.10995x17.37327 13.60681x16.63492 14.14047x15.92912 14.71094x15.25586 14.71094x15.25586 15.31409x14.61941 15.9458x14.02402 16.60608x13.46969 17.29492x12.95642 18.01233x12.48421 18.7583x12.05307 19.53284x11.66299 20.33594x11.31396 21.1676x11.006 22.02783x10.73911 22.91663x10.51327 23.83398x10.32849 24.77991x10.18478 25.75439x10.08212 26.75745x10.02053 27.78906x10 27.78906x10 28.78683x10.02101 29.75864x10.08405 30.70449x10.1891 31.62439x10.33618 32.51833x10.52528 33.38632x10.75641 34.22836x11.02956 35.04443x11.34473 35.83456x11.70192 36.59872x12.10114 37.33694x12.54237 38.04919x13.02563 38.7355x13.55092 39.39584x14.11823 40.03024x14.72755 40.63867x15.37891 40.63867x15.37891 41.21552x16.0661 41.75516x16.78296 42.25757x17.52948 42.72278x18.30566 43.15077x19.11151 43.54153x19.94702 43.89509x20.81219 44.21143x21.70703 44.49055x22.63153 44.73245x23.58569 44.93714x24.56952 45.10461x25.58301 45.23487x26.62616 45.32791x27.69897 45.38374x28.80145 45.40234x29.93359 16.04688x29.93359 16.09302x31.72437 16.23145x33.40527 16.33527x34.20453 16.46216x34.97632 16.61212x35.72064 16.78516x36.4375 16.98126x37.12689 17.20044x37.78882 17.44269x38.42328 17.70801x39.03027 18.30786x40.16187 19x41.18359 19x41.18359 19.78168x42.08997 20.65015x42.87549 21.60541x43.54016 22.64746x44.08398 23.77631x44.50696 24.99194x44.80908 26.29437x44.99036 26.97813x45.03568 27.68359x45.05078 27.68359x45.05078 28.38912x45.03575 29.07309x44.99063 30.37634x44.81018 31.59335x44.50943 32.72412x44.08838 33.76865x43.54703 34.72693x42.88538 35.59897x42.10342 36.38477x41.20117 36.38477x41.20117 37.08102x40.18301 37.68445x39.05334 37.95135x38.44669 38.19504x37.81216 38.41552x37.14976 38.61279x36.45947 38.78686x35.74131 38.93771x34.99527 39.06536x34.22135 39.1698x33.41956 39.30905x31.73233 39.35547x29.93359 39.35547x29.93359 39.30905x28.15189 39.1698x26.48059 39.06536x25.68635 38.93771x24.91971 38.78686x24.18067 38.61279x23.46924 38.41552x22.78541 38.19504x22.12918 37.95135x21.50056 37.68445x20.89954 37.08102x19.7803 36.38477x18.77148 36.38477x18.77148 35.59787x17.87747 34.72253x17.10266 33.75876x16.44705 32.70654x15.91064 31.56589x15.49344 30.33679x15.19543 29.68908x15.09113 29.01926x15.01663 28.32732x14.97193 27.61328x14.95703 27.61328x14.95703 26.90796x14.97173 26.22461x15.01581 24.92383x15.19214 23.71094x15.48602 22.58594x15.89746 21.54883x16.42645 20.59961x17.073 19.73828x17.8371 18.96484x18.71875 18.96484x18.71875 18.28094x19.71686 17.68823x20.83032 17.42607x21.43031 17.18671x22.05914 16.97014x22.71681 16.77637x23.40332 16.60539x24.11867 16.45721x24.86285 16.33183x25.63588 16.22925x26.43774 16.09247x28.12799 16.04688x29.93359 "; string[] paths = path.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries); - Polygon[] polys = paths.Select(line => + Polygon[] polys = [.. paths.Select(line => { string[] pl = line.Split([' '], StringSplitOptions.RemoveEmptyEntries); - PointF[] points = pl.Select(p => p.Split('x')) - .Select(p => new PointF(float.Parse(p[0]), float.Parse(p[1]))) - .ToArray(); + PointF[] points = [.. pl.Select(p => p.Split('x')).Select(p => new PointF(float.Parse(p[0], CultureInfo.InvariantCulture), float.Parse(p[1], CultureInfo.InvariantCulture)))]; return new Polygon(points); - }).ToArray(); + })]; ComplexPolygon complex = new(polys); complex.SaveImage("letter", "o.png"); @@ -182,23 +179,33 @@ private static void OutputDrawnShapeHourGlass() sb.Build().Translate(0, 10).Scale(10).SaveImage("drawing", $"HourGlass.png"); } - private static void OutputStarOutline(int points, float inner = 10, float outer = 20, float width = 5, JointStyle jointStyle = JointStyle.Miter) + private static void OutputStarOutline(int points, float inner = 10, float outer = 20, float width = 5, LineJoin jointStyle = LineJoin.Miter) { // center the shape outerRadii + 10 px away from edges float offset = outer + 10; Star star = new(offset, offset, points, inner, outer); - IPath outline = star.GenerateOutline(width, jointStyle, EndCapStyle.Butt); + StrokeOptions options = new() + { + LineJoin = jointStyle, + LineCap = LineCap.Butt + }; + IPath outline = star.GenerateOutline(width, options); outline.SaveImage("Stars", $"StarOutline_{points}_{jointStyle}.png"); } - private static void OutputStarOutlineDashed(int points, float inner = 10, float outer = 20, float width = 5, JointStyle jointStyle = JointStyle.Miter, EndCapStyle cap = EndCapStyle.Butt) + private static void OutputStarOutlineDashed(int points, float inner = 10, float outer = 20, float width = 5, LineJoin jointStyle = LineJoin.Miter, LineCap cap = LineCap.Butt) { // center the shape outerRadii + 10 px away from edges float offset = outer + 10; Star star = new(offset, offset, points, inner, outer); - IPath outline = star.GenerateOutline(width, [3, 3], jointStyle, cap); + StrokeOptions options = new() + { + LineCap = cap, + LineJoin = jointStyle + }; + IPath outline = star.GenerateOutline(width, [3, 3], options); outline.SaveImage("Stars", $"StarOutlineDashed_{points}_{jointStyle}_{cap}.png"); } diff --git a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj index d0a1b489..488180d6 100644 --- a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj +++ b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj @@ -46,7 +46,6 @@ - diff --git a/src/ImageSharp.Drawing/Processing/PathGradientBrush.cs b/src/ImageSharp.Drawing/Processing/PathGradientBrush.cs index 78799c43..ef315427 100644 --- a/src/ImageSharp.Drawing/Processing/PathGradientBrush.cs +++ b/src/ImageSharp.Drawing/Processing/PathGradientBrush.cs @@ -224,7 +224,7 @@ public PathGradientBrushApplicator( : base(configuration, options, source) { this.edges = edges; - Vector2[] points = edges.Select(s => s.Start).ToArray(); + Vector2[] points = [.. edges.Select(s => s.Start)]; this.center = points.Aggregate((p1, p2) => p1 + p2) / edges.Count; this.centerColor = centerColor.ToScaledVector4(); diff --git a/src/ImageSharp.Drawing/Processing/PatternPen.cs b/src/ImageSharp.Drawing/Processing/PatternPen.cs index c2872be5..f6da8ee0 100644 --- a/src/ImageSharp.Drawing/Processing/PatternPen.cs +++ b/src/ImageSharp.Drawing/Processing/PatternPen.cs @@ -75,5 +75,5 @@ public override bool Equals(Pen? other) /// public override IPath GeneratePath(IPath path, float strokeWidth) - => path.GenerateOutline(strokeWidth, this.StrokePattern, this.JointStyle, this.EndCapStyle); + => path.GenerateOutline(strokeWidth, this.StrokePattern, this.StrokeOptions); } diff --git a/src/ImageSharp.Drawing/Processing/Pen.cs b/src/ImageSharp.Drawing/Processing/Pen.cs index 9602c5c9..e3fbd309 100644 --- a/src/ImageSharp.Drawing/Processing/Pen.cs +++ b/src/ImageSharp.Drawing/Processing/Pen.cs @@ -58,6 +58,7 @@ protected Pen(Brush strokeFill, float strokeWidth, float[] strokePattern) this.StrokeFill = strokeFill; this.StrokeWidth = strokeWidth; this.pattern = strokePattern; + this.StrokeOptions = new StrokeOptions(); } /// @@ -69,8 +70,7 @@ protected Pen(PenOptions options) this.StrokeFill = options.StrokeFill; this.StrokeWidth = options.StrokeWidth; this.pattern = options.StrokePattern; - this.JointStyle = options.JointStyle; - this.EndCapStyle = options.EndCapStyle; + this.StrokeOptions = options.StrokeOptions ?? new StrokeOptions(); } /// @@ -82,11 +82,8 @@ protected Pen(PenOptions options) /// public ReadOnlySpan StrokePattern => this.pattern; - /// - public JointStyle JointStyle { get; } - - /// - public EndCapStyle EndCapStyle { get; } + /// + public StrokeOptions StrokeOptions { get; } /// /// Applies the styling from the pen to a path and generate a new path with the final vector. @@ -108,9 +105,8 @@ public IPath GeneratePath(IPath path) public virtual bool Equals(Pen? other) => other != null && this.StrokeWidth == other.StrokeWidth - && this.JointStyle == other.JointStyle - && this.EndCapStyle == other.EndCapStyle && this.StrokeFill.Equals(other.StrokeFill) + && this.StrokeOptions.Equals(other.StrokeOptions) && this.StrokePattern.SequenceEqual(other.StrokePattern); /// @@ -118,5 +114,5 @@ public virtual bool Equals(Pen? other) /// public override int GetHashCode() - => HashCode.Combine(this.StrokeWidth, this.JointStyle, this.EndCapStyle, this.StrokeFill, this.pattern); + => HashCode.Combine(this.StrokeWidth, this.StrokeFill, this.StrokeOptions, this.pattern); } diff --git a/src/ImageSharp.Drawing/Processing/PenOptions.cs b/src/ImageSharp.Drawing/Processing/PenOptions.cs index d000b9f9..9cd1ab22 100644 --- a/src/ImageSharp.Drawing/Processing/PenOptions.cs +++ b/src/ImageSharp.Drawing/Processing/PenOptions.cs @@ -51,8 +51,7 @@ public PenOptions(Brush strokeFill, float strokeWidth, float[]? strokePattern) this.StrokeFill = strokeFill; this.StrokeWidth = strokeWidth; this.StrokePattern = strokePattern ?? Pens.EmptyPattern; - this.JointStyle = JointStyle.Square; - this.EndCapStyle = EndCapStyle.Butt; + this.StrokeOptions = new StrokeOptions(); } /// @@ -71,12 +70,7 @@ public PenOptions(Brush strokeFill, float strokeWidth, float[]? strokePattern) public float[] StrokePattern { get; } /// - /// Gets or sets the joint style. + /// Gets or sets the stroke geometry options used to stroke paths drawn with this pen. /// - public JointStyle JointStyle { get; set; } - - /// - /// Gets or sets the end cap style. - /// - public EndCapStyle EndCapStyle { get; set; } + public StrokeOptions? StrokeOptions { get; set; } } diff --git a/src/ImageSharp.Drawing/Processing/ShapeOptions.cs b/src/ImageSharp.Drawing/Processing/ShapeOptions.cs index 6f079e43..4df70625 100644 --- a/src/ImageSharp.Drawing/Processing/ShapeOptions.cs +++ b/src/ImageSharp.Drawing/Processing/ShapeOptions.cs @@ -1,8 +1,6 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using SixLabors.PolygonClipper; - namespace SixLabors.ImageSharp.Drawing.Processing; /// @@ -20,7 +18,7 @@ public ShapeOptions() private ShapeOptions(ShapeOptions source) { this.IntersectionRule = source.IntersectionRule; - this.ClippingOperation = source.ClippingOperation; + this.BooleanOperation = source.BooleanOperation; } /// @@ -28,7 +26,7 @@ private ShapeOptions(ShapeOptions source) /// /// Defaults to . /// - public BooleanOperation ClippingOperation { get; set; } = BooleanOperation.Difference; + public BooleanOperation BooleanOperation { get; set; } = BooleanOperation.Difference; /// /// Gets or sets the rule for calculating intersection points. diff --git a/src/ImageSharp.Drawing/Processing/SolidPen.cs b/src/ImageSharp.Drawing/Processing/SolidPen.cs index e2c827e1..b56e465a 100644 --- a/src/ImageSharp.Drawing/Processing/SolidPen.cs +++ b/src/ImageSharp.Drawing/Processing/SolidPen.cs @@ -68,5 +68,5 @@ public override bool Equals(Pen? other) /// public override IPath GeneratePath(IPath path, float strokeWidth) - => path.GenerateOutline(strokeWidth, this.JointStyle, this.EndCapStyle); + => path.GenerateOutline(strokeWidth, this.StrokeOptions); } diff --git a/src/ImageSharp.Drawing/Processing/StrokeOptions.cs b/src/ImageSharp.Drawing/Processing/StrokeOptions.cs new file mode 100644 index 00000000..4e4b34e8 --- /dev/null +++ b/src/ImageSharp.Drawing/Processing/StrokeOptions.cs @@ -0,0 +1,63 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Drawing.Processing; + +/// +/// Provides configuration options for geometric stroke generation. +/// +public sealed class StrokeOptions : IEquatable +{ + /// + /// Gets or sets the miter limit used to clamp outer miter joins. + /// + public double MiterLimit { get; set; } = 4; + + /// + /// Gets or sets the inner miter limit used to clamp joins on acute interior angles. + /// + public double InnerMiterLimit { get; set; } = 1.01; + + /// + /// Gets or sets the arc approximation scale used for round joins and caps. + /// + public double ApproximationScale { get; set; } = 1.0; + + /// + /// Gets or sets the outer line join style used for stroking corners. + /// + public LineJoin LineJoin { get; set; } = LineJoin.Bevel; + + /// + /// Gets or sets the line cap style used for open path ends. + /// + public LineCap LineCap { get; set; } = LineCap.Butt; + + /// + /// Gets or sets the join style used for sharp interior angles. + /// + public InnerJoin InnerJoin { get; set; } = InnerJoin.Miter; + + /// + public override bool Equals(object? obj) => this.Equals(obj as StrokeOptions); + + /// + public bool Equals(StrokeOptions? other) + => other is not null && + this.MiterLimit == other.MiterLimit && + this.InnerMiterLimit == other.InnerMiterLimit && + this.ApproximationScale == other.ApproximationScale && + this.LineJoin == other.LineJoin && + this.LineCap == other.LineCap && + this.InnerJoin == other.InnerJoin; + + /// + public override int GetHashCode() + => HashCode.Combine( + this.MiterLimit, + this.InnerMiterLimit, + this.ApproximationScale, + this.LineJoin, + this.LineCap, + this.InnerJoin); +} diff --git a/src/ImageSharp.Drawing/Shapes/BooleanOperation.cs b/src/ImageSharp.Drawing/Shapes/BooleanOperation.cs new file mode 100644 index 00000000..7ee16019 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/BooleanOperation.cs @@ -0,0 +1,31 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Drawing; + +/// +/// Specifies the type of boolean operation to perform on polygons. +/// +public enum BooleanOperation +{ + /// + /// The intersection operation, which results in the area common to both polygons. + /// + Intersection = 0, + + /// + /// The union operation, which results in the combined area of both polygons. + /// + Union = 1, + + /// + /// The difference operation, which subtracts the clipping polygon from the subject polygon. + /// + Difference = 2, + + /// + /// The exclusive OR (XOR) operation, which results in the area covered by exactly one polygon, + /// excluding the overlapping areas. + /// + Xor = 3 +} diff --git a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs index 498126da..a1101853 100644 --- a/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs +++ b/src/ImageSharp.Drawing/Shapes/ClipPathExtensions.cs @@ -59,7 +59,7 @@ public static IPath Clip( clipper.AddPath(subjectPath, ClippingType.Subject); clipper.AddPaths(clipPaths, ClippingType.Clip); - IPath[] result = clipper.GenerateClippedShapes(options.ClippingOperation); + IPath[] result = clipper.GenerateClippedShapes(options.BooleanOperation); return new ComplexPolygon(result); } diff --git a/src/ImageSharp.Drawing/Shapes/EndCapStyle.cs b/src/ImageSharp.Drawing/Shapes/EndCapStyle.cs deleted file mode 100644 index f5d8d0f5..00000000 --- a/src/ImageSharp.Drawing/Shapes/EndCapStyle.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -namespace SixLabors.ImageSharp.Drawing; - -/// -/// The style to apply to the end cap when generating an outline. -/// -public enum EndCapStyle -{ - /// - /// The outline stops exactly at the end of the path. - /// - Butt = 0, - - /// - /// The outline extends with a rounded style passed the end of the path. - /// - Round = 1, - - /// - /// The outlines ends squared off passed the end of the path. - /// - Square = 2, - - /// - /// The outline is treated as a polygon. - /// - Polygon = 3, - - /// - /// The outlines ends are joined and the path treated as a polyline - /// - Joined = 4 -} - -/// -/// Specifies the shape to be used at the ends of open lines or paths when stroking. -/// -internal enum LineCap -{ - /// - /// The stroke ends exactly at the endpoint. - /// No extension is added beyond the path's end coordinates. - /// - Butt, - - /// - /// The stroke extends beyond the endpoint by half the line width, - /// producing a square edge. - /// - Square, - - /// - /// The stroke ends with a semicircular cap, - /// extending beyond the endpoint by half the line width. - /// - Round -} diff --git a/src/ImageSharp.Drawing/Shapes/InnerJoin.cs b/src/ImageSharp.Drawing/Shapes/InnerJoin.cs new file mode 100644 index 00000000..c8c1c7b3 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/InnerJoin.cs @@ -0,0 +1,36 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Drawing; + +/// +/// Specifies how inner corners of a stroked path or polygon are rendered +/// when the path turns sharply inward. These settings control how the interior +/// edge of the stroke is joined at such corners. +/// +public enum InnerJoin +{ + /// + /// Joins inner corners by connecting the edges with a straight line, + /// producing a flat, beveled appearance. + /// + Bevel, + + /// + /// Joins inner corners by extending the inner edges until they meet at a sharp point. + /// This can create long, narrow joins for acute angles. + /// + Miter, + + /// + /// Joins inner corners with a notched appearance, + /// forming a small cut or indentation at the join. + /// + Jag, + + /// + /// Joins inner corners using a circular arc between the edges, + /// creating a smooth, rounded interior transition. + /// + Round +} diff --git a/src/ImageSharp.Drawing/Shapes/JointStyle.cs b/src/ImageSharp.Drawing/Shapes/JointStyle.cs deleted file mode 100644 index d3d4d58e..00000000 --- a/src/ImageSharp.Drawing/Shapes/JointStyle.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -namespace SixLabors.ImageSharp.Drawing; - -/// -/// The style to apply to the joints when generating an outline. -/// -public enum JointStyle -{ - /// - /// Joints are squared off 1 width distance from the corner. - /// - Square = 0, - - /// - /// Rounded joints. Joints generate with a rounded profile. - /// - Round = 1, - - /// - /// Joints will generate to a long point unless the end of the point will exceed 4 times the width then we generate the joint using . - /// - Miter = 2 -} - -/// -/// Specifies how the connection between two consecutive line segments (a join) -/// is rendered when stroking paths or polygons. -/// -internal enum LineJoin -{ - /// - /// Joins lines by extending their outer edges until they meet at a sharp corner. - /// The miter length is limited by the miter limit; if exceeded, the join may fall back to a bevel. - /// - MiterJoin = 0, - - /// - /// Joins lines by extending their outer edges to form a miter, - /// but if the miter length exceeds the miter limit, the join is truncated - /// at the limit distance rather than falling back to a bevel. - /// - MiterJoinRevert = 1, - - /// - /// Joins lines by connecting them with a circular arc centered at the join point, - /// producing a smooth, rounded corner. - /// - RoundJoin = 2, - - /// - /// Joins lines by connecting the outer corners directly with a straight line, - /// forming a flat edge at the join point. - /// - BevelJoin = 3, - - /// - /// Joins lines by forming a miter, but if the miter limit is exceeded, - /// the join falls back to a round join instead of a bevel. - /// - MiterJoinRound = 4 -} - -/// -/// Specifies how inner corners of a stroked path or polygon are rendered -/// when the path turns sharply inward. These settings control how the interior -/// edge of the stroke is joined at such corners. -/// -internal enum InnerJoin -{ - /// - /// Joins inner corners by connecting the edges with a straight line, - /// producing a flat, beveled appearance. - /// - InnerBevel, - - /// - /// Joins inner corners by extending the inner edges until they meet at a sharp point. - /// This can create long, narrow joins for acute angles. - /// - InnerMiter, - - /// - /// Joins inner corners with a notched appearance, - /// forming a small cut or indentation at the join. - /// - InnerJag, - - /// - /// Joins inner corners using a circular arc between the edges, - /// creating a smooth, rounded interior transition. - /// - InnerRound -} diff --git a/src/ImageSharp.Drawing/Shapes/LineCap.cs b/src/ImageSharp.Drawing/Shapes/LineCap.cs new file mode 100644 index 00000000..1df99225 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/LineCap.cs @@ -0,0 +1,28 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Drawing; + +/// +/// Specifies the shape to be used at the ends of open lines or paths when stroking. +/// +public enum LineCap +{ + /// + /// The stroke ends exactly at the endpoint. + /// No extension is added beyond the path's end coordinates. + /// + Butt, + + /// + /// The stroke extends beyond the endpoint by half the line width, + /// producing a square edge. + /// + Square, + + /// + /// The stroke ends with a semicircular cap, + /// extending beyond the endpoint by half the line width. + /// + Round +} diff --git a/src/ImageSharp.Drawing/Shapes/LineJoin.cs b/src/ImageSharp.Drawing/Shapes/LineJoin.cs new file mode 100644 index 00000000..4ea8ea81 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/LineJoin.cs @@ -0,0 +1,42 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Drawing; + +/// +/// Specifies how the connection between two consecutive line segments (a join) +/// is rendered when stroking paths or polygons. +/// +public enum LineJoin +{ + /// + /// Joins lines by extending their outer edges until they meet at a sharp corner. + /// The miter length is limited by the miter limit; if exceeded, the join may fall back to a bevel. + /// + Miter = 0, + + /// + /// Joins lines by extending their outer edges to form a miter, + /// but if the miter length exceeds the miter limit, the join is truncated + /// at the limit distance rather than falling back to a bevel. + /// + MiterRevert = 1, + + /// + /// Joins lines by connecting them with a circular arc centered at the join point, + /// producing a smooth, rounded corner. + /// + Round = 2, + + /// + /// Joins lines by connecting the outer corners directly with a straight line, + /// forming a flat edge at the join point. + /// + Bevel = 3, + + /// + /// Joins lines by forming a miter, but if the miter limit is exceeded, + /// the join falls back to a round join instead of a bevel. + /// + MiterRound = 4 +} diff --git a/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs b/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs index 7533df08..466a597f 100644 --- a/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs +++ b/src/ImageSharp.Drawing/Shapes/OutlinePathExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Numerics; +using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; namespace SixLabors.ImageSharp.Drawing; @@ -11,10 +12,6 @@ namespace SixLabors.ImageSharp.Drawing; /// public static class OutlinePathExtensions { - private const float MiterOffsetDelta = 20; - private const JointStyle DefaultJointStyle = JointStyle.Square; - private const EndCapStyle DefaultEndCapStyle = EndCapStyle.Butt; - /// /// Generates an outline of the path. /// @@ -22,24 +19,23 @@ public static class OutlinePathExtensions /// The outline width. /// A new representing the outline. public static IPath GenerateOutline(this IPath path, float width) - => GenerateOutline(path, width, DefaultJointStyle, DefaultEndCapStyle); + => GenerateOutline(path, width, new StrokeOptions()); /// /// Generates an outline of the path. /// /// The path to outline /// The outline width. - /// The style to apply to the joints. - /// The style to apply to the end caps. + /// The stroke geometry options. /// A new representing the outline. - public static IPath GenerateOutline(this IPath path, float width, JointStyle jointStyle, EndCapStyle endCapStyle) + public static IPath GenerateOutline(this IPath path, float width, StrokeOptions strokeOptions) { if (width <= 0) { return Path.Empty; } - StrokedShapeGenerator generator = new(MiterOffsetDelta); + StrokedShapeGenerator generator = new(strokeOptions); return new ComplexPolygon(generator.GenerateStrokedShapes(path, width)); } @@ -59,10 +55,10 @@ public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpanThe path to outline /// The outline width. /// The pattern made of multiples of the width. - /// Whether the first item in the pattern is on or off. + /// The stroke geometry options. /// A new representing the outline. - public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan pattern, bool startOff) - => GenerateOutline(path, width, pattern, startOff, DefaultJointStyle, DefaultEndCapStyle); + public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan pattern, StrokeOptions strokeOptions) + => GenerateOutline(path, width, pattern, false, strokeOptions); /// /// Generates an outline of the path with alternating on and off segments based on the pattern. @@ -70,11 +66,10 @@ public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpanThe path to outline /// The outline width. /// The pattern made of multiples of the width. - /// The style to apply to the joints. - /// The style to apply to the end caps. + /// Whether the first item in the pattern is on or off. /// A new representing the outline. - public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan pattern, JointStyle jointStyle, EndCapStyle endCapStyle) - => GenerateOutline(path, width, pattern, false, jointStyle, endCapStyle); + public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan pattern, bool startOff) + => GenerateOutline(path, width, pattern, startOff, new StrokeOptions()); /// /// Generates an outline of the path with alternating on and off segments based on the pattern. @@ -83,10 +78,14 @@ public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpanThe outline width. /// The pattern made of multiples of the width. /// Whether the first item in the pattern is on or off. - /// The style to apply to the joints. - /// The style to apply to the end caps. + /// The stroke geometry options. /// A new representing the outline. - public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan pattern, bool startOff, JointStyle jointStyle, EndCapStyle endCapStyle) + public static IPath GenerateOutline( + this IPath path, + float width, + ReadOnlySpan pattern, + bool startOff, + StrokeOptions strokeOptions) { if (width <= 0) { @@ -95,10 +94,24 @@ public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan paths = path.Flatten(); @@ -119,6 +132,24 @@ public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan eps) + { + // Avoid runaway segmentation by falling back when the dash count explodes. + float estimatedSegments = (totalLength / patternLength) * pattern.Length; + if (estimatedSegments > maxPatternSegments) + { + return path.GenerateOutline(width, strokeOptions); + } + } int i = 0; Vector2 current = pts[0]; @@ -129,6 +160,7 @@ public static IPath GenerateOutline(this IPath path, float width, ReadOnlySpan +/// A helper type for avoiding allocations while building arrays. +/// +/// The type of item contained in the array. +internal struct ArrayBuilder + where T : struct +{ + private const int DefaultCapacity = 4; + + // Starts out null, initialized on first Add. + private T[]? data; + private int size; + + /// + /// Initializes a new instance of the struct. + /// + /// The initial capacity of the array. + public ArrayBuilder(int capacity) + : this() + { + if (capacity > 0) + { + this.data = new T[capacity]; + } + } + + /// + /// Gets or sets the number of items in the array. + /// + public int Length + { + readonly get => this.size; + + set + { + if (value > 0) + { + this.EnsureCapacity(value); + this.size = value; + } + else + { + this.size = 0; + } + } + } + + /// + /// Returns a reference to specified element of the array. + /// + /// The index of the element to return. + /// The . + /// + /// Thrown when index less than 0 or index greater than or equal to . + /// + public readonly ref T this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + DebugGuard.MustBeBetweenOrEqualTo(index, 0, this.size, nameof(index)); + return ref this.data![index]; + } + } + + /// + /// Adds the given item to the array. + /// + /// The item to add. + public void Add(T item) + { + int position = this.size; + T[]? array = this.data; + + if (array != null && (uint)position < (uint)array.Length) + { + this.size = position + 1; + array[position] = item; + } + else + { + this.AddWithResize(item); + } + } + + // Non-inline from Add to improve its code quality as uncommon path + [MethodImpl(MethodImplOptions.NoInlining)] + private void AddWithResize(T item) + { + int size = this.size; + this.Grow(size + 1); + this.size = size + 1; + this.data[size] = item; + } + + /// + /// Remove the last item from the array. + /// + public void RemoveLast() + { + DebugGuard.MustBeGreaterThan(this.size, 0, nameof(this.size)); + this.size--; + } + + /// + /// Clears the array. + /// Allocated memory is left intact for future usage. + /// + public void Clear() => + + // No need to actually clear since we're not allowing reference types. + this.size = 0; + + private void EnsureCapacity(int min) + { + int length = this.data?.Length ?? 0; + if (length < min) + { + this.Grow(min); + } + } + + [MemberNotNull(nameof(this.data))] + private void Grow(int capacity) + { + // Same expansion algorithm as List. + int length = this.data?.Length ?? 0; + int newCapacity = length == 0 ? DefaultCapacity : length * 2; + if ((uint)newCapacity > Array.MaxLength) + { + newCapacity = Array.MaxLength; + } + + if (newCapacity < capacity) + { + newCapacity = capacity; + } + + T[] array = new T[newCapacity]; + + if (this.size > 0) + { + Array.Copy(this.data!, array, this.size); + } + + this.data = array; + } +} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/BoundsF.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/BoundsF.cs new file mode 100644 index 00000000..14ac870b --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/BoundsF.cs @@ -0,0 +1,90 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; + +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; + +internal struct BoundsF +{ + public float Left; + public float Top; + public float Right; + public float Bottom; + + public BoundsF(float l, float t, float r, float b) + { + this.Left = l; + this.Top = t; + this.Right = r; + this.Bottom = b; + } + + public BoundsF(BoundsF bounds) + { + this.Left = bounds.Left; + this.Top = bounds.Top; + this.Right = bounds.Right; + this.Bottom = bounds.Bottom; + } + + public BoundsF(bool isValid) + { + if (isValid) + { + this.Left = 0; + this.Top = 0; + this.Right = 0; + this.Bottom = 0; + } + else + { + this.Left = float.MaxValue; + this.Top = float.MaxValue; + this.Right = -float.MaxValue; + this.Bottom = -float.MaxValue; + } + } + + public float Width + { + readonly get => this.Right - this.Left; + set => this.Right = this.Left + value; + } + + public float Height + { + readonly get => this.Bottom - this.Top; + set => this.Bottom = this.Top + value; + } + + public readonly bool IsEmpty() + => this.Bottom <= this.Top || this.Right <= this.Left; + + public readonly Vector2 MidPoint() + => new Vector2(this.Left + this.Right, this.Top + this.Bottom) * .5F; + + public readonly bool Contains(Vector2 pt) + => pt.X > this.Left + && pt.X < this.Right + && pt.Y > this.Top && pt.Y < this.Bottom; + + public readonly bool Contains(BoundsF bounds) + => bounds.Left >= this.Left + && bounds.Right <= this.Right + && bounds.Top >= this.Top + && bounds.Bottom <= this.Bottom; + + public readonly bool Intersects(BoundsF bounds) + => (Math.Max(this.Left, bounds.Left) < Math.Min(this.Right, bounds.Right)) + && (Math.Max(this.Top, bounds.Top) < Math.Min(this.Bottom, bounds.Bottom)); + + public readonly PathF AsPath() + => + [ + new Vector2(this.Left, this.Top), + new Vector2(this.Right, this.Top), + new Vector2(this.Right, this.Bottom), + new Vector2(this.Left, this.Bottom) + ]; +} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippedShapeGenerator.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippedShapeGenerator.cs index 9a6ac206..7fff6b6e 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippedShapeGenerator.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippedShapeGenerator.cs @@ -1,32 +1,31 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using SixLabors.PolygonClipper; -using ClipperPolygon = SixLabors.PolygonClipper.Polygon; -using PolygonClipperAction = SixLabors.PolygonClipper.PolygonClipper; - namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; /// /// Generates clipped shapes from one or more input paths using polygon boolean operations. /// /// -/// This class provides a high-level wrapper around the low-level . +/// This class provides a high-level wrapper around the low-level . /// It accumulates subject and clip polygons, applies the specified , /// and converts the resulting polygon contours back into instances suitable /// for rendering or further processing. /// internal sealed class ClippedShapeGenerator { - private ClipperPolygon? subject; - private ClipperPolygon? clip; + private readonly PolygonClipper polygonClipper; private readonly IntersectionRule rule; /// /// Initializes a new instance of the class. /// /// The intersection rule. - public ClippedShapeGenerator(IntersectionRule rule) => this.rule = rule; + public ClippedShapeGenerator(IntersectionRule rule) + { + this.rule = rule; + this.polygonClipper = new PolygonClipper() { PreserveCollinear = true }; + } /// /// Generates the final clipped shapes from the previously provided subject and clip paths. @@ -35,30 +34,48 @@ internal sealed class ClippedShapeGenerator /// The boolean operation to perform, such as , /// , or . /// + /// TEMP. Remove when we update IntersectionRule to add missing entries. /// /// An array of instances representing the result of the boolean operation. /// - public IPath[] GenerateClippedShapes(BooleanOperation operation) + public IPath[] GenerateClippedShapes(BooleanOperation operation, bool? positive = null) { - ArgumentNullException.ThrowIfNull(this.subject); - ArgumentNullException.ThrowIfNull(this.clip); + PathsF closedPaths = []; + PathsF openPaths = []; - PolygonClipperAction polygonClipper = new(this.subject, this.clip, operation); + ClipperFillRule fillRule = this.rule == IntersectionRule.EvenOdd ? ClipperFillRule.EvenOdd : ClipperFillRule.NonZero; + + if (positive.HasValue) + { + fillRule = positive.Value ? ClipperFillRule.Positive : ClipperFillRule.Negative; + } - ClipperPolygon result = polygonClipper.Run(); + this.polygonClipper.Execute(operation, fillRule, closedPaths, openPaths); - IPath[] shapes = new IPath[result.Count]; + IPath[] shapes = new IPath[closedPaths.Count + openPaths.Count]; int index = 0; - for (int i = 0; i < result.Count; i++) + for (int i = 0; i < closedPaths.Count; i++) { - Contour contour = result[i]; - PointF[] points = new PointF[contour.Count]; + PathF path = closedPaths[i]; + PointF[] points = new PointF[path.Count]; - for (int j = 0; j < contour.Count; j++) + for (int j = 0; j < path.Count; j++) { - Vertex vertex = contour[j]; - points[j] = new PointF((float)vertex.X, (float)vertex.Y); + points[j] = path[j]; + } + + shapes[index++] = new Polygon(points); + } + + for (int i = 0; i < openPaths.Count; i++) + { + PathF path = openPaths[i]; + PointF[] points = new PointF[path.Count]; + + for (int j = 0; j < path.Count; j++) + { + points[j] = path[j]; } shapes[index++] = new Polygon(points); @@ -80,16 +97,9 @@ public void AddPaths(IEnumerable paths, ClippingType clippingType) { Guard.NotNull(paths, nameof(paths)); - // Accumulate all paths of the complex shape into a single polygon. - ClipperPolygon polygon = PolygonClipperFactory.FromPaths(paths, this.rule); - - if (clippingType == ClippingType.Clip) - { - this.clip = polygon; - } - else + foreach (IPath p in paths) { - this.subject = polygon; + this.AddPath(p, clippingType); } } @@ -104,14 +114,21 @@ public void AddPath(IPath path, ClippingType clippingType) { Guard.NotNull(path, nameof(path)); - ClipperPolygon polygon = PolygonClipperFactory.FromSimplePaths(path.Flatten(), this.rule); - if (clippingType == ClippingType.Clip) + foreach (ISimplePath p in path.Flatten()) { - this.clip = polygon; + this.AddPath(p, clippingType); } - else + } + + private void AddPath(ISimplePath path, ClippingType clippingType) + { + ReadOnlySpan vectors = path.Points.Span; + PathF points = new(vectors.Length); + for (int i = 0; i < vectors.Length; i++) { - this.subject = polygon; + points.Add(vectors[i]); } + + this.polygonClipper.AddPath(points, clippingType, !path.IsClosed); } } diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/Clipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/Clipper.cs new file mode 100644 index 00000000..71b26112 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/Clipper.cs @@ -0,0 +1,111 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; + +/// +/// Library to clip polygons. +/// +internal class Clipper +{ + private readonly PolygonClipper polygonClipper; + + /// + /// Initializes a new instance of the class. + /// + public Clipper() + => this.polygonClipper = new PolygonClipper() { PreserveCollinear = true }; + + /// + /// Generates the clipped shapes from the previously provided paths. + /// + /// The clipping operation. + /// The intersection rule. + /// The . + public IPath[] GenerateClippedShapes(BooleanOperation operation, IntersectionRule rule) + { + PathsF closedPaths = []; + PathsF openPaths = []; + + ClipperFillRule fillRule = rule == IntersectionRule.EvenOdd ? ClipperFillRule.EvenOdd : ClipperFillRule.NonZero; + this.polygonClipper.Execute(operation, fillRule, closedPaths, openPaths); + + IPath[] shapes = new IPath[closedPaths.Count + openPaths.Count]; + + int index = 0; + for (int i = 0; i < closedPaths.Count; i++) + { + PathF path = closedPaths[i]; + PointF[] points = new PointF[path.Count]; + + for (int j = 0; j < path.Count; j++) + { + points[j] = path[j]; + } + + shapes[index++] = new Polygon(points); + } + + for (int i = 0; i < openPaths.Count; i++) + { + PathF path = openPaths[i]; + PointF[] points = new PointF[path.Count]; + + for (int j = 0; j < path.Count; j++) + { + points[j] = path[j]; + } + + shapes[index++] = new Polygon(points); + } + + return shapes; + } + + /// + /// Adds the shapes. + /// + /// The paths. + /// The clipping type. + public void AddPaths(IEnumerable paths, ClippingType clippingType) + { + Guard.NotNull(paths, nameof(paths)); + + foreach (IPath p in paths) + { + this.AddPath(p, clippingType); + } + } + + /// + /// Adds the path. + /// + /// The path. + /// The clipping type. + public void AddPath(IPath path, ClippingType clippingType) + { + Guard.NotNull(path, nameof(path)); + + foreach (ISimplePath p in path.Flatten()) + { + this.AddPath(p, clippingType); + } + } + + /// + /// Adds the path. + /// + /// The path. + /// Type of the poly. + internal void AddPath(ISimplePath path, ClippingType clippingType) + { + ReadOnlySpan vectors = path.Points.Span; + PathF points = new(vectors.Length); + for (int i = 0; i < vectors.Length; i++) + { + points.Add(vectors[i]); + } + + this.polygonClipper.AddPath(points, clippingType, !path.IsClosed); + } +} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperException.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperException.cs new file mode 100644 index 00000000..d22aff79 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperException.cs @@ -0,0 +1,37 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; + +/// +/// The exception that is thrown when an error occurs clipping a polygon. +/// +public class ClipperException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public ClipperException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public ClipperException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a + /// reference to the inner exception that is the cause of this exception. + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a + /// reference if no inner exception is specified. + public ClipperException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperFillRule.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperFillRule.cs new file mode 100644 index 00000000..90d1c614 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperFillRule.cs @@ -0,0 +1,23 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Drawing.Shapes.Rasterization; + +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; + +/// +/// By far the most widely used filling rules for polygons are EvenOdd +/// and NonZero, sometimes called Alternate and Winding respectively. +/// +/// +/// +/// TODO: This overlaps with the enum. +/// We should see if we can enhance the to support all these rules. +/// +internal enum ClipperFillRule +{ + EvenOdd, + NonZero, + Positive, + Negative +} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperUtils.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperUtils.cs new file mode 100644 index 00000000..6ed77da4 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperUtils.cs @@ -0,0 +1,236 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using System.Runtime.CompilerServices; + +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; + +internal static class ClipperUtils +{ + public const float DefaultArcTolerance = .25F; + public const float FloatingPointTolerance = 1e-05F; + public const float DefaultMinimumEdgeLength = .1F; + + // TODO: rename to Pow2? + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float Sqr(float value) => value * value; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float Area(PathF path) + { + // https://en.wikipedia.org/wiki/Shoelace_formula + float a = 0F; + if (path.Count < 3) + { + return a; + } + + Vector2 prevPt = path[path.Count - 1]; + for (int i = 0; i < path.Count; i++) + { + Vector2 pt = path[i]; + a += (prevPt.Y + pt.Y) * (prevPt.X - pt.X); + prevPt = pt; + } + + return a * .5F; + } + + public static PathF StripDuplicates(PathF path, bool isClosedPath) + { + int cnt = path.Count; + PathF result = new(cnt); + if (cnt == 0) + { + return result; + } + + PointF lastPt = path[0]; + result.Add(lastPt); + for (int i = 1; i < cnt; i++) + { + if (lastPt != path[i]) + { + lastPt = path[i]; + result.Add(lastPt); + } + } + + if (isClosedPath && lastPt == result[0]) + { + result.RemoveAt(result.Count - 1); + } + + return result; + } + + public static PathF Ellipse(Vector2 center, float radiusX, float radiusY = 0, int steps = 0) + { + if (radiusX <= 0) + { + return []; + } + + if (radiusY <= 0) + { + radiusY = radiusX; + } + + if (steps <= 2) + { + steps = (int)MathF.Ceiling(MathF.PI * MathF.Sqrt((radiusX + radiusY) * .5F)); + } + + float si = MathF.Sin(2 * MathF.PI / steps); + float co = MathF.Cos(2 * MathF.PI / steps); + float dx = co, dy = si; + PathF result = new(steps) { new Vector2(center.X + radiusX, center.Y) }; + Vector2 radiusXY = new(radiusX, radiusY); + for (int i = 1; i < steps; ++i) + { + result.Add(center + (radiusXY * new Vector2(dx, dy))); + float x = (dx * co) - (dy * si); + dy = (dy * co) + (dx * si); + dx = x; + } + + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float DotProduct(Vector2 vec1, Vector2 vec2) + => Vector2.Dot(vec1, vec2); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float CrossProduct(Vector2 vec1, Vector2 vec2) + => (vec1.Y * vec2.X) - (vec2.Y * vec1.X); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float CrossProduct(Vector2 pt1, Vector2 pt2, Vector2 pt3) + => ((pt2.X - pt1.X) * (pt3.Y - pt2.Y)) - ((pt2.Y - pt1.Y) * (pt3.X - pt2.X)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float DotProduct(Vector2 pt1, Vector2 pt2, Vector2 pt3) + => Vector2.Dot(pt2 - pt1, pt3 - pt2); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsAlmostZero(float value) + => MathF.Abs(value) <= FloatingPointTolerance; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float PerpendicDistFromLineSqrd(Vector2 pt, Vector2 line1, Vector2 line2) + { + Vector2 ab = pt - line1; + Vector2 cd = line2 - line1; + if (cd == Vector2.Zero) + { + return 0; + } + + return Sqr(CrossProduct(cd, ab)) / DotProduct(cd, cd); + } + + public static bool SegsIntersect(Vector2 seg1a, Vector2 seg1b, Vector2 seg2a, Vector2 seg2b, bool inclusive = false) + { + if (inclusive) + { + float res1 = CrossProduct(seg1a, seg2a, seg2b); + float res2 = CrossProduct(seg1b, seg2a, seg2b); + if (res1 * res2 > 0) + { + return false; + } + + float res3 = CrossProduct(seg2a, seg1a, seg1b); + float res4 = CrossProduct(seg2b, seg1a, seg1b); + if (res3 * res4 > 0) + { + return false; + } + + // ensure NOT collinear + return res1 != 0 || res2 != 0 || res3 != 0 || res4 != 0; + } + + return (CrossProduct(seg1a, seg2a, seg2b) * CrossProduct(seg1b, seg2a, seg2b) < 0) + && (CrossProduct(seg2a, seg1a, seg1b) * CrossProduct(seg2b, seg1a, seg1b) < 0); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool GetIntersectPt(Vector2 ln1a, Vector2 ln1b, Vector2 ln2a, Vector2 ln2b, out Vector2 ip) + { + Vector2 dxy1 = ln1b - ln1a; + Vector2 dxy2 = ln2b - ln2a; + float cp = CrossProduct(dxy1, dxy2); + if (cp == 0F) + { + ip = default; + return false; + } + + float qx = CrossProduct(ln1a, dxy1); + float qy = CrossProduct(ln2a, dxy2); + + ip = ((dxy1 * qy) - (dxy2 * qx)) / cp; + return ip != new Vector2(float.MaxValue); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool GetIntersectPoint(Vector2 ln1a, Vector2 ln1b, Vector2 ln2a, Vector2 ln2b, out Vector2 ip) + { + Vector2 dxy1 = ln1b - ln1a; + Vector2 dxy2 = ln2b - ln2a; + float det = CrossProduct(dxy1, dxy2); + if (det == 0F) + { + ip = default; + return false; + } + + float t = (((ln1a.X - ln2a.X) * dxy2.Y) - ((ln1a.Y - ln2a.Y) * dxy2.X)) / det; + if (t <= 0F) + { + ip = ln1a; + } + else if (t >= 1F) + { + ip = ln1b; + } + else + { + ip = ln1a + (t * dxy1); + } + + return true; + } + + public static Vector2 GetClosestPtOnSegment(Vector2 offPt, Vector2 seg1, Vector2 seg2) + { + if (seg1 == seg2) + { + return seg1; + } + + Vector2 dxy = seg2 - seg1; + Vector2 oxy = (offPt - seg1) * dxy; + float q = (oxy.X + oxy.Y) / DotProduct(dxy, dxy); + + if (q < 0) + { + q = 0; + } + else if (q > 1) + { + q = 1; + } + + return seg1 + (dxy * q); + } + + public static PathF ReversePath(PathF path) + { + path.Reverse(); + return path; + } +} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/JoinWith.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/JoinWith.cs new file mode 100644 index 00000000..83ca61ad --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/JoinWith.cs @@ -0,0 +1,29 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; + +internal enum JoinWith +{ + None, + Left, + Right +} + +internal enum HorzPosition +{ + Bottom, + Middle, + Top +} + +// Vertex: a pre-clipping data structure. It is used to separate polygons +// into ascending and descending 'bounds' (or sides) that start at local +// minima and ascend to a local maxima, before descending again. +[Flags] +internal enum PointInPolygonResult +{ + IsOn = 0, + IsInside = 1, + IsOutside = 2 +} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs new file mode 100644 index 00000000..cd4c4363 --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs @@ -0,0 +1,3461 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +#nullable disable + +using System.Collections; +using System.Numerics; +using System.Runtime.CompilerServices; + +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; + +/// +/// Contains functions that cover most polygon boolean and offsetting needs. +/// Ported from and originally licensed +/// under +/// +internal sealed class PolygonClipper +{ + private BooleanOperation clipType; + private ClipperFillRule fillRule; + private Active actives; + private Active flaggedHorizontal; + private readonly List minimaList; + private readonly List intersectList; + private readonly List vertexList; + private readonly List outrecList; + private readonly List scanlineList; + private readonly List horzSegList; + private readonly List horzJoinList; + private int currentLocMin; + private float currentBotY; + private bool isSortedMinimaList; + private bool hasOpenPaths; + + public PolygonClipper() + { + this.minimaList = []; + this.intersectList = []; + this.vertexList = []; + this.outrecList = []; + this.scanlineList = []; + this.horzSegList = []; + this.horzJoinList = []; + this.PreserveCollinear = true; + } + + public bool PreserveCollinear { get; set; } + + public bool ReverseSolution { get; set; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddSubject(PathsF paths) => this.AddPaths(paths, ClippingType.Subject); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddPath(PathF path, ClippingType polytype, bool isOpen = false) + { + PathsF tmp = [path]; + this.AddPaths(tmp, polytype, isOpen); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AddPaths(PathsF paths, ClippingType polytype, bool isOpen = false) + { + if (isOpen) + { + this.hasOpenPaths = true; + } + + this.isSortedMinimaList = false; + this.AddPathsToVertexList(paths, polytype, isOpen); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Execute(BooleanOperation clipType, ClipperFillRule fillRule, PathsF solutionClosed) + => this.Execute(clipType, fillRule, solutionClosed, []); + + public void Execute(BooleanOperation clipType, ClipperFillRule fillRule, PathsF solutionClosed, PathsF solutionOpen) + { + solutionClosed.Clear(); + solutionOpen.Clear(); + + try + { + this.ExecuteInternal(clipType, fillRule); + this.BuildPaths(solutionClosed, solutionOpen); + } + catch (Exception ex) + { + throw new ClipperException("An error occurred while attempting to clip the polygon. See the inner exception for details.", ex); + } + finally + { + this.ClearSolutionOnly(); + } + } + + private void ExecuteInternal(BooleanOperation ct, ClipperFillRule fillRule) + { + this.fillRule = fillRule; + this.clipType = ct; + this.Reset(); + if (!this.PopScanline(out float y)) + { + return; + } + + while (true) + { + this.InsertLocalMinimaIntoAEL(y); + Active ae; + while (this.PopHorz(out ae)) + { + this.DoHorizontal(ae); + } + + if (this.horzSegList.Count > 0) + { + this.ConvertHorzSegsToJoins(); + this.horzSegList.Clear(); + } + + this.currentBotY = y; // bottom of scanbeam + if (!this.PopScanline(out y)) + { + break; // y new top of scanbeam + } + + this.DoIntersections(y); + this.DoTopOfScanbeam(y); + while (this.PopHorz(out ae)) + { + this.DoHorizontal(ae!); + } + } + + this.ProcessHorzJoins(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DoIntersections(float topY) + { + if (this.BuildIntersectList(topY)) + { + this.ProcessIntersectList(); + this.DisposeIntersectNodes(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DisposeIntersectNodes() + => this.intersectList.Clear(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AddNewIntersectNode(Active ae1, Active ae2, float topY) + { + if (!ClipperUtils.GetIntersectPt(ae1.Bot, ae1.Top, ae2.Bot, ae2.Top, out Vector2 ip)) + { + ip = new Vector2(ae1.CurX, topY); + } + + if (ip.Y > this.currentBotY || ip.Y < topY) + { + float absDx1 = MathF.Abs(ae1.Dx); + float absDx2 = MathF.Abs(ae2.Dx); + + // TODO: Check threshold here once we remove upscaling. + if (absDx1 > 100 && absDx2 > 100) + { + if (absDx1 > absDx2) + { + ip = ClipperUtils.GetClosestPtOnSegment(ip, ae1.Bot, ae1.Top); + } + else + { + ip = ClipperUtils.GetClosestPtOnSegment(ip, ae2.Bot, ae2.Top); + } + } + else if (absDx1 > 100) + { + ip = ClipperUtils.GetClosestPtOnSegment(ip, ae1.Bot, ae1.Top); + } + else if (absDx2 > 100) + { + ip = ClipperUtils.GetClosestPtOnSegment(ip, ae2.Bot, ae2.Top); + } + else + { + if (ip.Y < topY) + { + ip.Y = topY; + } + else + { + ip.Y = this.currentBotY; + } + + if (absDx1 < absDx2) + { + ip.X = TopX(ae1, ip.Y); + } + else + { + ip.X = TopX(ae2, ip.Y); + } + } + } + + IntersectNode node = new(ip, ae1, ae2); + this.intersectList.Add(node); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool SetHorzSegHeadingForward(HorzSegment hs, OutPt opP, OutPt opN) + { + if (opP.Point.X == opN.Point.X) + { + return false; + } + + if (opP.Point.X < opN.Point.X) + { + hs.LeftOp = opP; + hs.RightOp = opN; + hs.LeftToRight = true; + } + else + { + hs.LeftOp = opN; + hs.RightOp = opP; + hs.LeftToRight = false; + } + + return true; + } + + private static bool UpdateHorzSegment(HorzSegment hs) + { + OutPt op = hs.LeftOp; + OutRec outrec = GetRealOutRec(op.OutRec); + bool outrecHasEdges = outrec.FrontEdge != null; + float curr_y = op.Point.Y; + OutPt opP = op, opN = op; + if (outrecHasEdges) + { + OutPt opA = outrec.Pts!, opZ = opA.Next; + while (opP != opZ && opP.Prev.Point.Y == curr_y) + { + opP = opP.Prev; + } + + while (opN != opA && opN.Next.Point.Y == curr_y) + { + opN = opN.Next; + } + } + else + { + while (opP.Prev != opN && opP.Prev.Point.Y == curr_y) + { + opP = opP.Prev; + } + + while (opN.Next != opP && opN.Next.Point.Y == curr_y) + { + opN = opN.Next; + } + } + + bool result = SetHorzSegHeadingForward(hs, opP, opN) && hs.LeftOp.HorizSegment == null; + + if (result) + { + hs.LeftOp.HorizSegment = hs; + } + else + { + hs.RightOp = null; // (for sorting) + } + + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static OutPt DuplicateOp(OutPt op, bool insert_after) + { + OutPt result = new(op.Point, op.OutRec); + if (insert_after) + { + result.Next = op.Next; + result.Next.Prev = result; + result.Prev = op; + op.Next = result; + } + else + { + result.Prev = op.Prev; + result.Prev.Next = result; + result.Next = op; + op.Prev = result; + } + + return result; + } + + private void ConvertHorzSegsToJoins() + { + int k = 0; + foreach (HorzSegment hs in this.horzSegList) + { + if (UpdateHorzSegment(hs)) + { + k++; + } + } + + if (k < 2) + { + return; + } + + this.horzSegList.Sort(default(HorzSegSorter)); + + for (int i = 0; i < k - 1; i++) + { + HorzSegment hs1 = this.horzSegList[i]; + + // for each HorzSegment, find others that overlap + for (int j = i + 1; j < k; j++) + { + HorzSegment hs2 = this.horzSegList[j]; + if ((hs2.LeftOp.Point.X >= hs1.RightOp.Point.X) || + (hs2.LeftToRight == hs1.LeftToRight) || + (hs2.RightOp.Point.X <= hs1.LeftOp.Point.X)) + { + continue; + } + + float curr_y = hs1.LeftOp.Point.Y; + if (hs1.LeftToRight) + { + while (hs1.LeftOp.Next.Point.Y == curr_y && + hs1.LeftOp.Next.Point.X <= hs2.LeftOp.Point.X) + { + hs1.LeftOp = hs1.LeftOp.Next; + } + + while (hs2.LeftOp.Prev.Point.Y == curr_y && + hs2.LeftOp.Prev.Point.X <= hs1.LeftOp.Point.X) + { + hs2.LeftOp = hs2.LeftOp.Prev; + } + + HorzJoin join = new(DuplicateOp(hs1.LeftOp, true), DuplicateOp(hs2.LeftOp, false)); + this.horzJoinList.Add(join); + } + else + { + while (hs1.LeftOp.Prev.Point.Y == curr_y && + hs1.LeftOp.Prev.Point.X <= hs2.LeftOp.Point.X) + { + hs1.LeftOp = hs1.LeftOp.Prev; + } + + while (hs2.LeftOp.Next.Point.Y == curr_y && + hs2.LeftOp.Next.Point.X <= hs1.LeftOp.Point.X) + { + hs2.LeftOp = hs2.LeftOp.Next; + } + + HorzJoin join = new(DuplicateOp(hs2.LeftOp, true), DuplicateOp(hs1.LeftOp, false)); + this.horzJoinList.Add(join); + } + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ClearSolutionOnly() + { + while (this.actives != null) + { + this.DeleteFromAEL(this.actives); + } + + this.scanlineList.Clear(); + this.DisposeIntersectNodes(); + this.outrecList.Clear(); + this.horzSegList.Clear(); + this.horzJoinList.Clear(); + } + + private bool BuildPaths(PathsF solutionClosed, PathsF solutionOpen) + { + solutionClosed.Clear(); + solutionOpen.Clear(); + solutionClosed.EnsureCapacity(this.outrecList.Count); + solutionOpen.EnsureCapacity(this.outrecList.Count); + + int i = 0; + + // _outrecList.Count is not static here because + // CleanCollinear can indirectly add additional OutRec + while (i < this.outrecList.Count) + { + OutRec outrec = this.outrecList[i++]; + if (outrec.Pts == null) + { + continue; + } + + PathF path = []; + if (outrec.IsOpen) + { + if (BuildPath(outrec.Pts, this.ReverseSolution, true, path)) + { + solutionOpen.Add(path); + } + } + else + { + this.CleanCollinear(outrec); + + // closed paths should always return a Positive orientation + // except when ReverseSolution == true + if (BuildPath(outrec.Pts, this.ReverseSolution, false, path)) + { + solutionClosed.Add(path); + } + } + } + + return true; + } + + private static bool BuildPath(OutPt op, bool reverse, bool isOpen, PathF path) + { + if (op == null || op.Next == op || (!isOpen && op.Next == op.Prev)) + { + return false; + } + + path.Clear(); + + Vector2 lastPt; + OutPt op2; + if (reverse) + { + lastPt = op.Point; + op2 = op.Prev; + } + else + { + op = op.Next; + lastPt = op.Point; + op2 = op.Next; + } + + path.Add(lastPt); + + while (op2 != op) + { + if (op2.Point != lastPt) + { + lastPt = op2.Point; + path.Add(lastPt); + } + + if (reverse) + { + op2 = op2.Prev; + } + else + { + op2 = op2.Next; + } + } + + return path.Count != 3 || !IsVerySmallTriangle(op2); + } + + private void DoHorizontal(Active horz) + /******************************************************************************* + * Notes: Horizontal edges (HEs) at scanline intersections (i.e. at the top or * + * bottom of a scanbeam) are processed as if layered.The order in which HEs * + * are processed doesn't matter. HEs intersect with the bottom vertices of * + * other HEs[#] and with non-horizontal edges [*]. Once these intersections * + * are completed, intermediate HEs are 'promoted' to the next edge in their * + * bounds, and they in turn may be intersected[%] by other HEs. * + * * + * eg: 3 horizontals at a scanline: / | / / * + * | / | (HE3)o ========%========== o * + * o ======= o(HE2) / | / / * + * o ============#=========*======*========#=========o (HE1) * + * / | / | / * + *******************************************************************************/ + { + Vector2 pt; + bool horzIsOpen = IsOpen(horz); + float y = horz.Bot.Y; + + Vertex vertex_max = horzIsOpen ? GetCurrYMaximaVertex_Open(horz) : GetCurrYMaximaVertex(horz); + + // remove 180 deg.spikes and also simplify + // consecutive horizontals when PreserveCollinear = true + if (vertex_max != null && + !horzIsOpen && vertex_max != horz.VertexTop) + { + TrimHorz(horz, this.PreserveCollinear); + } + + bool isLeftToRight = ResetHorzDirection(horz, vertex_max, out float leftX, out float rightX); + + if (IsHotEdge(horz)) + { + OutPt op = AddOutPt(horz, new Vector2(horz.CurX, y)); + this.AddToHorzSegList(op); + } + + OutRec currOutrec = horz.Outrec; + + while (true) + { + // loops through consec. horizontal edges (if open) + Active ae = isLeftToRight ? horz.NextInAEL : horz.PrevInAEL; + + while (ae != null) + { + if (ae.VertexTop == vertex_max) + { + // do this first!! + if (IsHotEdge(horz) && IsJoined(ae!)) + { + this.Split(ae, ae.Top); + } + + if (IsHotEdge(horz)) + { + while (horz.VertexTop != vertex_max) + { + AddOutPt(horz, horz.Top); + this.UpdateEdgeIntoAEL(horz); + } + + if (isLeftToRight) + { + this.AddLocalMaxPoly(horz, ae, horz.Top); + } + else + { + this.AddLocalMaxPoly(ae, horz, horz.Top); + } + } + + this.DeleteFromAEL(ae); + this.DeleteFromAEL(horz); + return; + } + + // if horzEdge is a maxima, keep going until we reach + // its maxima pair, otherwise check for break conditions + if (vertex_max != horz.VertexTop || IsOpenEnd(horz)) + { + // otherwise stop when 'ae' is beyond the end of the horizontal line + if ((isLeftToRight && ae.CurX > rightX) || (!isLeftToRight && ae.CurX < leftX)) + { + break; + } + + if (ae.CurX == horz.Top.X && !IsHorizontal(ae)) + { + pt = NextVertex(horz).Point; + + // to maximize the possibility of putting open edges into + // solutions, we'll only break if it's past HorzEdge's end + if (IsOpen(ae) && !IsSamePolyType(ae, horz) && !IsHotEdge(ae)) + { + if ((isLeftToRight && (TopX(ae, pt.Y) > pt.X)) || + (!isLeftToRight && (TopX(ae, pt.Y) < pt.X))) + { + break; + } + } + + // otherwise for edges at horzEdge's end, only stop when horzEdge's + // outslope is greater than e's slope when heading right or when + // horzEdge's outslope is less than e's slope when heading left. + else if ((isLeftToRight && (TopX(ae, pt.Y) >= pt.X)) || (!isLeftToRight && (TopX(ae, pt.Y) <= pt.X))) + { + break; + } + } + } + + pt = new Vector2(ae.CurX, y); + + if (isLeftToRight) + { + this.IntersectEdges(horz, ae, pt); + this.SwapPositionsInAEL(horz, ae); + horz.CurX = ae.CurX; + ae = horz.NextInAEL; + } + else + { + this.IntersectEdges(ae, horz, pt); + this.SwapPositionsInAEL(ae, horz); + horz.CurX = ae.CurX; + ae = horz.PrevInAEL; + } + + if (IsHotEdge(horz) && (horz.Outrec != currOutrec)) + { + currOutrec = horz.Outrec; + this.AddToHorzSegList(GetLastOp(horz)); + } + + // we've reached the end of this horizontal + } + + // check if we've finished looping + // through consecutive horizontals + // ie open at top + if (horzIsOpen && IsOpenEnd(horz)) + { + if (IsHotEdge(horz)) + { + AddOutPt(horz, horz.Top); + if (IsFront(horz)) + { + horz.Outrec.FrontEdge = null; + } + else + { + horz.Outrec.BackEdge = null; + } + + horz.Outrec = null; + } + + this.DeleteFromAEL(horz); + return; + } + else if (NextVertex(horz).Point.Y != horz.Top.Y) + { + break; + } + + // still more horizontals in bound to process ... + if (IsHotEdge(horz)) + { + AddOutPt(horz, horz.Top); + } + + this.UpdateEdgeIntoAEL(horz); + + if (this.PreserveCollinear && !horzIsOpen && HorzIsSpike(horz)) + { + TrimHorz(horz, true); + } + + isLeftToRight = ResetHorzDirection(horz, vertex_max, out leftX, out rightX); + + // end for loop and end of (possible consecutive) horizontals + } + + if (IsHotEdge(horz)) + { + this.AddToHorzSegList(AddOutPt(horz, horz.Top)); + } + + this.UpdateEdgeIntoAEL(horz); // this is the end of an intermediate horiz. + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DoTopOfScanbeam(float y) + { + this.flaggedHorizontal = null; // sel_ is reused to flag horizontals (see PushHorz below) + Active ae = this.actives; + while (ae != null) + { + // NB 'ae' will never be horizontal here + if (ae.Top.Y == y) + { + ae.CurX = ae.Top.X; + if (IsMaxima(ae)) + { + ae = this.DoMaxima(ae); // TOP OF BOUND (MAXIMA) + continue; + } + + // INTERMEDIATE VERTEX ... + if (IsHotEdge(ae)) + { + AddOutPt(ae, ae.Top); + } + + this.UpdateEdgeIntoAEL(ae); + if (IsHorizontal(ae)) + { + this.PushHorz(ae); // horizontals are processed later + } + } + else + { + // i.e. not the top of the edge + ae.CurX = TopX(ae, y); + } + + ae = ae.NextInAEL; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Active DoMaxima(Active ae) + { + Active prevE; + Active nextE, maxPair; + prevE = ae.PrevInAEL; + nextE = ae.NextInAEL; + + if (IsOpenEnd(ae)) + { + if (IsHotEdge(ae)) + { + AddOutPt(ae, ae.Top); + } + + if (!IsHorizontal(ae)) + { + if (IsHotEdge(ae)) + { + if (IsFront(ae)) + { + ae.Outrec.FrontEdge = null; + } + else + { + ae.Outrec.BackEdge = null; + } + + ae.Outrec = null; + } + + this.DeleteFromAEL(ae); + } + + return nextE; + } + + maxPair = GetMaximaPair(ae); + if (maxPair == null) + { + return nextE; // eMaxPair is horizontal + } + + if (IsJoined(ae)) + { + this.Split(ae, ae.Top); + } + + if (IsJoined(maxPair)) + { + this.Split(maxPair, maxPair.Top); + } + + // only non-horizontal maxima here. + // process any edges between maxima pair ... + while (nextE != maxPair) + { + this.IntersectEdges(ae, nextE!, ae.Top); + this.SwapPositionsInAEL(ae, nextE!); + nextE = ae.NextInAEL; + } + + if (IsOpen(ae)) + { + if (IsHotEdge(ae)) + { + this.AddLocalMaxPoly(ae, maxPair, ae.Top); + } + + this.DeleteFromAEL(maxPair); + this.DeleteFromAEL(ae); + return prevE != null ? prevE.NextInAEL : this.actives; + } + + // here ae.nextInAel == ENext == EMaxPair ... + if (IsHotEdge(ae)) + { + this.AddLocalMaxPoly(ae, maxPair, ae.Top); + } + + this.DeleteFromAEL(ae); + this.DeleteFromAEL(maxPair); + return prevE != null ? prevE.NextInAEL : this.actives; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void TrimHorz(Active horzEdge, bool preserveCollinear) + { + bool wasTrimmed = false; + Vector2 pt = NextVertex(horzEdge).Point; + + while (pt.Y == horzEdge.Top.Y) + { + // always trim 180 deg. spikes (in closed paths) + // but otherwise break if preserveCollinear = true + if (preserveCollinear && (pt.X < horzEdge.Top.X) != (horzEdge.Bot.X < horzEdge.Top.X)) + { + break; + } + + horzEdge.VertexTop = NextVertex(horzEdge); + horzEdge.Top = pt; + wasTrimmed = true; + if (IsMaxima(horzEdge)) + { + break; + } + + pt = NextVertex(horzEdge).Point; + } + + if (wasTrimmed) + { + SetDx(horzEdge); // +/-infinity + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AddToHorzSegList(OutPt op) + { + if (op.OutRec.IsOpen) + { + return; + } + + this.horzSegList.Add(new HorzSegment(op)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static OutPt GetLastOp(Active hotEdge) + { + OutRec outrec = hotEdge.Outrec; + return (hotEdge == outrec.FrontEdge) ? outrec.Pts : outrec.Pts.Next; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vertex GetCurrYMaximaVertex_Open(Active ae) + { + Vertex result = ae.VertexTop; + if (ae.WindDx > 0) + { + while (result.Next.Point.Y == result.Point.Y && ((result.Flags & (VertexFlags.OpenEnd | VertexFlags.LocalMax)) == VertexFlags.None)) + { + result = result.Next; + } + } + else + { + while (result.Prev.Point.Y == result.Point.Y && ((result.Flags & (VertexFlags.OpenEnd | VertexFlags.LocalMax)) == VertexFlags.None)) + { + result = result.Prev; + } + } + + if (!IsMaxima(result)) + { + result = null; // not a maxima + } + + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vertex GetCurrYMaximaVertex(Active ae) + { + Vertex result = ae.VertexTop; + if (ae.WindDx > 0) + { + while (result.Next.Point.Y == result.Point.Y) + { + result = result.Next; + } + } + else + { + while (result.Prev.Point.Y == result.Point.Y) + { + result = result.Prev; + } + } + + if (!IsMaxima(result)) + { + result = null; // not a maxima + } + + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsVerySmallTriangle(OutPt op) + => op.Next.Next == op.Prev + && (PtsReallyClose(op.Prev.Point, op.Next.Point) + || PtsReallyClose(op.Point, op.Next.Point) + || PtsReallyClose(op.Point, op.Prev.Point)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidClosedPath(OutPt op) + => op != null && op.Next != op && (op.Next != op.Prev || !IsVerySmallTriangle(op)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static OutPt DisposeOutPt(OutPt op) + { + OutPt result = op.Next == op ? null : op.Next; + op.Prev.Next = op.Next; + op.Next.Prev = op.Prev; + + return result; + } + + private void ProcessHorzJoins() + { + foreach (HorzJoin j in this.horzJoinList) + { + OutRec or1 = GetRealOutRec(j.Op1.OutRec); + OutRec or2 = GetRealOutRec(j.Op2.OutRec); + + OutPt op1b = j.Op1.Next; + OutPt op2b = j.Op2.Prev; + j.Op1.Next = j.Op2; + j.Op2.Prev = j.Op1; + op1b.Prev = op2b; + op2b.Next = op1b; + + // 'join' is really a split + if (or1 == or2) + { + or2 = new OutRec + { + Pts = op1b + }; + + FixOutRecPts(or2); + + if (or1.Pts.OutRec == or2) + { + or1.Pts = j.Op1; + or1.Pts.OutRec = or1; + } + + or2.Owner = or1; + + this.outrecList.Add(or2); + } + else + { + or2.Pts = null; + or2.Owner = or1; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool PtsReallyClose(Vector2 pt1, Vector2 pt2) + + // TODO: Check scale once we can remove upscaling. + => (Math.Abs(pt1.X - pt2.X) < 2F) && (Math.Abs(pt1.Y - pt2.Y) < 2F); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void CleanCollinear(OutRec outrec) + { + outrec = GetRealOutRec(outrec); + + if (outrec?.IsOpen != false) + { + return; + } + + if (!IsValidClosedPath(outrec.Pts)) + { + outrec.Pts = null; + return; + } + + OutPt startOp = outrec.Pts; + OutPt op2 = startOp; + do + { + // NB if preserveCollinear == true, then only remove 180 deg. spikes + if ((ClipperUtils.CrossProduct(op2.Prev.Point, op2.Point, op2.Next.Point) == 0) + && ((op2.Point == op2.Prev.Point) || (op2.Point == op2.Next.Point) || !this.PreserveCollinear || (ClipperUtils.DotProduct(op2.Prev.Point, op2.Point, op2.Next.Point) < 0))) + { + if (op2 == outrec.Pts) + { + outrec.Pts = op2.Prev; + } + + op2 = DisposeOutPt(op2); + if (!IsValidClosedPath(op2)) + { + outrec.Pts = null; + return; + } + + startOp = op2; + continue; + } + + op2 = op2.Next; + } + while (op2 != startOp); + + this.FixSelfIntersects(outrec); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DoSplitOp(OutRec outrec, OutPt splitOp) + { + // splitOp.prev <=> splitOp && + // splitOp.next <=> splitOp.next.next are intersecting + OutPt prevOp = splitOp.Prev; + OutPt nextNextOp = splitOp.Next.Next; + outrec.Pts = prevOp; + + ClipperUtils.GetIntersectPoint( + prevOp.Point, splitOp.Point, splitOp.Next.Point, nextNextOp.Point, out Vector2 ip); + + float area1 = Area(prevOp); + float absArea1 = Math.Abs(area1); + + if (absArea1 < 2) + { + outrec.Pts = null; + return; + } + + float area2 = AreaTriangle(ip, splitOp.Point, splitOp.Next.Point); + float absArea2 = Math.Abs(area2); + + // de-link splitOp and splitOp.next from the path + // while inserting the intersection point + if (ip == prevOp.Point || ip == nextNextOp.Point) + { + nextNextOp.Prev = prevOp; + prevOp.Next = nextNextOp; + } + else + { + OutPt newOp2 = new(ip, outrec) + { + Prev = prevOp, + Next = nextNextOp + }; + + nextNextOp.Prev = newOp2; + prevOp.Next = newOp2; + } + + // nb: area1 is the path's area *before* splitting, whereas area2 is + // the area of the triangle containing splitOp & splitOp.next. + // So the only way for these areas to have the same sign is if + // the split triangle is larger than the path containing prevOp or + // if there's more than one self=intersection. + if (absArea2 > 1 && (absArea2 > absArea1 || ((area2 > 0) == (area1 > 0)))) + { + OutRec newOutRec = this.NewOutRec(); + newOutRec.Owner = outrec.Owner; + splitOp.OutRec = newOutRec; + splitOp.Next.OutRec = newOutRec; + + OutPt newOp = new(ip, newOutRec) { Prev = splitOp.Next, Next = splitOp }; + newOutRec.Pts = newOp; + splitOp.Prev = newOp; + splitOp.Next.Next = newOp; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void FixSelfIntersects(OutRec outrec) + { + OutPt op2 = outrec.Pts; + + // triangles can't self-intersect + while (op2.Prev != op2.Next.Next) + { + if (ClipperUtils.SegsIntersect(op2.Prev.Point, op2.Point, op2.Next.Point, op2.Next.Next.Point)) + { + this.DoSplitOp(outrec, op2); + if (outrec.Pts == null) + { + return; + } + + op2 = outrec.Pts; + continue; + } + else + { + op2 = op2.Next; + } + + if (op2 == outrec.Pts) + { + break; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Reset() + { + if (!this.isSortedMinimaList) + { + this.minimaList.Sort(default(LocMinSorter)); + this.isSortedMinimaList = true; + } + + this.scanlineList.EnsureCapacity(this.minimaList.Count); + for (int i = this.minimaList.Count - 1; i >= 0; i--) + { + this.scanlineList.Add(this.minimaList[i].Vertex.Point.Y); + } + + this.currentBotY = 0; + this.currentLocMin = 0; + this.actives = null; + this.flaggedHorizontal = null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void InsertScanline(float y) + { + int index = this.scanlineList.BinarySearch(y); + if (index >= 0) + { + return; + } + + index = ~index; + this.scanlineList.Insert(index, y); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool PopScanline(out float y) + { + int cnt = this.scanlineList.Count - 1; + if (cnt < 0) + { + y = 0; + return false; + } + + y = this.scanlineList[cnt]; + this.scanlineList.RemoveAt(cnt--); + while (cnt >= 0 && y == this.scanlineList[cnt]) + { + this.scanlineList.RemoveAt(cnt--); + } + + return true; + } + + private void InsertLocalMinimaIntoAEL(float botY) + { + LocalMinima localMinima; + Active leftBound, rightBound; + + // Add any local minima (if any) at BotY + // NB horizontal local minima edges should contain locMin.vertex.prev + while (this.HasLocMinAtY(botY)) + { + localMinima = this.PopLocalMinima(); + if ((localMinima.Vertex.Flags & VertexFlags.OpenStart) != VertexFlags.None) + { + leftBound = null; + } + else + { + leftBound = new Active + { + Bot = localMinima.Vertex.Point, + CurX = localMinima.Vertex.Point.X, + WindDx = -1, + VertexTop = localMinima.Vertex.Prev, + Top = localMinima.Vertex.Prev.Point, + Outrec = null, + LocalMin = localMinima + }; + SetDx(leftBound); + } + + if ((localMinima.Vertex.Flags & VertexFlags.OpenEnd) != VertexFlags.None) + { + rightBound = null; + } + else + { + rightBound = new Active + { + Bot = localMinima.Vertex.Point, + CurX = localMinima.Vertex.Point.X, + WindDx = 1, + VertexTop = localMinima.Vertex.Next, // i.e. ascending + Top = localMinima.Vertex.Next.Point, + Outrec = null, + LocalMin = localMinima + }; + SetDx(rightBound); + } + + // Currently LeftB is just the descending bound and RightB is the ascending. + // Now if the LeftB isn't on the left of RightB then we need swap them. + if (leftBound != null && rightBound != null) + { + if (IsHorizontal(leftBound)) + { + if (IsHeadingRightHorz(leftBound)) + { + SwapActives(ref leftBound, ref rightBound); + } + } + else if (IsHorizontal(rightBound)) + { + if (IsHeadingLeftHorz(rightBound)) + { + SwapActives(ref leftBound, ref rightBound); + } + } + else if (leftBound.Dx < rightBound.Dx) + { + SwapActives(ref leftBound, ref rightBound); + } + + // so when leftBound has windDx == 1, the polygon will be oriented + // counter-clockwise in Cartesian coords (clockwise with inverted Y). + } + else if (leftBound == null) + { + leftBound = rightBound; + rightBound = null; + } + + bool contributing; + leftBound.IsLeftBound = true; + this.InsertLeftEdge(leftBound); + + if (IsOpen(leftBound)) + { + this.SetWindCountForOpenPathEdge(leftBound); + contributing = this.IsContributingOpen(leftBound); + } + else + { + this.SetWindCountForClosedPathEdge(leftBound); + contributing = this.IsContributingClosed(leftBound); + } + + if (rightBound != null) + { + rightBound.WindCount = leftBound.WindCount; + rightBound.WindCount2 = leftBound.WindCount2; + InsertRightEdge(leftBound, rightBound); /////// + + if (contributing) + { + this.AddLocalMinPoly(leftBound, rightBound, leftBound.Bot, true); + if (!IsHorizontal(leftBound)) + { + this.CheckJoinLeft(leftBound, leftBound.Bot); + } + } + + while (rightBound.NextInAEL != null && IsValidAelOrder(rightBound.NextInAEL, rightBound)) + { + this.IntersectEdges(rightBound, rightBound.NextInAEL, rightBound.Bot); + this.SwapPositionsInAEL(rightBound, rightBound.NextInAEL); + } + + if (IsHorizontal(rightBound)) + { + this.PushHorz(rightBound); + } + else + { + this.CheckJoinRight(rightBound, rightBound.Bot); + this.InsertScanline(rightBound.Top.Y); + } + } + else if (contributing) + { + this.StartOpenPath(leftBound, leftBound.Bot); + } + + if (IsHorizontal(leftBound)) + { + this.PushHorz(leftBound); + } + else + { + this.InsertScanline(leftBound.Top.Y); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Active ExtractFromSEL(Active ae) + { + Active res = ae.NextInSEL; + if (res != null) + { + res.PrevInSEL = ae.PrevInSEL; + } + + ae.PrevInSEL.NextInSEL = res; + return res; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Insert1Before2InSEL(Active ae1, Active ae2) + { + ae1.PrevInSEL = ae2.PrevInSEL; + if (ae1.PrevInSEL != null) + { + ae1.PrevInSEL.NextInSEL = ae1; + } + + ae1.NextInSEL = ae2; + ae2.PrevInSEL = ae1; + } + + private bool BuildIntersectList(float topY) + { + if (this.actives == null || this.actives.NextInAEL == null) + { + return false; + } + + // Calculate edge positions at the top of the current scanbeam, and from this + // we will determine the intersections required to reach these new positions. + this.AdjustCurrXAndCopyToSEL(topY); + + // Find all edge intersections in the current scanbeam using a stable merge + // sort that ensures only adjacent edges are intersecting. Intersect info is + // stored in FIntersectList ready to be processed in ProcessIntersectList. + // Re merge sorts see https://stackoverflow.com/a/46319131/359538 + Active left = this.flaggedHorizontal; + Active right; + Active lEnd; + Active rEnd; + Active currBase; + Active prevBase; + Active tmp; + + while (left.Jump != null) + { + prevBase = null; + while (left?.Jump != null) + { + currBase = left; + right = left.Jump; + lEnd = right; + rEnd = right.Jump; + left.Jump = rEnd; + while (left != lEnd && right != rEnd) + { + if (right.CurX < left.CurX) + { + tmp = right.PrevInSEL; + while (true) + { + this.AddNewIntersectNode(tmp, right, topY); + if (tmp == left) + { + break; + } + + tmp = tmp.PrevInSEL; + } + + tmp = right; + right = ExtractFromSEL(tmp); + lEnd = right; + Insert1Before2InSEL(tmp, left); + if (left == currBase) + { + currBase = tmp; + currBase.Jump = rEnd; + if (prevBase == null) + { + this.flaggedHorizontal = currBase; + } + else + { + prevBase.Jump = currBase; + } + } + } + else + { + left = left.NextInSEL; + } + } + + prevBase = currBase; + left = rEnd; + } + + left = this.flaggedHorizontal; + } + + return this.intersectList.Count > 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ProcessIntersectList() + { + // We now have a list of intersections required so that edges will be + // correctly positioned at the top of the scanbeam. However, it's important + // that edge intersections are processed from the bottom up, but it's also + // crucial that intersections only occur between adjacent edges. + + // First we do a quicksort so intersections proceed in a bottom up order ... + this.intersectList.Sort(default(IntersectListSort)); + + // Now as we process these intersections, we must sometimes adjust the order + // to ensure that intersecting edges are always adjacent ... + for (int i = 0; i < this.intersectList.Count; ++i) + { + if (!EdgesAdjacentInAEL(this.intersectList[i])) + { + int j = i + 1; + while (!EdgesAdjacentInAEL(this.intersectList[j])) + { + j++; + } + + // swap + (this.intersectList[j], this.intersectList[i]) = + (this.intersectList[i], this.intersectList[j]); + } + + IntersectNode node = this.intersectList[i]; + this.IntersectEdges(node.Edge1, node.Edge2, node.Point); + this.SwapPositionsInAEL(node.Edge1, node.Edge2); + + node.Edge1.CurX = node.Point.X; + node.Edge2.CurX = node.Point.X; + this.CheckJoinLeft(node.Edge2, node.Point, true); + this.CheckJoinRight(node.Edge1, node.Point, true); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SwapPositionsInAEL(Active ae1, Active ae2) + { + // preconditon: ae1 must be immediately to the left of ae2 + Active next = ae2.NextInAEL; + if (next != null) + { + next.PrevInAEL = ae1; + } + + Active prev = ae1.PrevInAEL; + if (prev != null) + { + prev.NextInAEL = ae2; + } + + ae2.PrevInAEL = prev; + ae2.NextInAEL = ae1; + ae1.PrevInAEL = ae2; + ae1.NextInAEL = next; + if (ae2.PrevInAEL == null) + { + this.actives = ae2; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ResetHorzDirection(Active horz, Vertex vertexMax, out float leftX, out float rightX) + { + if (horz.Bot.X == horz.Top.X) + { + // the horizontal edge is going nowhere ... + leftX = horz.CurX; + rightX = horz.CurX; + Active ae = horz.NextInAEL; + while (ae != null && ae.VertexTop != vertexMax) + { + ae = ae.NextInAEL; + } + + return ae != null; + } + + if (horz.CurX < horz.Top.X) + { + leftX = horz.CurX; + rightX = horz.Top.X; + return true; + } + + leftX = horz.Top.X; + rightX = horz.CurX; + return false; // right to left + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool HorzIsSpike(Active horz) + { + Vector2 nextPt = NextVertex(horz).Point; + return (horz.Bot.X < horz.Top.X) != (horz.Top.X < nextPt.X); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Active FindEdgeWithMatchingLocMin(Active e) + { + Active result = e.NextInAEL; + while (result != null) + { + if (result.LocalMin == e.LocalMin) + { + return result; + } + + if (!IsHorizontal(result) && e.Bot != result.Bot) + { + result = null; + } + else + { + result = result.NextInAEL; + } + } + + result = e.PrevInAEL; + while (result != null) + { + if (result.LocalMin == e.LocalMin) + { + return result; + } + + if (!IsHorizontal(result) && e.Bot != result.Bot) + { + return null; + } + + result = result.PrevInAEL; + } + + return result; + } + + private OutPt IntersectEdges(Active ae1, Active ae2, Vector2 pt) + { + OutPt resultOp = null; + + // MANAGE OPEN PATH INTERSECTIONS SEPARATELY ... + if (this.hasOpenPaths && (IsOpen(ae1) || IsOpen(ae2))) + { + if (IsOpen(ae1) && IsOpen(ae2)) + { + return null; + } + + // the following line avoids duplicating quite a bit of code + if (IsOpen(ae2)) + { + SwapActives(ref ae1, ref ae2); + } + + if (IsJoined(ae2)) + { + this.Split(ae2, pt); // needed for safety + } + + if (this.clipType == BooleanOperation.Union) + { + if (!IsHotEdge(ae2)) + { + return null; + } + } + else if (ae2.LocalMin.Polytype == ClippingType.Subject) + { + return null; + } + + switch (this.fillRule) + { + case ClipperFillRule.Positive: + if (ae2.WindCount != 1) + { + return null; + } + + break; + case ClipperFillRule.Negative: + if (ae2.WindCount != -1) + { + return null; + } + + break; + default: + if (Math.Abs(ae2.WindCount) != 1) + { + return null; + } + + break; + } + + // toggle contribution ... + if (IsHotEdge(ae1)) + { + resultOp = AddOutPt(ae1, pt); + if (IsFront(ae1)) + { + ae1.Outrec.FrontEdge = null; + } + else + { + ae1.Outrec.BackEdge = null; + } + + ae1.Outrec = null; + } + + // horizontal edges can pass under open paths at a LocMins + else if (pt == ae1.LocalMin.Vertex.Point && !IsOpenEnd(ae1.LocalMin.Vertex)) + { + // find the other side of the LocMin and + // if it's 'hot' join up with it ... + Active ae3 = FindEdgeWithMatchingLocMin(ae1); + if (ae3 != null && IsHotEdge(ae3)) + { + ae1.Outrec = ae3.Outrec; + if (ae1.WindDx > 0) + { + SetSides(ae3.Outrec!, ae1, ae3); + } + else + { + SetSides(ae3.Outrec!, ae3, ae1); + } + + return ae3.Outrec.Pts; + } + + resultOp = this.StartOpenPath(ae1, pt); + } + else + { + resultOp = this.StartOpenPath(ae1, pt); + } + + return resultOp; + } + + // MANAGING CLOSED PATHS FROM HERE ON + if (IsJoined(ae1)) + { + this.Split(ae1, pt); + } + + if (IsJoined(ae2)) + { + this.Split(ae2, pt); + } + + // UPDATE WINDING COUNTS... + int oldE1WindCount, oldE2WindCount; + if (ae1.LocalMin.Polytype == ae2.LocalMin.Polytype) + { + if (this.fillRule == ClipperFillRule.EvenOdd) + { + oldE1WindCount = ae1.WindCount; + ae1.WindCount = ae2.WindCount; + ae2.WindCount = oldE1WindCount; + } + else + { + if (ae1.WindCount + ae2.WindDx == 0) + { + ae1.WindCount = -ae1.WindCount; + } + else + { + ae1.WindCount += ae2.WindDx; + } + + if (ae2.WindCount - ae1.WindDx == 0) + { + ae2.WindCount = -ae2.WindCount; + } + else + { + ae2.WindCount -= ae1.WindDx; + } + } + } + else + { + if (this.fillRule != ClipperFillRule.EvenOdd) + { + ae1.WindCount2 += ae2.WindDx; + } + else + { + ae1.WindCount2 = ae1.WindCount2 == 0 ? 1 : 0; + } + + if (this.fillRule != ClipperFillRule.EvenOdd) + { + ae2.WindCount2 -= ae1.WindDx; + } + else + { + ae2.WindCount2 = ae2.WindCount2 == 0 ? 1 : 0; + } + } + + switch (this.fillRule) + { + case ClipperFillRule.Positive: + oldE1WindCount = ae1.WindCount; + oldE2WindCount = ae2.WindCount; + break; + case ClipperFillRule.Negative: + oldE1WindCount = -ae1.WindCount; + oldE2WindCount = -ae2.WindCount; + break; + default: + oldE1WindCount = Math.Abs(ae1.WindCount); + oldE2WindCount = Math.Abs(ae2.WindCount); + break; + } + + bool e1WindCountIs0or1 = oldE1WindCount is 0 or 1; + bool e2WindCountIs0or1 = oldE2WindCount is 0 or 1; + + if ((!IsHotEdge(ae1) && !e1WindCountIs0or1) || (!IsHotEdge(ae2) && !e2WindCountIs0or1)) + { + return null; + } + + // NOW PROCESS THE INTERSECTION ... + + // if both edges are 'hot' ... + if (IsHotEdge(ae1) && IsHotEdge(ae2)) + { + if ((oldE1WindCount != 0 && oldE1WindCount != 1) || (oldE2WindCount != 0 && oldE2WindCount != 1) || + (ae1.LocalMin.Polytype != ae2.LocalMin.Polytype && this.clipType != BooleanOperation.Xor)) + { + resultOp = this.AddLocalMaxPoly(ae1, ae2, pt); + } + else if (IsFront(ae1) || (ae1.Outrec == ae2.Outrec)) + { + // this 'else if' condition isn't strictly needed but + // it's sensible to split polygons that ony touch at + // a common vertex (not at common edges). + resultOp = this.AddLocalMaxPoly(ae1, ae2, pt); + this.AddLocalMinPoly(ae1, ae2, pt); + } + else + { + // can't treat as maxima & minima + resultOp = AddOutPt(ae1, pt); + AddOutPt(ae2, pt); + SwapOutrecs(ae1, ae2); + } + } + + // if one or other edge is 'hot' ... + else if (IsHotEdge(ae1)) + { + resultOp = AddOutPt(ae1, pt); + SwapOutrecs(ae1, ae2); + } + else if (IsHotEdge(ae2)) + { + resultOp = AddOutPt(ae2, pt); + SwapOutrecs(ae1, ae2); + } + + // neither edge is 'hot' + else + { + float e1Wc2, e2Wc2; + switch (this.fillRule) + { + case ClipperFillRule.Positive: + e1Wc2 = ae1.WindCount2; + e2Wc2 = ae2.WindCount2; + break; + case ClipperFillRule.Negative: + e1Wc2 = -ae1.WindCount2; + e2Wc2 = -ae2.WindCount2; + break; + default: + e1Wc2 = Math.Abs(ae1.WindCount2); + e2Wc2 = Math.Abs(ae2.WindCount2); + break; + } + + if (!IsSamePolyType(ae1, ae2)) + { + resultOp = this.AddLocalMinPoly(ae1, ae2, pt); + } + else if (oldE1WindCount == 1 && oldE2WindCount == 1) + { + resultOp = null; + switch (this.clipType) + { + case BooleanOperation.Union: + if (e1Wc2 > 0 && e2Wc2 > 0) + { + return null; + } + + resultOp = this.AddLocalMinPoly(ae1, ae2, pt); + break; + + case BooleanOperation.Difference: + if (((GetPolyType(ae1) == ClippingType.Clip) && (e1Wc2 > 0) && (e2Wc2 > 0)) + || ((GetPolyType(ae1) == ClippingType.Subject) && (e1Wc2 <= 0) && (e2Wc2 <= 0))) + { + resultOp = this.AddLocalMinPoly(ae1, ae2, pt); + } + + break; + + case BooleanOperation.Xor: + resultOp = this.AddLocalMinPoly(ae1, ae2, pt); + break; + + default: // ClipType.Intersection: + if (e1Wc2 <= 0 || e2Wc2 <= 0) + { + return null; + } + + resultOp = this.AddLocalMinPoly(ae1, ae2, pt); + break; + } + } + } + + return resultOp; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DeleteFromAEL(Active ae) + { + Active prev = ae.PrevInAEL; + Active next = ae.NextInAEL; + if (prev == null && next == null && (ae != this.actives)) + { + return; // already deleted + } + + if (prev != null) + { + prev.NextInAEL = next; + } + else + { + this.actives = next; + } + + if (next != null) + { + next.PrevInAEL = prev; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AdjustCurrXAndCopyToSEL(float topY) + { + Active ae = this.actives; + this.flaggedHorizontal = ae; + while (ae != null) + { + ae.PrevInSEL = ae.PrevInAEL; + ae.NextInSEL = ae.NextInAEL; + ae.Jump = ae.NextInSEL; + if (ae.JoinWith == JoinWith.Left) + { + ae.CurX = ae.PrevInAEL.CurX; // this also avoids complications + } + else + { + ae.CurX = TopX(ae, topY); + } + + // NB don't update ae.curr.Y yet (see AddNewIntersectNode) + ae = ae.NextInAEL; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool HasLocMinAtY(float y) + => this.currentLocMin < this.minimaList.Count && this.minimaList[this.currentLocMin].Vertex.Point.Y == y; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private LocalMinima PopLocalMinima() + => this.minimaList[this.currentLocMin++]; + + private void AddPathsToVertexList(PathsF paths, ClippingType polytype, bool isOpen) + { + int totalVertCnt = 0; + for (int i = 0; i < paths.Count; i++) + { + PathF path = paths[i]; + totalVertCnt += path.Count; + } + + this.vertexList.EnsureCapacity(this.vertexList.Count + totalVertCnt); + + foreach (PathF path in paths) + { + Vertex v0 = null, prev_v = null, curr_v; + foreach (Vector2 pt in path) + { + if (v0 == null) + { + v0 = new Vertex(pt, VertexFlags.None, null); + this.vertexList.Add(v0); + prev_v = v0; + } + else if (prev_v.Point != pt) + { + // ie skips duplicates + curr_v = new Vertex(pt, VertexFlags.None, prev_v); + this.vertexList.Add(curr_v); + prev_v.Next = curr_v; + prev_v = curr_v; + } + } + + if (prev_v == null || prev_v.Prev == null) + { + continue; + } + + if (!isOpen && prev_v.Point == v0.Point) + { + prev_v = prev_v.Prev; + } + + prev_v.Next = v0; + v0.Prev = prev_v; + if (!isOpen && prev_v.Next == prev_v) + { + continue; + } + + // OK, we have a valid path + bool going_up, going_up0; + if (isOpen) + { + curr_v = v0.Next; + while (curr_v != v0 && curr_v.Point.Y == v0.Point.Y) + { + curr_v = curr_v.Next; + } + + going_up = curr_v.Point.Y <= v0.Point.Y; + if (going_up) + { + v0.Flags = VertexFlags.OpenStart; + this.AddLocMin(v0, polytype, true); + } + else + { + v0.Flags = VertexFlags.OpenStart | VertexFlags.LocalMax; + } + } + else + { + // closed path + prev_v = v0.Prev; + while (prev_v != v0 && prev_v.Point.Y == v0.Point.Y) + { + prev_v = prev_v.Prev; + } + + if (prev_v == v0) + { + continue; // only open paths can be completely flat + } + + going_up = prev_v.Point.Y > v0.Point.Y; + } + + going_up0 = going_up; + prev_v = v0; + curr_v = v0.Next; + while (curr_v != v0) + { + if (curr_v.Point.Y > prev_v.Point.Y && going_up) + { + prev_v.Flags |= VertexFlags.LocalMax; + going_up = false; + } + else if (curr_v.Point.Y < prev_v.Point.Y && !going_up) + { + going_up = true; + this.AddLocMin(prev_v, polytype, isOpen); + } + + prev_v = curr_v; + curr_v = curr_v.Next; + } + + if (isOpen) + { + prev_v.Flags |= VertexFlags.OpenEnd; + if (going_up) + { + prev_v.Flags |= VertexFlags.LocalMax; + } + else + { + this.AddLocMin(prev_v, polytype, isOpen); + } + } + else if (going_up != going_up0) + { + if (going_up0) + { + this.AddLocMin(prev_v, polytype, false); + } + else + { + prev_v.Flags |= VertexFlags.LocalMax; + } + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AddLocMin(Vertex vert, ClippingType polytype, bool isOpen) + { + // make sure the vertex is added only once. + if ((vert.Flags & VertexFlags.LocalMin) != VertexFlags.None) + { + return; + } + + vert.Flags |= VertexFlags.LocalMin; + + LocalMinima lm = new(vert, polytype, isOpen); + this.minimaList.Add(lm); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void PushHorz(Active ae) + { + ae.NextInSEL = this.flaggedHorizontal; + this.flaggedHorizontal = ae; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool PopHorz(out Active ae) + { + ae = this.flaggedHorizontal; + if (this.flaggedHorizontal == null) + { + return false; + } + + this.flaggedHorizontal = this.flaggedHorizontal.NextInSEL; + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private OutPt AddLocalMinPoly(Active ae1, Active ae2, Vector2 pt, bool isNew = false) + { + OutRec outrec = this.NewOutRec(); + ae1.Outrec = outrec; + ae2.Outrec = outrec; + + if (IsOpen(ae1)) + { + outrec.Owner = null; + outrec.IsOpen = true; + if (ae1.WindDx > 0) + { + SetSides(outrec, ae1, ae2); + } + else + { + SetSides(outrec, ae2, ae1); + } + } + else + { + outrec.IsOpen = false; + Active prevHotEdge = GetPrevHotEdge(ae1); + + // e.windDx is the winding direction of the **input** paths + // and unrelated to the winding direction of output polygons. + // Output orientation is determined by e.outrec.frontE which is + // the ascending edge (see AddLocalMinPoly). + if (prevHotEdge != null) + { + outrec.Owner = prevHotEdge.Outrec; + if (OutrecIsAscending(prevHotEdge) == isNew) + { + SetSides(outrec, ae2, ae1); + } + else + { + SetSides(outrec, ae1, ae2); + } + } + else + { + outrec.Owner = null; + if (isNew) + { + SetSides(outrec, ae1, ae2); + } + else + { + SetSides(outrec, ae2, ae1); + } + } + } + + OutPt op = new(pt, outrec); + outrec.Pts = op; + return op; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SetDx(Active ae) + => ae.Dx = GetDx(ae.Bot, ae.Top); + + /******************************************************************************* + * Dx: 0(90deg) * + * | * + * +inf (180deg) <--- o --. -inf (0deg) * + *******************************************************************************/ + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float GetDx(Vector2 pt1, Vector2 pt2) + { + float dy = pt2.Y - pt1.Y; + if (dy != 0) + { + return (pt2.X - pt1.X) / dy; + } + + if (pt2.X > pt1.X) + { + return float.NegativeInfinity; + } + + return float.PositiveInfinity; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float TopX(Active ae, float currentY) + { + Vector2 top = ae.Top; + Vector2 bottom = ae.Bot; + + if ((currentY == top.Y) || (top.X == bottom.X)) + { + return top.X; + } + + if (currentY == bottom.Y) + { + return bottom.X; + } + + return bottom.X + (ae.Dx * (currentY - bottom.Y)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHorizontal(Active ae) + => ae.Top.Y == ae.Bot.Y; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHeadingRightHorz(Active ae) + => float.IsNegativeInfinity(ae.Dx); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHeadingLeftHorz(Active ae) + => float.IsPositiveInfinity(ae.Dx); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SwapActives(ref Active ae1, ref Active ae2) + => (ae2, ae1) = (ae1, ae2); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ClippingType GetPolyType(Active ae) + => ae.LocalMin.Polytype; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsSamePolyType(Active ae1, Active ae2) + => ae1.LocalMin.Polytype == ae2.LocalMin.Polytype; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsContributingClosed(Active ae) + { + switch (this.fillRule) + { + case ClipperFillRule.Positive: + if (ae.WindCount != 1) + { + return false; + } + + break; + case ClipperFillRule.Negative: + if (ae.WindCount != -1) + { + return false; + } + + break; + case ClipperFillRule.NonZero: + if (Math.Abs(ae.WindCount) != 1) + { + return false; + } + + break; + } + + switch (this.clipType) + { + case BooleanOperation.Intersection: + return this.fillRule switch + { + ClipperFillRule.Positive => ae.WindCount2 > 0, + ClipperFillRule.Negative => ae.WindCount2 < 0, + _ => ae.WindCount2 != 0, + }; + + case BooleanOperation.Union: + return this.fillRule switch + { + ClipperFillRule.Positive => ae.WindCount2 <= 0, + ClipperFillRule.Negative => ae.WindCount2 >= 0, + _ => ae.WindCount2 == 0, + }; + + case BooleanOperation.Difference: + bool result = this.fillRule switch + { + ClipperFillRule.Positive => ae.WindCount2 <= 0, + ClipperFillRule.Negative => ae.WindCount2 >= 0, + _ => ae.WindCount2 == 0, + }; + return (GetPolyType(ae) == ClippingType.Subject) ? result : !result; + + case BooleanOperation.Xor: + return true; // XOr is always contributing unless open + + default: + return false; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsContributingOpen(Active ae) + { + bool isInClip, isInSubj; + switch (this.fillRule) + { + case ClipperFillRule.Positive: + isInSubj = ae.WindCount > 0; + isInClip = ae.WindCount2 > 0; + break; + case ClipperFillRule.Negative: + isInSubj = ae.WindCount < 0; + isInClip = ae.WindCount2 < 0; + break; + default: + isInSubj = ae.WindCount != 0; + isInClip = ae.WindCount2 != 0; + break; + } + + bool result = this.clipType switch + { + BooleanOperation.Intersection => isInClip, + BooleanOperation.Union => !isInSubj && !isInClip, + _ => !isInClip + }; + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SetWindCountForClosedPathEdge(Active ae) + { + // Wind counts refer to polygon regions not edges, so here an edge's WindCnt + // indicates the higher of the wind counts for the two regions touching the + // edge. (nb: Adjacent regions can only ever have their wind counts differ by + // one. Also, open paths have no meaningful wind directions or counts.) + Active ae2 = ae.PrevInAEL; + + // find the nearest closed path edge of the same PolyType in AEL (heading left) + ClippingType pt = GetPolyType(ae); + while (ae2 != null && (GetPolyType(ae2) != pt || IsOpen(ae2))) + { + ae2 = ae2.PrevInAEL; + } + + if (ae2 == null) + { + ae.WindCount = ae.WindDx; + ae2 = this.actives; + } + else if (this.fillRule == ClipperFillRule.EvenOdd) + { + ae.WindCount = ae.WindDx; + ae.WindCount2 = ae2.WindCount2; + ae2 = ae2.NextInAEL; + } + else + { + // NonZero, positive, or negative filling here ... + // when e2's WindCnt is in the SAME direction as its WindDx, + // then polygon will fill on the right of 'e2' (and 'e' will be inside) + // nb: neither e2.WindCnt nor e2.WindDx should ever be 0. + if (ae2.WindCount * ae2.WindDx < 0) + { + // opposite directions so 'ae' is outside 'ae2' ... + if (Math.Abs(ae2.WindCount) > 1) + { + // outside prev poly but still inside another. + if (ae2.WindDx * ae.WindDx < 0) + { + // reversing direction so use the same WC + ae.WindCount = ae2.WindCount; + } + else + { + // otherwise keep 'reducing' the WC by 1 (i.e. towards 0) ... + ae.WindCount = ae2.WindCount + ae.WindDx; + } + } + else + { + // now outside all polys of same polytype so set own WC ... + ae.WindCount = IsOpen(ae) ? 1 : ae.WindDx; + } + } + else + { + // 'ae' must be inside 'ae2' + if (ae2.WindDx * ae.WindDx < 0) + { + // reversing direction so use the same WC + ae.WindCount = ae2.WindCount; + } + else + { + // otherwise keep 'increasing' the WC by 1 (i.e. away from 0) ... + ae.WindCount = ae2.WindCount + ae.WindDx; + } + } + + ae.WindCount2 = ae2.WindCount2; + ae2 = ae2.NextInAEL; // i.e. get ready to calc WindCnt2 + } + + // update windCount2 ... + if (this.fillRule == ClipperFillRule.EvenOdd) + { + while (ae2 != ae) + { + if (GetPolyType(ae2!) != pt && !IsOpen(ae2!)) + { + ae.WindCount2 = ae.WindCount2 == 0 ? 1 : 0; + } + + ae2 = ae2.NextInAEL; + } + } + else + { + while (ae2 != ae) + { + if (GetPolyType(ae2!) != pt && !IsOpen(ae2!)) + { + ae.WindCount2 += ae2.WindDx; + } + + ae2 = ae2.NextInAEL; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SetWindCountForOpenPathEdge(Active ae) + { + Active ae2 = this.actives; + if (this.fillRule == ClipperFillRule.EvenOdd) + { + int cnt1 = 0, cnt2 = 0; + while (ae2 != ae) + { + if (GetPolyType(ae2!) == ClippingType.Clip) + { + cnt2++; + } + else if (!IsOpen(ae2!)) + { + cnt1++; + } + + ae2 = ae2.NextInAEL; + } + + ae.WindCount = IsOdd(cnt1) ? 1 : 0; + ae.WindCount2 = IsOdd(cnt2) ? 1 : 0; + } + else + { + while (ae2 != ae) + { + if (GetPolyType(ae2!) == ClippingType.Clip) + { + ae.WindCount2 += ae2.WindDx; + } + else if (!IsOpen(ae2!)) + { + ae.WindCount += ae2.WindDx; + } + + ae2 = ae2.NextInAEL; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidAelOrder(Active resident, Active newcomer) + { + if (newcomer.CurX != resident.CurX) + { + return newcomer.CurX > resident.CurX; + } + + // get the turning direction a1.top, a2.bot, a2.top + float d = ClipperUtils.CrossProduct(resident.Top, newcomer.Bot, newcomer.Top); + if (d != 0) + { + return d < 0; + } + + // edges must be collinear to get here + + // for starting open paths, place them according to + // the direction they're about to turn + if (!IsMaxima(resident) && (resident.Top.Y > newcomer.Top.Y)) + { + return ClipperUtils.CrossProduct(newcomer.Bot, resident.Top, NextVertex(resident).Point) <= 0; + } + + if (!IsMaxima(newcomer) && (newcomer.Top.Y > resident.Top.Y)) + { + return ClipperUtils.CrossProduct(newcomer.Bot, newcomer.Top, NextVertex(newcomer).Point) >= 0; + } + + float y = newcomer.Bot.Y; + bool newcomerIsLeft = newcomer.IsLeftBound; + + if (resident.Bot.Y != y || resident.LocalMin.Vertex.Point.Y != y) + { + return newcomer.IsLeftBound; + } + + // resident must also have just been inserted + if (resident.IsLeftBound != newcomerIsLeft) + { + return newcomerIsLeft; + } + + if (ClipperUtils.CrossProduct(PrevPrevVertex(resident).Point, resident.Bot, resident.Top) == 0) + { + return true; + } + + // compare turning direction of the alternate bound + return (ClipperUtils.CrossProduct(PrevPrevVertex(resident).Point, newcomer.Bot, PrevPrevVertex(newcomer).Point) > 0) == newcomerIsLeft; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void InsertLeftEdge(Active ae) + { + Active ae2; + + if (this.actives == null) + { + ae.PrevInAEL = null; + ae.NextInAEL = null; + this.actives = ae; + } + else if (!IsValidAelOrder(this.actives, ae)) + { + ae.PrevInAEL = null; + ae.NextInAEL = this.actives; + this.actives.PrevInAEL = ae; + this.actives = ae; + } + else + { + ae2 = this.actives; + while (ae2.NextInAEL != null && IsValidAelOrder(ae2.NextInAEL, ae)) + { + ae2 = ae2.NextInAEL; + } + + // don't separate joined edges + if (ae2.JoinWith == JoinWith.Right) + { + ae2 = ae2.NextInAEL; + } + + ae.NextInAEL = ae2.NextInAEL; + if (ae2.NextInAEL != null) + { + ae2.NextInAEL.PrevInAEL = ae; + } + + ae.PrevInAEL = ae2; + ae2.NextInAEL = ae; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void InsertRightEdge(Active ae, Active ae2) + { + ae2.NextInAEL = ae.NextInAEL; + if (ae.NextInAEL != null) + { + ae.NextInAEL.PrevInAEL = ae2; + } + + ae2.PrevInAEL = ae; + ae.NextInAEL = ae2; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vertex NextVertex(Active ae) + { + if (ae.WindDx > 0) + { + return ae.VertexTop.Next; + } + + return ae.VertexTop.Prev; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vertex PrevPrevVertex(Active ae) + { + if (ae.WindDx > 0) + { + return ae.VertexTop.Prev.Prev; + } + + return ae.VertexTop.Next.Next; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsMaxima(Vertex vertex) + => (vertex.Flags & VertexFlags.LocalMax) != VertexFlags.None; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsMaxima(Active ae) + => IsMaxima(ae.VertexTop); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Active GetMaximaPair(Active ae) + { + Active ae2; + ae2 = ae.NextInAEL; + while (ae2 != null) + { + if (ae2.VertexTop == ae.VertexTop) + { + return ae2; // Found! + } + + ae2 = ae2.NextInAEL; + } + + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsOdd(int val) + => (val & 1) != 0; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHotEdge(Active ae) + => ae.Outrec != null; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsOpen(Active ae) + => ae.LocalMin.IsOpen; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsOpenEnd(Active ae) + => ae.LocalMin.IsOpen && IsOpenEnd(ae.VertexTop); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsOpenEnd(Vertex v) + => (v.Flags & (VertexFlags.OpenStart | VertexFlags.OpenEnd)) != VertexFlags.None; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Active GetPrevHotEdge(Active ae) + { + Active prev = ae.PrevInAEL; + while (prev != null && (IsOpen(prev) || !IsHotEdge(prev))) + { + prev = prev.PrevInAEL; + } + + return prev; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void JoinOutrecPaths(Active ae1, Active ae2) + { + // join ae2 outrec path onto ae1 outrec path and then delete ae2 outrec path + // pointers. (NB Only very rarely do the joining ends share the same coords.) + OutPt p1Start = ae1.Outrec.Pts; + OutPt p2Start = ae2.Outrec.Pts; + OutPt p1End = p1Start.Next; + OutPt p2End = p2Start.Next; + if (IsFront(ae1)) + { + p2End.Prev = p1Start; + p1Start.Next = p2End; + p2Start.Next = p1End; + p1End.Prev = p2Start; + ae1.Outrec.Pts = p2Start; + + // nb: if IsOpen(e1) then e1 & e2 must be a 'maximaPair' + ae1.Outrec.FrontEdge = ae2.Outrec.FrontEdge; + if (ae1.Outrec.FrontEdge != null) + { + ae1.Outrec.FrontEdge.Outrec = ae1.Outrec; + } + } + else + { + p1End.Prev = p2Start; + p2Start.Next = p1End; + p1Start.Next = p2End; + p2End.Prev = p1Start; + + ae1.Outrec.BackEdge = ae2.Outrec.BackEdge; + if (ae1.Outrec.BackEdge != null) + { + ae1.Outrec.BackEdge.Outrec = ae1.Outrec; + } + } + + // after joining, the ae2.OutRec must contains no vertices ... + ae2.Outrec.FrontEdge = null; + ae2.Outrec.BackEdge = null; + ae2.Outrec.Pts = null; + SetOwner(ae2.Outrec, ae1.Outrec); + + if (IsOpenEnd(ae1)) + { + ae2.Outrec.Pts = ae1.Outrec.Pts; + ae1.Outrec.Pts = null; + } + + // and ae1 and ae2 are maxima and are about to be dropped from the Actives list. + ae1.Outrec = null; + ae2.Outrec = null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static OutPt AddOutPt(Active ae, Vector2 pt) + { + // Outrec.OutPts: a circular doubly-linked-list of POutPt where ... + // opFront[.Prev]* ~~~> opBack & opBack == opFront.Next + OutRec outrec = ae.Outrec; + bool toFront = IsFront(ae); + OutPt opFront = outrec.Pts; + OutPt opBack = opFront.Next; + + if (toFront && (pt == opFront.Point)) + { + return opFront; + } + else if (!toFront && (pt == opBack.Point)) + { + return opBack; + } + + OutPt newOp = new(pt, outrec); + opBack.Prev = newOp; + newOp.Prev = opFront; + newOp.Next = opBack; + opFront.Next = newOp; + if (toFront) + { + outrec.Pts = newOp; + } + + return newOp; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private OutRec NewOutRec() + { + OutRec result = new() + { + Idx = this.outrecList.Count + }; + this.outrecList.Add(result); + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private OutPt StartOpenPath(Active ae, Vector2 pt) + { + OutRec outrec = this.NewOutRec(); + outrec.IsOpen = true; + if (ae.WindDx > 0) + { + outrec.FrontEdge = ae; + outrec.BackEdge = null; + } + else + { + outrec.FrontEdge = null; + outrec.BackEdge = ae; + } + + ae.Outrec = outrec; + OutPt op = new(pt, outrec); + outrec.Pts = op; + return op; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateEdgeIntoAEL(Active ae) + { + ae.Bot = ae.Top; + ae.VertexTop = NextVertex(ae); + ae.Top = ae.VertexTop.Point; + ae.CurX = ae.Bot.X; + SetDx(ae); + + if (IsJoined(ae)) + { + this.Split(ae, ae.Bot); + } + + if (IsHorizontal(ae)) + { + return; + } + + this.InsertScanline(ae.Top.Y); + + this.CheckJoinLeft(ae, ae.Bot); + this.CheckJoinRight(ae, ae.Bot, true); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SetSides(OutRec outrec, Active startEdge, Active endEdge) + { + outrec.FrontEdge = startEdge; + outrec.BackEdge = endEdge; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SwapOutrecs(Active ae1, Active ae2) + { + OutRec or1 = ae1.Outrec; // at least one edge has + OutRec or2 = ae2.Outrec; // an assigned outrec + if (or1 == or2) + { + (or1.BackEdge, or1.FrontEdge) = (or1.FrontEdge, or1.BackEdge); + return; + } + + if (or1 != null) + { + if (ae1 == or1.FrontEdge) + { + or1.FrontEdge = ae2; + } + else + { + or1.BackEdge = ae2; + } + } + + if (or2 != null) + { + if (ae2 == or2.FrontEdge) + { + or2.FrontEdge = ae1; + } + else + { + or2.BackEdge = ae1; + } + } + + ae1.Outrec = or2; + ae2.Outrec = or1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SetOwner(OutRec outrec, OutRec newOwner) + { + // precondition1: new_owner is never null + while (newOwner.Owner != null && newOwner.Owner.Pts == null) + { + newOwner.Owner = newOwner.Owner.Owner; + } + + // make sure that outrec isn't an owner of newOwner + OutRec tmp = newOwner; + while (tmp != null && tmp != outrec) + { + tmp = tmp.Owner; + } + + if (tmp != null) + { + newOwner.Owner = outrec.Owner; + } + + outrec.Owner = newOwner; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float Area(OutPt op) + { + // https://en.wikipedia.org/wiki/Shoelace_formula + float area = 0; + OutPt op2 = op; + do + { + area += (op2.Prev.Point.Y + op2.Point.Y) * (op2.Prev.Point.X - op2.Point.X); + op2 = op2.Next; + } + while (op2 != op); + return area * .5F; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static float AreaTriangle(Vector2 pt1, Vector2 pt2, Vector2 pt3) + => ((pt3.Y + pt1.Y) * (pt3.X - pt1.X)) + + ((pt1.Y + pt2.Y) * (pt1.X - pt2.X)) + + ((pt2.Y + pt3.Y) * (pt2.X - pt3.X)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static OutRec GetRealOutRec(OutRec outRec) + { + while ((outRec != null) && (outRec.Pts == null)) + { + outRec = outRec.Owner; + } + + return outRec; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void UncoupleOutRec(Active ae) + { + OutRec outrec = ae.Outrec; + if (outrec == null) + { + return; + } + + outrec.FrontEdge.Outrec = null; + outrec.BackEdge.Outrec = null; + outrec.FrontEdge = null; + outrec.BackEdge = null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool OutrecIsAscending(Active hotEdge) + => hotEdge == hotEdge.Outrec.FrontEdge; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SwapFrontBackSides(OutRec outrec) + { + // while this proc. is needed for open paths + // it's almost never needed for closed paths + (outrec.BackEdge, outrec.FrontEdge) = (outrec.FrontEdge, outrec.BackEdge); + outrec.Pts = outrec.Pts.Next; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool EdgesAdjacentInAEL(IntersectNode inode) + => (inode.Edge1.NextInAEL == inode.Edge2) || (inode.Edge1.PrevInAEL == inode.Edge2); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void CheckJoinLeft(Active e, Vector2 pt, bool checkCurrX = false) + { + Active prev = e.PrevInAEL; + if (prev == null + || IsOpen(e) + || IsOpen(prev) + || !IsHotEdge(e) + || !IsHotEdge(prev)) + { + return; + } + + // Avoid trivial joins + if ((pt.Y < e.Top.Y + 2 || pt.Y < prev.Top.Y + 2) + && ((e.Bot.Y > pt.Y) || (prev.Bot.Y > pt.Y))) + { + return; + } + + if (checkCurrX) + { + if (ClipperUtils.PerpendicDistFromLineSqrd(pt, prev.Bot, prev.Top) > 0.25) + { + return; + } + } + else if (e.CurX != prev.CurX) + { + return; + } + + if (ClipperUtils.CrossProduct(e.Top, pt, prev.Top) != 0) + { + return; + } + + if (e.Outrec.Idx == prev.Outrec.Idx) + { + this.AddLocalMaxPoly(prev, e, pt); + } + else if (e.Outrec.Idx < prev.Outrec.Idx) + { + JoinOutrecPaths(e, prev); + } + else + { + JoinOutrecPaths(prev, e); + } + + prev.JoinWith = JoinWith.Right; + e.JoinWith = JoinWith.Left; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void CheckJoinRight(Active e, Vector2 pt, bool checkCurrX = false) + { + Active next = e.NextInAEL; + if (IsOpen(e) + || !IsHotEdge(e) + || IsJoined(e) + || next == null + || IsOpen(next) + || !IsHotEdge(next)) + { + return; + } + + // Avoid trivial joins + if ((pt.Y < e.Top.Y + 2 || pt.Y < next.Top.Y + 2) + && ((e.Bot.Y > pt.Y) || (next.Bot.Y > pt.Y))) + { + return; + } + + if (checkCurrX) + { + if (ClipperUtils.PerpendicDistFromLineSqrd(pt, next.Bot, next.Top) > 0.25) + { + return; + } + } + else if (e.CurX != next.CurX) + { + return; + } + + if (ClipperUtils.CrossProduct(e.Top, pt, next.Top) != 0) + { + return; + } + + if (e.Outrec.Idx == next.Outrec.Idx) + { + this.AddLocalMaxPoly(e, next, pt); + } + else if (e.Outrec.Idx < next.Outrec.Idx) + { + JoinOutrecPaths(e, next); + } + else + { + JoinOutrecPaths(next, e); + } + + e.JoinWith = JoinWith.Right; + next.JoinWith = JoinWith.Left; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void FixOutRecPts(OutRec outrec) + { + OutPt op = outrec.Pts; + do + { + op.OutRec = outrec; + op = op.Next; + } + while (op != outrec.Pts); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private OutPt AddLocalMaxPoly(Active ae1, Active ae2, Vector2 pt) + { + if (IsJoined(ae1)) + { + this.Split(ae1, pt); + } + + if (IsJoined(ae2)) + { + this.Split(ae2, pt); + } + + if (IsFront(ae1) == IsFront(ae2)) + { + if (IsOpenEnd(ae1)) + { + SwapFrontBackSides(ae1.Outrec!); + } + else if (IsOpenEnd(ae2)) + { + SwapFrontBackSides(ae2.Outrec!); + } + else + { + return null; + } + } + + OutPt result = AddOutPt(ae1, pt); + if (ae1.Outrec == ae2.Outrec) + { + OutRec outrec = ae1.Outrec; + outrec.Pts = result; + UncoupleOutRec(ae1); + } + + // and to preserve the winding orientation of outrec ... + else if (IsOpen(ae1)) + { + if (ae1.WindDx < 0) + { + JoinOutrecPaths(ae1, ae2); + } + else + { + JoinOutrecPaths(ae2, ae1); + } + } + else if (ae1.Outrec.Idx < ae2.Outrec.Idx) + { + JoinOutrecPaths(ae1, ae2); + } + else + { + JoinOutrecPaths(ae2, ae1); + } + + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsJoined(Active e) + => e.JoinWith != JoinWith.None; + + private void Split(Active e, Vector2 currPt) + { + if (e.JoinWith == JoinWith.Right) + { + e.JoinWith = JoinWith.None; + e.NextInAEL.JoinWith = JoinWith.None; + this.AddLocalMinPoly(e, e.NextInAEL, currPt, true); + } + else + { + e.JoinWith = JoinWith.None; + e.PrevInAEL.JoinWith = JoinWith.None; + this.AddLocalMinPoly(e.PrevInAEL, e, currPt, true); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsFront(Active ae) + => ae == ae.Outrec.FrontEdge; + + private struct LocMinSorter : IComparer + { + public readonly int Compare(LocalMinima locMin1, LocalMinima locMin2) + => locMin2.Vertex.Point.Y.CompareTo(locMin1.Vertex.Point.Y); + } + + private readonly struct LocalMinima + { + public readonly Vertex Vertex; + public readonly ClippingType Polytype; + public readonly bool IsOpen; + + public LocalMinima(Vertex vertex, ClippingType polytype, bool isOpen = false) + { + this.Vertex = vertex; + this.Polytype = polytype; + this.IsOpen = isOpen; + } + + public static bool operator ==(LocalMinima lm1, LocalMinima lm2) + + // TODO: Check this. Why ref equals. + => ReferenceEquals(lm1.Vertex, lm2.Vertex); + + public static bool operator !=(LocalMinima lm1, LocalMinima lm2) + => !(lm1 == lm2); + + public override bool Equals(object obj) + => obj is LocalMinima minima && this == minima; + + public override int GetHashCode() + => this.Vertex.GetHashCode(); + } + + // IntersectNode: a structure representing 2 intersecting edges. + // Intersections must be sorted so they are processed from the largest + // Y coordinates to the smallest while keeping edges adjacent. + private readonly struct IntersectNode + { + public readonly Vector2 Point; + public readonly Active Edge1; + public readonly Active Edge2; + + public IntersectNode(Vector2 pt, Active edge1, Active edge2) + { + this.Point = pt; + this.Edge1 = edge1; + this.Edge2 = edge2; + } + } + + private struct HorzSegSorter : IComparer + { + public readonly int Compare(HorzSegment hs1, HorzSegment hs2) + { + if (hs1 == null || hs2 == null) + { + return 0; + } + + if (hs1.RightOp == null) + { + return hs2.RightOp == null ? 0 : 1; + } + else if (hs2.RightOp == null) + { + return -1; + } + else + { + return hs1.LeftOp.Point.X.CompareTo(hs2.LeftOp.Point.X); + } + } + } + + private struct IntersectListSort : IComparer + { + public readonly int Compare(IntersectNode a, IntersectNode b) + { + if (a.Point.Y == b.Point.Y) + { + if (a.Point.X == b.Point.X) + { + return 0; + } + + return (a.Point.X < b.Point.X) ? -1 : 1; + } + + return (a.Point.Y > b.Point.Y) ? -1 : 1; + } + } + + private class HorzSegment + { + public HorzSegment(OutPt op) + { + this.LeftOp = op; + this.RightOp = null; + this.LeftToRight = true; + } + + public OutPt LeftOp { get; set; } + + public OutPt RightOp { get; set; } + + public bool LeftToRight { get; set; } + } + + private class HorzJoin + { + public HorzJoin(OutPt ltor, OutPt rtol) + { + this.Op1 = ltor; + this.Op2 = rtol; + } + + public OutPt Op1 { get; } + + public OutPt Op2 { get; } + } + + // OutPt: vertex data structure for clipping solutions + private class OutPt + { + public OutPt(Vector2 pt, OutRec outrec) + { + this.Point = pt; + this.OutRec = outrec; + this.Next = this; + this.Prev = this; + this.HorizSegment = null; + } + + public Vector2 Point { get; } + + public OutPt Next { get; set; } + + public OutPt Prev { get; set; } + + public OutRec OutRec { get; set; } + + public HorzSegment HorizSegment { get; set; } + } + + // OutRec: path data structure for clipping solutions + private class OutRec + { + public int Idx { get; set; } + + public OutRec Owner { get; set; } + + public Active FrontEdge { get; set; } + + public Active BackEdge { get; set; } + + public OutPt Pts { get; set; } + + public PolyPathF PolyPath { get; set; } + + public BoundsF Bounds { get; set; } + + public PathF Path { get; set; } = []; + + public bool IsOpen { get; set; } + + public List Splits { get; set; } + } + + private class Vertex + { + public Vertex(Vector2 pt, VertexFlags flags, Vertex prev) + { + this.Point = pt; + this.Flags = flags; + this.Next = null; + this.Prev = prev; + } + + public Vector2 Point { get; } + + public Vertex Next { get; set; } + + public Vertex Prev { get; set; } + + public VertexFlags Flags { get; set; } + } + + private class Active + { + public Vector2 Bot { get; set; } + + public Vector2 Top { get; set; } + + public float CurX { get; set; } // current (updated at every new scanline) + + public float Dx { get; set; } + + public int WindDx { get; set; } // 1 or -1 depending on winding direction + + public int WindCount { get; set; } + + public int WindCount2 { get; set; } // winding count of the opposite polytype + + public OutRec Outrec { get; set; } + + // AEL: 'active edge list' (Vatti's AET - active edge table) + // a linked list of all edges (from left to right) that are present + // (or 'active') within the current scanbeam (a horizontal 'beam' that + // sweeps from bottom to top over the paths in the clipping operation). + public Active PrevInAEL { get; set; } + + public Active NextInAEL { get; set; } + + // SEL: 'sorted edge list' (Vatti's ST - sorted table) + // linked list used when sorting edges into their new positions at the + // top of scanbeams, but also (re)used to process horizontals. + public Active PrevInSEL { get; set; } + + public Active NextInSEL { get; set; } + + public Active Jump { get; set; } + + public Vertex VertexTop { get; set; } + + public LocalMinima LocalMin { get; set; } // the bottom of an edge 'bound' (also Vatti) + + public bool IsLeftBound { get; set; } + + public JoinWith JoinWith { get; set; } + } +} + +internal class PolyPathF : IEnumerable +{ + private readonly PolyPathF parent; + private readonly List items = []; + + public PolyPathF(PolyPathF parent = null) + => this.parent = parent; + + public PathF Polygon { get; private set; } // polytree root's polygon == null + + public int Level => this.GetLevel(); + + public bool IsHole => this.GetIsHole(); + + public int Count => this.items.Count; + + public PolyPathF this[int index] => this.items[index]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PolyPathF AddChild(PathF p) + { + PolyPathF child = new(this) + { + Polygon = p + }; + + this.items.Add(child); + return child; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public float Area() + { + float result = this.Polygon == null ? 0 : ClipperUtils.Area(this.Polygon); + for (int i = 0; i < this.items.Count; i++) + { + PolyPathF child = this.items[i]; + result += child.Area(); + } + + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Clear() => this.items.Clear(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool GetIsHole() + { + int lvl = this.Level; + return lvl != 0 && (lvl & 1) == 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int GetLevel() + { + int result = 0; + PolyPathF pp = this.parent; + while (pp != null) + { + ++result; + pp = pp.parent; + } + + return result; + } + + public IEnumerator GetEnumerator() => this.items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => this.items.GetEnumerator(); +} + +internal class PolyTreeF : PolyPathF +{ +} + +internal class PathsF : List +{ + public PathsF() + { + } + + public PathsF(IEnumerable items) + : base(items) + { + } + + public PathsF(int capacity) + : base(capacity) + { + } +} + +internal class PathF : List +{ + public PathF() + { + } + + public PathF(IEnumerable items) + : base(items) + { + } + + public PathF(int capacity) + : base(capacity) + { + } +} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipperFactory.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipperFactory.cs deleted file mode 100644 index f904629e..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipperFactory.cs +++ /dev/null @@ -1,384 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -using System.Runtime.CompilerServices; -using SixLabors.PolygonClipper; -using ClipperPolygon = SixLabors.PolygonClipper.Polygon; - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; - -/// -/// Builders for from ImageSharp paths. -/// PolygonClipper requires explicit orientation and nesting of contours ImageSharp polygons do not contain that information -/// so we must derive that from the input. -/// -internal static class PolygonClipperFactory -{ - /// - /// Creates a new polygon by combining multiple paths using the specified intersection rule. - /// - /// Use this method to construct complex polygons from multiple input paths, such as when - /// importing shapes from vector graphics or combining user-drawn segments. The resulting polygon's structure - /// depends on the order and geometry of the input paths as well as the chosen intersection rule. - /// - /// - /// A collection of paths that define the shapes to be combined into a single polygon. Each path is expected to - /// represent a simple or complex shape. - /// - /// Containment rule for nesting, or . - /// A representing the union of all input paths, combined according to the specified intersection rule. - public static ClipperPolygon FromPaths(IEnumerable paths, IntersectionRule rule) - { - // Accumulate all paths of the complex shape into a single polygon. - ClipperPolygon polygon = []; - - foreach (IPath path in paths) - { - polygon = FromSimplePaths(path.Flatten(), rule, polygon); - } - - return polygon; - } - - /// - /// Builds a from closed rings. - /// - /// - /// Pipeline: - /// 1) Filter to closed paths with ≥3 unique points, copy to rings. - /// 2) Compute signed area via the shoelace formula to get orientation and magnitude. - /// 3) For each ring, pick its lexicographic bottom-left vertex. - /// 4) Parent assignment: for ring i, shoot a conceptual vertical ray downward from its bottom-left point - /// and test containment against all other rings using the selected . - /// The parent is the smallest-area ring that contains the point. - /// 5) Depth is the number of ancestors by repeated parent lookup. - /// 6) Materialize s, enforce even depth CCW and odd depth CW, - /// set and , add to and wire holes. - /// Notes: - /// - Step 4 mirrors the parent-detection approach formalized in Martínez–Rueda 2013. - /// - Containment uses Even-Odd or Non-Zero consistently, so glyph-like inputs can use Non-Zero. - /// - Boundary handling: points exactly on edges are not special-cased here, which is typical for nesting. - /// - /// Closed simple paths. - /// Containment rule for nesting, or . - /// Optional existing polygon to populate. - /// The constructed . - public static ClipperPolygon FromSimplePaths(IEnumerable paths, IntersectionRule rule, ClipperPolygon? polygon = null) - { - // Gather rings as Vertex lists (explicitly closed), plus per-ring metadata. - List> rings = []; - List areas = []; - List bottomLeft = []; - - foreach (ISimplePath p in paths) - { - if (!p.IsClosed) - { - // TODO: could append first point to close, but that fabricates geometry. - continue; - } - - ReadOnlySpan s = p.Points.Span; - int n = s.Length; - - // Need at least 3 points to form area. - if (n < 3) - { - continue; - } - - // Copy all points as-is. - List ring = new(n); - for (int i = 0; i < n; i++) - { - ring.Add(new Vertex(s[i].X, s[i].Y)); - } - - // Ensure explicit closure: start == end. - if (ring.Count > 0) - { - Vertex first = ring[0]; - Vertex last = ring[^1]; - if (first.X != last.X || first.Y != last.Y) - { - ring.Add(first); - } - } - - // After closure, still require at least 3 unique vertices. - if (ring.Count < 4) // 3 unique + repeated first == last - { - continue; - } - - rings.Add(ring); - - // SignedArea must handle a closed ring (last == first). - areas.Add(SignedArea(ring)); - - // Choose lexicographic bottom-left vertex index for nesting test. - bottomLeft.Add(IndexOfBottomLeft(ring)); - } - - int m = rings.Count; - if (m == 0) - { - return []; - } - - // Parent assignment: pick the smallest-area ring that contains the bottom-left vertex. - // TODO: We can use pooling here if we care about large numbers of rings. - int[] parent = new int[m]; - Array.Fill(parent, -1); - - for (int i = 0; i < m; i++) - { - Vertex q = rings[i][bottomLeft[i]]; - int best = -1; - double bestArea = double.MaxValue; - - for (int j = 0; j < m; j++) - { - if (i == j) - { - continue; - } - - if (IsPointInPolygon(q, rings[j], rule)) - { - double a = Math.Abs(areas[j]); - if (a < bestArea) - { - bestArea = a; - best = j; - } - } - } - - parent[i] = best; - } - - // Depth = number of ancestors by following Parent links. - // TODO: We can pool this if we care about large numbers of rings. - int[] depth = new int[m]; - for (int i = 0; i < m; i++) - { - int d = 0; - for (int pIdx = parent[i]; pIdx >= 0; pIdx = parent[pIdx]) - { - d++; - } - - depth[i] = d; - } - - // Emit contours, enforce orientation by depth, and wire into polygon. - polygon ??= []; - for (int i = 0; i < m; i++) - { - Contour c = new(); - - // Stream vertices into the contour. Ring is already explicitly closed. - foreach (Vertex v in rings[i]) - { - c.AddVertex(v); - } - - // Orientation convention: even depth = outer => CCW, odd depth = hole => CW. - if ((depth[i] & 1) == 0) - { - c.SetCounterClockwise(); - } - else - { - c.SetClockwise(); - } - - // Topology annotations. - c.ParentIndex = parent[i] >= 0 ? parent[i] : null; - c.Depth = depth[i]; - - polygon.Add(c); - } - - // Record hole indices for parents now that indices are stable. - for (int i = 0; i < m; i++) - { - int pIdx = parent[i]; - if (pIdx >= 0) - { - polygon[pIdx].AddHoleIndex(i); - } - } - - return polygon; - } - - /// - /// Computes the signed area of a closed ring using the shoelace formula. - /// - /// Ring of vertices. - /// - /// Formula: - /// - /// A = 0.5 * Σ cross(v[j], v[i]) with j = (i - 1) mod n - /// - /// where cross(a,b) = a.X * b.Y - a.Y * b.X. - /// Interpretation: - /// - A > 0 means counter-clockwise orientation. - /// - A < 0 means clockwise orientation. - /// - private static double SignedArea(List r) - { - double area = 0d; - - for (int i = 0, j = r.Count - 1; i < r.Count; j = i, i++) - { - area += Vertex.Cross(r[j], r[i]); - } - - return 0.5d * area; - } - - /// - /// Returns the index of the lexicographically bottom-left vertex. - /// - /// Ring of vertices. - /// - /// Lexicographic order (X then Y) yields a unique seed for nesting tests and matches - /// common parent-detection proofs that cast a ray from the lowest-leftmost point. - /// - private static int IndexOfBottomLeft(List r) - { - int k = 0; - - for (int i = 1; i < r.Count; i++) - { - Vertex a = r[i]; - Vertex b = r[k]; - - if (a.X < b.X || (a.X == b.X && a.Y < b.Y)) - { - k = i; - } - } - - return k; - } - - /// - /// Dispatches to the selected point-in-polygon implementation. - /// - /// Query point. - /// Closed ring. - /// Fill rule. - private static bool IsPointInPolygon(in Vertex p, List ring, IntersectionRule rule) - { - if (rule == IntersectionRule.EvenOdd) - { - return PointInPolygonEvenOdd(p, ring); - } - - return PointInPolygonNonZero(p, ring); - } - - /// - /// Even-odd point-in-polygon via ray casting. - /// - /// Query point. - /// Closed ring. - /// - /// Let a horizontal ray start at and extend to +∞ in X. - /// For each edge (a→b), count an intersection if the edge straddles the ray’s Y - /// and the ray’s X is strictly less than the edge’s X at that Y: - /// - /// intersects = ((b.Y > p.Y) != (a.Y > p.Y)) amp;& p.X < x_at_pY(a,b) - /// - /// Parity of the count determines interior. - /// Horizontal edges contribute zero because the straddle test excludes equal Y. - /// Using a half-open interval on Y prevents double-counting shared vertices. - /// - private static bool PointInPolygonEvenOdd(in Vertex p, List ring) - { - bool inside = false; - int n = ring.Count; - int j = n - 1; - - for (int i = 0; i < n; j = i, i++) - { - Vertex a = ring[j]; - Vertex b = ring[i]; - - bool straddles = (b.Y > p.Y) != (a.Y > p.Y); - - if (straddles) - { - double ySpan = a.Y - b.Y; - double xAtPY = (((a.X - b.X) * (p.Y - b.Y)) / (ySpan == 0d ? double.Epsilon : ySpan)) + b.X; - - if (p.X < xAtPY) - { - inside = !inside; - } - } - } - - return inside; - } - - /// - /// Non-zero winding point-in-polygon. - /// - /// Query point. - /// Closed ring. - /// - /// Scan all edges (a→b). - /// - If the edge crosses the scanline upward (a.Y ≤ p.Y && b.Y > p.Y) and - /// lies strictly to the left of the edge, increment the winding. - /// - If it crosses downward (a.Y > p.Y && b.Y ≤ p.Y) and - /// lies strictly to the right, decrement the winding. - /// The point is inside iff the winding number is non-zero. - /// Left/right is decided by the sign of the cross product of vectors a→b and a→p. - /// - private static bool PointInPolygonNonZero(in Vertex p, List ring) - { - int winding = 0; - int n = ring.Count; - - for (int i = 0, j = n - 1; i < n; j = i, i++) - { - Vertex a = ring[j]; - Vertex b = ring[i]; - - if (a.Y <= p.Y) - { - if (b.Y > p.Y && IsLeft(a, b, p)) - { - winding++; - } - } - else if (b.Y <= p.Y && !IsLeft(a, b, p)) - { - winding--; - } - } - - return winding != 0; - } - - /// - /// Returns true if is strictly left of the directed edge a→b. - /// - /// Edge start. - /// Edge end. - /// Query point. - /// - /// Tests the sign of the 2D cross product: - /// - /// cross = (b - a) × (p - a) = (b.X - a.X)*(p.Y - a.Y) - (b.Y - a.Y)*(p.X - a.X) - /// - /// Left if cross > 0, right if cross < 0, collinear if cross == 0. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsLeft(Vertex a, Vertex b, Vertex p) => Vertex.Cross(b - a, p - a) > 0d; -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs index 9d5ac054..14ea9c70 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs @@ -2,7 +2,7 @@ // Licensed under the Six Labors Split License. using System.Runtime.CompilerServices; -using SixLabors.ImageSharp.Drawing.Shapes.Helpers; +using SixLabors.ImageSharp.Drawing.Processing; #pragma warning disable SA1201 // Elements should appear in the correct order namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; @@ -28,7 +28,6 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; /// /// internal sealed class PolygonStroker - { private ArrayBuilder outVertices = new(1); private ArrayBuilder srcVertices = new(16); @@ -42,18 +41,56 @@ internal sealed class PolygonStroker private double widthEps = 0.5 / 1024.0; private int widthSign = 1; - public double MiterLimit { get; set; } = 4; - - public double InnerMiterLimit { get; set; } = 1.01; - - public double ApproximationScale { get; set; } = 1.0; - - public LineJoin LineJoin { get; set; } = LineJoin.BevelJoin; - - public LineCap LineCap { get; set; } = LineCap.Butt; - - public InnerJoin InnerJoin { get; set; } = InnerJoin.InnerMiter; + /// + /// Initializes a new instance of the class with the specified stroke options. + /// + /// + /// The stroke options to use for configuring line joins, caps, miter limits, and approximation scale. + /// Cannot be . + /// + public PolygonStroker(StrokeOptions options) + { + this.LineJoin = options.LineJoin; + this.InnerJoin = options.InnerJoin; + this.LineCap = options.LineCap; + this.MiterLimit = options.MiterLimit; + this.InnerMiterLimit = options.InnerMiterLimit; + this.ApproximationScale = options.ApproximationScale; + } + /// + /// Gets the miter limit used to clamp outer miter joins. + /// + public double MiterLimit { get; } + + /// + /// Gets the inner miter limit used to clamp joins on acute interior angles. + /// + public double InnerMiterLimit { get; } + + /// + /// Gets the arc approximation scale used for round joins and caps. + /// + public double ApproximationScale { get; } + + /// + /// Gets the outer line join style used for stroking corners. + /// + public LineJoin LineJoin { get; } + + /// + /// Gets the line cap style used for open path ends. + /// + public LineCap LineCap { get; } + + /// + /// Gets the join style used for sharp interior angles. + /// + public InnerJoin InnerJoin { get; } + + /// + /// Gets or sets the stroke width in the caller's coordinate space. + /// public double Width { get => this.strokeWidth * 2.0; @@ -75,6 +112,12 @@ public double Width } } + /// + /// Strokes the provided polyline or polygon and returns the outline vertices. + /// + /// The input points to stroke. + /// Whether the input is a closed ring. + /// The stroked outline as a closed point array. public PointF[] ProcessPath(ReadOnlySpan linePoints, bool isClosed) { if (linePoints.Length < 2) @@ -95,6 +138,10 @@ public PointF[] ProcessPath(ReadOnlySpan linePoints, bool isClosed) return [.. results]; } + /// + /// Adds a sequence of line segments to the current stroker state. + /// + /// The input points to add as line segments. public void AddLinePath(ReadOnlySpan linePoints) { for (int i = 0; i < linePoints.Length; i++) @@ -104,6 +151,9 @@ public void AddLinePath(ReadOnlySpan linePoints) } } + /// + /// Marks the current path as closed before finishing the outline. + /// public void ClosePath() { // Mark the current src path as closed; no geometry is pushed here. @@ -111,6 +161,10 @@ public void ClosePath() this.status = Status.Initial; } + /// + /// Finalizes stroking and appends output points to the provided list. + /// + /// The list that receives the stroked outline vertices. public void FinishPath(List results) { PointF currentPoint = new(0, 0); @@ -137,6 +191,9 @@ public void FinishPath(List results) } } + /// + /// Resets the stroker state so it can be reused for a new path. + /// public void Reset() { this.srcVertices.Clear(); @@ -483,14 +540,14 @@ private void CalcMiter( switch (lj) { - case LineJoin.MiterJoinRevert: + case LineJoin.MiterRevert: this.AddPoint(v1.X + dx1, v1.Y - dy1); this.AddPoint(v1.X + dx2, v1.Y - dy2); break; - case LineJoin.MiterJoinRound: + case LineJoin.MiterRound: this.CalcArc(v1.X, v1.Y, dx1, -dy1, dx2, -dy2); break; @@ -629,19 +686,19 @@ private void CalcJoin(ref VertexDistance v0, ref VertexDistance v1, ref VertexDi break; - case InnerJoin.InnerMiter: - this.CalcMiter(ref v0, ref v1, ref v2, dx1, dy1, dx2, dy2, LineJoin.MiterJoinRevert, limit, 0); + case InnerJoin.Miter: + this.CalcMiter(ref v0, ref v1, ref v2, dx1, dy1, dx2, dy2, LineJoin.MiterRevert, limit, 0); break; - case InnerJoin.InnerJag: - case InnerJoin.InnerRound: + case InnerJoin.Jag: + case InnerJoin.Round: cp = ((dx1 - dx2) * (dx1 - dx2)) + ((dy1 - dy2) * (dy1 - dy2)); if (cp < len1 * len1 && cp < len2 * len2) { - this.CalcMiter(ref v0, ref v1, ref v2, dx1, dy1, dx2, dy2, LineJoin.MiterJoinRevert, limit, 0); + this.CalcMiter(ref v0, ref v1, ref v2, dx1, dy1, dx2, dy2, LineJoin.MiterRevert, limit, 0); } - else if (this.InnerJoin == InnerJoin.InnerJag) + else if (this.InnerJoin == InnerJoin.Jag) { this.AddPoint(v1.X + dx1, v1.Y - dy1); this.AddPoint(v1.X, v1.Y); @@ -665,7 +722,7 @@ private void CalcJoin(ref VertexDistance v0, ref VertexDistance v1, ref VertexDi double dy = (dy1 + dy2) / 2; double dbevel = Math.Sqrt((dx * dx) + (dy * dy)); - if (this.LineJoin is LineJoin.RoundJoin or LineJoin.BevelJoin && this.ApproximationScale * (this.widthAbs - dbevel) < this.widthEps) + if (this.LineJoin is LineJoin.Round or LineJoin.Bevel && this.ApproximationScale * (this.widthAbs - dbevel) < this.widthEps) { if (UtilityMethods.CalcIntersection(v0.X + dx1, v0.Y - dy1, v1.X + dx1, v1.Y - dy1, v1.X + dx2, v1.Y - dy2, v2.X + dx2, v2.Y - dy2, ref dx, ref dy)) { @@ -681,14 +738,14 @@ private void CalcJoin(ref VertexDistance v0, ref VertexDistance v1, ref VertexDi switch (this.LineJoin) { - case LineJoin.MiterJoin: - case LineJoin.MiterJoinRevert: - case LineJoin.MiterJoinRound: + case LineJoin.Miter: + case LineJoin.MiterRevert: + case LineJoin.MiterRound: this.CalcMiter(ref v0, ref v1, ref v2, dx1, dy1, dx2, dy2, this.LineJoin, this.MiterLimit, dbevel); break; - case LineJoin.RoundJoin: + case LineJoin.Round: this.CalcArc(v1.X, v1.Y, dx1, -dy1, dx2, -dy2); break; diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs index 917ccb96..f04f9a04 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/StrokedShapeGenerator.cs @@ -2,8 +2,8 @@ // Licensed under the Six Labors Split License. using System.Numerics; +using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Drawing.Utilities; -using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; @@ -17,15 +17,8 @@ internal sealed class StrokedShapeGenerator /// /// Initializes a new instance of the class. /// - /// meter limit - /// arc tolerance - public StrokedShapeGenerator(float meterLimit = 2F, float arcTolerance = .25F) - { - // TODO: We need to consume the joint type properties here. - // to do so we need to replace the existing ones with our new enums and update - // the overloads and pens. - this.polygonStroker = new PolygonStroker(); - } + public StrokedShapeGenerator(StrokeOptions options) + => this.polygonStroker = new PolygonStroker(options); /// /// Strokes a collection of dashed polyline spans and returns a merged outline. @@ -43,24 +36,17 @@ public StrokedShapeGenerator(float meterLimit = 2F, float arcTolerance = .25F) /// /// /// This method streams each dashed span through the internal stroker as an open polyline, - /// producing closed stroke rings. To clean self overlaps, the rings are split between - /// subject and clip sets and a is performed. - /// The split ensures at least two operands so the union resolves overlaps. + /// producing closed stroke rings. To clean self overlaps, all rings are added as subject + /// paths and a is performed. /// The union uses to preserve winding density. /// public IPath[] GenerateStrokedShapes(List spans, float width) { - // PolygonClipper is not designed to clean up self-intersecting geometry within a single polygon. - // It operates strictly on two polygon operands (subject and clip) and only resolves overlaps - // between them. To force cleanup of dashed stroke overlaps, we alternate assigning each - // stroked segment to subject or clip, ensuring at least two operands exist so the union - // operation performs a true merge rather than a no-op on a single polygon. - // 1) Stroke each dashed span as open. this.polygonStroker.Width = width; List ringPoints = new(spans.Count); - List rings = new(spans.Count); + List rings = new(spans.Count); foreach (PointF[] span in spans) { if (span == null || span.Length < 2) @@ -89,37 +75,10 @@ public IPath[] GenerateStrokedShapes(List spans, float width) return count == 1 ? [rings[0]] : [.. rings]; } - // 2) Partition so the first and last are on different polygons - List subjectRings = new(count); - List clipRings = new(count); - - // First => subject - subjectRings.Add(rings[0]); - - // Middle by alternation using a single bool flag - bool assignToSubject = false; // start with clip for i=1 - for (int i = 1; i < count - 1; i++) - { - if (assignToSubject) - { - subjectRings.Add(rings[i]); - } - else - { - clipRings.Add(rings[i]); - } - - assignToSubject = !assignToSubject; - } - - // Last => opposite of first (i.e., clip) - clipRings.Add(rings[count - 1]); - - // 3) Union subject vs clip + // 2) Union all rings as subject paths ClippedShapeGenerator clipper = new(IntersectionRule.NonZero); - clipper.AddPaths(subjectRings, ClippingType.Subject); - clipper.AddPaths(clipRings, ClippingType.Clip); - return clipper.GenerateClippedShapes(BooleanOperation.Union); + clipper.AddPaths(rings, ClippingType.Subject); + return clipper.GenerateClippedShapes(BooleanOperation.Union, true); } /// @@ -135,16 +94,15 @@ public IPath[] GenerateStrokedShapes(List spans, float width) /// /// Each flattened simple path is streamed through the internal stroker as open or closed /// according to . The resulting stroke rings are split - /// between subject and clip sets and combined using . - /// This split is required because the Martinez based clipper resolves overlaps only between - /// two operands. Using preserves fill across overlaps - /// and prevents unintended holes in the merged outline. + /// paths and combined using . Using + /// preserves fill across overlaps and prevents + /// unintended holes in the merged outline. /// public IPath[] GenerateStrokedShapes(IPath path, float width) { // 1) Stroke the input path into closed rings List ringPoints = []; - List rings = []; + List rings = []; this.polygonStroker.Width = width; foreach (ISimplePath p in path.Flatten()) @@ -170,43 +128,12 @@ public IPath[] GenerateStrokedShapes(IPath path, float width) return count == 1 ? [rings[0]] : [.. rings]; } - // 2) Partition so the first and last are on different polygons - // PolygonClipper is not designed to clean up self-intersecting geometry within a single polygon. - // It operates strictly on two polygon operands (subject and clip) and only resolves overlaps - // between them. To force cleanup of overlaps, we alternate assigning each stroked ring to - // subject or clip, ensuring at least two operands exist so the union performs a true merge. - List subjectRings = new(count); - List clipRings = new(count); - - // First => subject - subjectRings.Add(rings[0]); - - // Middle by alternation using a single bool flag - bool assignToSubject = false; // start with clip for i=1 - for (int i = 1; i < count - 1; i++) - { - if (assignToSubject) - { - subjectRings.Add(rings[i]); - } - else - { - clipRings.Add(rings[i]); - } - - assignToSubject = !assignToSubject; - } - - // Last => opposite of first (i.e., clip) - clipRings.Add(rings[count - 1]); - - // 3) Union subject vs clip + // 2) Union all rings as subject paths ClippedShapeGenerator clipper = new(IntersectionRule.NonZero); - clipper.AddPaths(subjectRings, ClippingType.Subject); - clipper.AddPaths(clipRings, ClippingType.Clip); + clipper.AddPaths(rings, ClippingType.Subject); - // 4) Return the cleaned, merged outline - return clipper.GenerateClippedShapes(BooleanOperation.Union); + // 3) Return the cleaned, merged outline + return clipper.GenerateClippedShapes(BooleanOperation.Union, true); } /// diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexFlags.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexFlags.cs new file mode 100644 index 00000000..fd038b4a --- /dev/null +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/VertexFlags.cs @@ -0,0 +1,14 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; + +[Flags] +internal enum VertexFlags +{ + None = 0, + OpenStart = 1, + OpenEnd = 2, + LocalMax = 4, + LocalMin = 8 +} diff --git a/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs b/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs index 8734440e..2966e3f7 100644 --- a/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs +++ b/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs @@ -6,7 +6,6 @@ using SixLabors.Fonts; using SixLabors.Fonts.Rendering; using SixLabors.ImageSharp.Drawing.Processing; -using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Text; @@ -221,7 +220,7 @@ void IGlyphRenderer.EndLayer() ShapeOptions options = new() { - ClippingOperation = BooleanOperation.Intersection, + BooleanOperation = BooleanOperation.Intersection, IntersectionRule = TextUtilities.MapFillRule(this.currentLayerFillRule) }; diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/DrawLinesTests.cs b/tests/ImageSharp.Drawing.Tests/Drawing/DrawLinesTests.cs index 375fb2d5..b2ba8752 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/DrawLinesTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/DrawLinesTests.cs @@ -99,7 +99,10 @@ public void DrawLines_EndCapRound(TestImageProvider provider, st where TPixel : unmanaged, IPixel { Color color = TestUtils.GetColorByName(colorName).WithAlpha(alpha); - PatternPen pen = new(new PenOptions(color, thickness, [3f, 3f]) { EndCapStyle = EndCapStyle.Round }); + PatternPen pen = new(new PenOptions(color, thickness, [3f, 3f]) + { + StrokeOptions = new StrokeOptions { LineCap = LineCap.Round }, + }); DrawLinesImpl(provider, colorName, alpha, thickness, antialias, pen); } @@ -110,7 +113,10 @@ public void DrawLines_EndCapButt(TestImageProvider provider, str where TPixel : unmanaged, IPixel { Color color = TestUtils.GetColorByName(colorName).WithAlpha(alpha); - PatternPen pen = new(new PenOptions(color, thickness, [3f, 3f]) { EndCapStyle = EndCapStyle.Butt }); + PatternPen pen = new(new PenOptions(color, thickness, [3f, 3f]) + { + StrokeOptions = new StrokeOptions { LineCap = LineCap.Butt }, + }); DrawLinesImpl(provider, colorName, alpha, thickness, antialias, pen); } @@ -121,7 +127,10 @@ public void DrawLines_EndCapSquare(TestImageProvider provider, s where TPixel : unmanaged, IPixel { Color color = TestUtils.GetColorByName(colorName).WithAlpha(alpha); - PatternPen pen = new(new PenOptions(color, thickness, [3f, 3f]) { EndCapStyle = EndCapStyle.Square }); + PatternPen pen = new(new PenOptions(color, thickness, [3f, 3f]) + { + StrokeOptions = new StrokeOptions { LineCap = LineCap.Square }, + }); DrawLinesImpl(provider, colorName, alpha, thickness, antialias, pen); } @@ -132,7 +141,10 @@ public void DrawLines_JointStyleRound(TestImageProvider provider where TPixel : unmanaged, IPixel { Color color = TestUtils.GetColorByName(colorName).WithAlpha(alpha); - SolidPen pen = new(new PenOptions(color, thickness) { JointStyle = JointStyle.Round }); + SolidPen pen = new(new PenOptions(color, thickness) + { + StrokeOptions = new StrokeOptions { LineJoin = LineJoin.Round }, + }); DrawLinesImpl(provider, colorName, alpha, thickness, antialias, pen); } @@ -143,7 +155,10 @@ public void DrawLines_JointStyleSquare(TestImageProvider provide where TPixel : unmanaged, IPixel { Color color = TestUtils.GetColorByName(colorName).WithAlpha(alpha); - SolidPen pen = new(new PenOptions(color, thickness) { JointStyle = JointStyle.Square }); + SolidPen pen = new(new PenOptions(color, thickness) + { + StrokeOptions = new StrokeOptions { LineJoin = LineJoin.Bevel }, + }); DrawLinesImpl(provider, colorName, alpha, thickness, antialias, pen); } @@ -154,7 +169,10 @@ public void DrawLines_JointStyleMiter(TestImageProvider provider where TPixel : unmanaged, IPixel { Color color = TestUtils.GetColorByName(colorName).WithAlpha(alpha); - SolidPen pen = new(new PenOptions(color, thickness) { JointStyle = JointStyle.Miter }); + SolidPen pen = new(new PenOptions(color, thickness) + { + StrokeOptions = new StrokeOptions { LineJoin = LineJoin.Miter }, + }); DrawLinesImpl(provider, colorName, alpha, thickness, antialias, pen); } diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs b/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs index f4f45bc9..b86078fc 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/FillPolygonTests.cs @@ -6,7 +6,6 @@ using SixLabors.ImageSharp.Drawing.Tests.TestUtilities.ImageComparison; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; -using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Tests.Drawing; @@ -192,7 +191,7 @@ public void FillPolygon_StarCircle_AllOperations(TestImageProvider provi // See http://www.angusj.com/clipper2/Docs/Units/Clipper/Types/ClipType.htm for reference. foreach (BooleanOperation operation in (BooleanOperation[])Enum.GetValues(typeof(BooleanOperation))) { - ShapeOptions options = new() { ClippingOperation = operation }; + ShapeOptions options = new() { BooleanOperation = operation }; IPath shape = star.Clip(options, circle); provider.RunValidatingProcessorTest( diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawBezier.cs b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawBezier.cs index ef268520..91d56671 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawBezier.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawBezier.cs @@ -121,8 +121,8 @@ public void JointAndEndCapStyle() Assert.NotEqual(this.shapeOptions, processor.Options.ShapeOptions); this.VerifyPoints(this.points, processor.Path); SolidPen processorPen = Assert.IsType(processor.Pen); - Assert.Equal(this.pen.JointStyle, processorPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, processorPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, processorPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, processorPen.StrokeOptions.LineCap); } [Fact] @@ -135,7 +135,7 @@ public void JointAndEndCapStyleDefaultOptions() Assert.Equal(this.shapeOptions, processor.Options.ShapeOptions); this.VerifyPoints(this.points, processor.Path); SolidPen processorPen = Assert.IsType(processor.Pen); - Assert.Equal(this.pen.JointStyle, processorPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, processorPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, processorPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, processorPen.StrokeOptions.LineCap); } } diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawLine.cs b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawLine.cs index 6cdb5c25..5ab5ae86 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawLine.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawLine.cs @@ -117,8 +117,8 @@ public void JointAndEndCapStyle() Assert.NotEqual(this.shapeOptions, processor.Options.ShapeOptions); this.VerifyPoints(this.points, processor.Path); SolidPen processorPen = Assert.IsType(processor.Pen); - Assert.Equal(this.pen.JointStyle, processorPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, processorPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, processorPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, processorPen.StrokeOptions.LineCap); } [Fact] @@ -131,7 +131,7 @@ public void JointAndEndCapStyleDefaultOptions() Assert.Equal(this.shapeOptions, processor.Options.ShapeOptions); this.VerifyPoints(this.points, processor.Path); SolidPen processorPen = Assert.IsType(processor.Pen); - Assert.Equal(this.pen.JointStyle, processorPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, processorPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, processorPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, processorPen.StrokeOptions.LineCap); } } diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPath.cs b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPath.cs index d57bf36d..8c283ed2 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPath.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPath.cs @@ -104,8 +104,8 @@ public void JointAndEndCapStyle() Assert.NotEqual(this.shapeOptions, processor.Options.ShapeOptions); Assert.Equal(this.path, processor.Path); SolidPen processorPen = Assert.IsType(processor.Pen); - Assert.Equal(this.pen.JointStyle, processorPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, processorPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, processorPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, processorPen.StrokeOptions.LineCap); } [Fact] @@ -118,7 +118,7 @@ public void JointAndEndCapStyleDefaultOptions() Assert.Equal(this.shapeOptions, processor.Options.ShapeOptions); Assert.Equal(this.path, processor.Path); SolidPen processorPen = Assert.IsType(processor.Pen); - Assert.Equal(this.pen.JointStyle, processorPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, processorPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, processorPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, processorPen.StrokeOptions.LineCap); } } diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPathCollection.cs b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPathCollection.cs index df0bbf1f..cb104bbb 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPathCollection.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPathCollection.cs @@ -10,8 +10,6 @@ namespace SixLabors.ImageSharp.Drawing.Tests.Drawing.Paths; public class DrawPathCollection : BaseImageOperationsExtensionTest { - private readonly GraphicsOptions nonDefault = new() { Antialias = false }; - private readonly Color color = Color.HotPink; private readonly SolidPen pen = Pens.Solid(Color.HotPink, 1); private readonly IPath path1 = new Path(new LinearLineSegment( [ @@ -162,8 +160,8 @@ public void JointAndEndCapStyle() { Assert.NotEqual(this.shapeOptions, p.Options.ShapeOptions); SolidPen pPen = Assert.IsType(p.Pen); - Assert.Equal(this.pen.JointStyle, pPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, pPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, pPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, pPen.StrokeOptions.LineCap); }); Assert.Collection( @@ -182,8 +180,8 @@ public void JointAndEndCapStyleDefaultOptions() { Assert.Equal(this.shapeOptions, p.Options.ShapeOptions); SolidPen pPen = Assert.IsType(p.Pen); - Assert.Equal(this.pen.JointStyle, pPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, pPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, pPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, pPen.StrokeOptions.LineCap); }); Assert.Collection( diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPolygon.cs b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPolygon.cs index 0b6900cc..fbc3cbee 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPolygon.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawPolygon.cs @@ -19,7 +19,7 @@ public class DrawPolygon : BaseImageOperationsExtensionTest new PointF(25, 10) ]; - private void VerifyPoints(PointF[] expectedPoints, IPath path) + private static void VerifyPoints(PointF[] expectedPoints, IPath path) { ISimplePath simplePath = Assert.Single(path.Flatten()); Assert.True(simplePath.IsClosed); @@ -34,7 +34,7 @@ public void Pen() DrawPathProcessor processor = this.Verify(); Assert.NotEqual(this.shapeOptions, processor.Options.ShapeOptions); - this.VerifyPoints(this.points, processor.Path); + VerifyPoints(this.points, processor.Path); Assert.Equal(this.pen, processor.Pen); } @@ -46,7 +46,7 @@ public void PenDefaultOptions() DrawPathProcessor processor = this.Verify(); Assert.Equal(this.shapeOptions, processor.Options.ShapeOptions); - this.VerifyPoints(this.points, processor.Path); + VerifyPoints(this.points, processor.Path); Assert.Equal(this.pen, processor.Pen); } @@ -58,7 +58,7 @@ public void BrushAndThickness() DrawPathProcessor processor = this.Verify(); Assert.NotEqual(this.shapeOptions, processor.Options.ShapeOptions); - this.VerifyPoints(this.points, processor.Path); + VerifyPoints(this.points, processor.Path); SolidPen processorPen = Assert.IsType(processor.Pen); Assert.Equal(this.pen.StrokeFill, processorPen.StrokeFill); Assert.Equal(10, processorPen.StrokeWidth); @@ -72,7 +72,7 @@ public void BrushAndThicknessDefaultOptions() DrawPathProcessor processor = this.Verify(); Assert.Equal(this.shapeOptions, processor.Options.ShapeOptions); - this.VerifyPoints(this.points, processor.Path); + VerifyPoints(this.points, processor.Path); SolidPen processorPen = Assert.IsType(processor.Pen); Assert.Equal(this.pen.StrokeFill, processorPen.StrokeFill); Assert.Equal(10, processorPen.StrokeWidth); @@ -86,7 +86,7 @@ public void ColorAndThickness() DrawPathProcessor processor = this.Verify(); Assert.NotEqual(this.shapeOptions, processor.Options.ShapeOptions); - this.VerifyPoints(this.points, processor.Path); + VerifyPoints(this.points, processor.Path); SolidBrush brush = Assert.IsType(processor.Pen.StrokeFill); SolidPen processorPen = Assert.IsType(processor.Pen); Assert.Equal(Color.Red, brush.Color); @@ -101,7 +101,7 @@ public void ColorAndThicknessDefaultOptions() DrawPathProcessor processor = this.Verify(); Assert.Equal(this.shapeOptions, processor.Options.ShapeOptions); - this.VerifyPoints(this.points, processor.Path); + VerifyPoints(this.points, processor.Path); SolidBrush brush = Assert.IsType(processor.Pen.StrokeFill); Assert.Equal(Color.Red, brush.Color); SolidPen processorPen = Assert.IsType(processor.Pen); @@ -116,10 +116,10 @@ public void JointAndEndCapStyle() DrawPathProcessor processor = this.Verify(); Assert.NotEqual(this.shapeOptions, processor.Options.ShapeOptions); - this.VerifyPoints(this.points, processor.Path); + VerifyPoints(this.points, processor.Path); SolidPen processorPen = Assert.IsType(processor.Pen); - Assert.Equal(this.pen.JointStyle, processorPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, processorPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, processorPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, processorPen.StrokeOptions.LineCap); } [Fact] @@ -130,9 +130,9 @@ public void JointAndEndCapStyleDefaultOptions() DrawPathProcessor processor = this.Verify(); Assert.Equal(this.shapeOptions, processor.Options.ShapeOptions); - this.VerifyPoints(this.points, processor.Path); + VerifyPoints(this.points, processor.Path); SolidPen processorPen = Assert.IsType(processor.Pen); - Assert.Equal(this.pen.JointStyle, processorPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, processorPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, processorPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, processorPen.StrokeOptions.LineCap); } } diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawRectangle.cs b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawRectangle.cs index b40b41c1..5e5ed330 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawRectangle.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/Paths/DrawRectangle.cs @@ -112,7 +112,7 @@ public void JointAndEndCapStyle() Assert.True(RectangularPolygonValueComparer.Equals(this.RectanglePolygon, processor.Path)); Assert.NotEqual(this.pen, processor.Pen); SolidPen processorPen = Assert.IsType(processor.Pen); - Assert.Equal(this.pen.JointStyle, processorPen.JointStyle); - Assert.Equal(this.pen.EndCapStyle, processorPen.EndCapStyle); + Assert.Equal(this.pen.StrokeOptions.LineJoin, processorPen.StrokeOptions.LineJoin); + Assert.Equal(this.pen.StrokeOptions.LineCap, processorPen.StrokeOptions.LineCap); } } diff --git a/tests/ImageSharp.Drawing.Tests/Issues/Issue_323.cs b/tests/ImageSharp.Drawing.Tests/Issues/Issue_323.cs index 631058a5..c77648fe 100644 --- a/tests/ImageSharp.Drawing.Tests/Issues/Issue_323.cs +++ b/tests/ImageSharp.Drawing.Tests/Issues/Issue_323.cs @@ -22,19 +22,19 @@ public void DrawPolygonMustDrawoutlineOnly(TestImageProvider pro x => x.DrawPolygon( color, scale, - new PointF[] { + [ new(5, 5), new(5, 150), new(190, 150), - }), + ]), new { scale }); } [Theory] - [WithSolidFilledImages(300, 300, "White", PixelTypes.Rgba32, 3f)] - [WithSolidFilledImages(300, 300, "White", PixelTypes.Rgba32, 1f)] - [WithSolidFilledImages(300, 300, "White", PixelTypes.Rgba32, 0.3f)] - [WithSolidFilledImages(300, 300, "White", PixelTypes.Rgba32, 0.7f)] + //[WithSolidFilledImages(300, 300, "White", PixelTypes.Rgba32, 3f)] + //[WithSolidFilledImages(300, 300, "White", PixelTypes.Rgba32, 1f)] + //[WithSolidFilledImages(300, 300, "White", PixelTypes.Rgba32, 0.3f)] + //[WithSolidFilledImages(300, 300, "White", PixelTypes.Rgba32, 0.7f)] [WithSolidFilledImages(300, 300, "White", PixelTypes.Rgba32, 0.003f)] public void DrawPolygonMustDrawoutlineOnly_Pattern(TestImageProvider provider, float scale) where TPixel : unmanaged, IPixel @@ -44,11 +44,11 @@ public void DrawPolygonMustDrawoutlineOnly_Pattern(TestImageProvider x.DrawPolygon( pen, - new PointF[] { - new(5, 5), - new(5, 150), - new(190, 150), - }), + [ + new(5, 5), + new(5, 150), + new(190, 150), + ]), new { scale }); } } diff --git a/tests/ImageSharp.Drawing.Tests/Processing/ShapeOptionsDefaultsExtensionsTests.cs b/tests/ImageSharp.Drawing.Tests/Processing/ShapeOptionsDefaultsExtensionsTests.cs index 1853828e..766efb0e 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/ShapeOptionsDefaultsExtensionsTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/ShapeOptionsDefaultsExtensionsTests.cs @@ -3,7 +3,6 @@ using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.PixelFormats; -using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Tests.Processing; @@ -28,7 +27,7 @@ public void UpdateDefaultOptionsOnProcessingContext_AlwaysNewInstance() { ShapeOptions option = new() { - ClippingOperation = BooleanOperation.Intersection, + BooleanOperation = BooleanOperation.Intersection, IntersectionRule = IntersectionRule.NonZero }; Configuration config = new(); @@ -37,18 +36,18 @@ public void UpdateDefaultOptionsOnProcessingContext_AlwaysNewInstance() context.SetShapeOptions(o => { - Assert.Equal(BooleanOperation.Intersection, o.ClippingOperation); // has original values + Assert.Equal(BooleanOperation.Intersection, o.BooleanOperation); // has original values Assert.Equal(IntersectionRule.NonZero, o.IntersectionRule); - o.ClippingOperation = BooleanOperation.Xor; + o.BooleanOperation = BooleanOperation.Xor; o.IntersectionRule = IntersectionRule.EvenOdd; }); ShapeOptions returnedOption = context.GetShapeOptions(); - Assert.Equal(BooleanOperation.Xor, returnedOption.ClippingOperation); + Assert.Equal(BooleanOperation.Xor, returnedOption.BooleanOperation); Assert.Equal(IntersectionRule.EvenOdd, returnedOption.IntersectionRule); - Assert.Equal(BooleanOperation.Intersection, option.ClippingOperation); // hasn't been mutated + Assert.Equal(BooleanOperation.Intersection, option.BooleanOperation); // hasn't been mutated Assert.Equal(IntersectionRule.NonZero, option.IntersectionRule); } @@ -68,7 +67,7 @@ public void UpdateDefaultOptionsOnConfiguration_AlwaysNewInstance() { ShapeOptions option = new() { - ClippingOperation = BooleanOperation.Intersection, + BooleanOperation = BooleanOperation.Intersection, IntersectionRule = IntersectionRule.NonZero }; Configuration config = new(); @@ -76,16 +75,16 @@ public void UpdateDefaultOptionsOnConfiguration_AlwaysNewInstance() config.SetShapeOptions(o => { - Assert.Equal(BooleanOperation.Intersection, o.ClippingOperation); // has original values + Assert.Equal(BooleanOperation.Intersection, o.BooleanOperation); // has original values Assert.Equal(IntersectionRule.NonZero, o.IntersectionRule); - o.ClippingOperation = BooleanOperation.Xor; + o.BooleanOperation = BooleanOperation.Xor; o.IntersectionRule = IntersectionRule.EvenOdd; }); ShapeOptions returnedOption = config.GetShapeOptions(); - Assert.Equal(BooleanOperation.Xor, returnedOption.ClippingOperation); + Assert.Equal(BooleanOperation.Xor, returnedOption.BooleanOperation); Assert.Equal(IntersectionRule.EvenOdd, returnedOption.IntersectionRule); - Assert.Equal(BooleanOperation.Intersection, option.ClippingOperation); // hasn't been mutated + Assert.Equal(BooleanOperation.Intersection, option.BooleanOperation); // hasn't been mutated Assert.Equal(IntersectionRule.NonZero, option.IntersectionRule); } diff --git a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs index 6a48e823..c8a84d34 100644 --- a/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Shapes/PolygonClipper/ClipperTests.cs @@ -4,7 +4,6 @@ using System.Numerics; using SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; using SixLabors.ImageSharp.Drawing.Tests.TestUtilities; -using SixLabors.PolygonClipper; namespace SixLabors.ImageSharp.Drawing.Tests.PolygonClipper; From 52312a1e5ae007cdf1921459c9211259ba937819 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 5 Feb 2026 14:12:30 +1000 Subject: [PATCH 13/18] Clean PolygonClipperUtilities --- .../Shapes/PolygonGeometry/PolygonClipper.cs | 38 ++--- ...perUtils.cs => PolygonClipperUtilities.cs} | 152 ++++++------------ 2 files changed, 64 insertions(+), 126 deletions(-) rename src/ImageSharp.Drawing/Shapes/PolygonGeometry/{ClipperUtils.cs => PolygonClipperUtilities.cs} (54%) diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs index cd4c4363..4cb04247 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs @@ -153,7 +153,7 @@ private void DisposeIntersectNodes() [MethodImpl(MethodImplOptions.AggressiveInlining)] private void AddNewIntersectNode(Active ae1, Active ae2, float topY) { - if (!ClipperUtils.GetIntersectPt(ae1.Bot, ae1.Top, ae2.Bot, ae2.Top, out Vector2 ip)) + if (!PolygonClipperUtilities.GetLineIntersectPoint(ae1.Bot, ae1.Top, ae2.Bot, ae2.Top, out Vector2 ip)) { ip = new Vector2(ae1.CurX, topY); } @@ -168,20 +168,20 @@ private void AddNewIntersectNode(Active ae1, Active ae2, float topY) { if (absDx1 > absDx2) { - ip = ClipperUtils.GetClosestPtOnSegment(ip, ae1.Bot, ae1.Top); + ip = PolygonClipperUtilities.GetClosestPtOnSegment(ip, ae1.Bot, ae1.Top); } else { - ip = ClipperUtils.GetClosestPtOnSegment(ip, ae2.Bot, ae2.Top); + ip = PolygonClipperUtilities.GetClosestPtOnSegment(ip, ae2.Bot, ae2.Top); } } else if (absDx1 > 100) { - ip = ClipperUtils.GetClosestPtOnSegment(ip, ae1.Bot, ae1.Top); + ip = PolygonClipperUtilities.GetClosestPtOnSegment(ip, ae1.Bot, ae1.Top); } else if (absDx2 > 100) { - ip = ClipperUtils.GetClosestPtOnSegment(ip, ae2.Bot, ae2.Top); + ip = PolygonClipperUtilities.GetClosestPtOnSegment(ip, ae2.Bot, ae2.Top); } else { @@ -989,8 +989,8 @@ private void CleanCollinear(OutRec outrec) do { // NB if preserveCollinear == true, then only remove 180 deg. spikes - if ((ClipperUtils.CrossProduct(op2.Prev.Point, op2.Point, op2.Next.Point) == 0) - && ((op2.Point == op2.Prev.Point) || (op2.Point == op2.Next.Point) || !this.PreserveCollinear || (ClipperUtils.DotProduct(op2.Prev.Point, op2.Point, op2.Next.Point) < 0))) + if ((PolygonClipperUtilities.CrossProduct(op2.Prev.Point, op2.Point, op2.Next.Point) == 0) + && ((op2.Point == op2.Prev.Point) || (op2.Point == op2.Next.Point) || !this.PreserveCollinear || (PolygonClipperUtilities.DotProduct(op2.Prev.Point, op2.Point, op2.Next.Point) < 0))) { if (op2 == outrec.Pts) { @@ -1024,7 +1024,7 @@ private void DoSplitOp(OutRec outrec, OutPt splitOp) OutPt nextNextOp = splitOp.Next.Next; outrec.Pts = prevOp; - ClipperUtils.GetIntersectPoint( + PolygonClipperUtilities.GetIntersectPoint( prevOp.Point, splitOp.Point, splitOp.Next.Point, nextNextOp.Point, out Vector2 ip); float area1 = Area(prevOp); @@ -1085,7 +1085,7 @@ private void FixSelfIntersects(OutRec outrec) // triangles can't self-intersect while (op2.Prev != op2.Next.Next) { - if (ClipperUtils.SegsIntersect(op2.Prev.Point, op2.Point, op2.Next.Point, op2.Next.Next.Point)) + if (PolygonClipperUtilities.SegsIntersect(op2.Prev.Point, op2.Point, op2.Next.Point, op2.Next.Next.Point)) { this.DoSplitOp(outrec, op2); if (outrec.Pts == null) @@ -2453,7 +2453,7 @@ private static bool IsValidAelOrder(Active resident, Active newcomer) } // get the turning direction a1.top, a2.bot, a2.top - float d = ClipperUtils.CrossProduct(resident.Top, newcomer.Bot, newcomer.Top); + float d = PolygonClipperUtilities.CrossProduct(resident.Top, newcomer.Bot, newcomer.Top); if (d != 0) { return d < 0; @@ -2465,12 +2465,12 @@ private static bool IsValidAelOrder(Active resident, Active newcomer) // the direction they're about to turn if (!IsMaxima(resident) && (resident.Top.Y > newcomer.Top.Y)) { - return ClipperUtils.CrossProduct(newcomer.Bot, resident.Top, NextVertex(resident).Point) <= 0; + return PolygonClipperUtilities.CrossProduct(newcomer.Bot, resident.Top, NextVertex(resident).Point) <= 0; } if (!IsMaxima(newcomer) && (newcomer.Top.Y > resident.Top.Y)) { - return ClipperUtils.CrossProduct(newcomer.Bot, newcomer.Top, NextVertex(newcomer).Point) >= 0; + return PolygonClipperUtilities.CrossProduct(newcomer.Bot, newcomer.Top, NextVertex(newcomer).Point) >= 0; } float y = newcomer.Bot.Y; @@ -2487,13 +2487,13 @@ private static bool IsValidAelOrder(Active resident, Active newcomer) return newcomerIsLeft; } - if (ClipperUtils.CrossProduct(PrevPrevVertex(resident).Point, resident.Bot, resident.Top) == 0) + if (PolygonClipperUtilities.CrossProduct(PrevPrevVertex(resident).Point, resident.Bot, resident.Top) == 0) { return true; } // compare turning direction of the alternate bound - return (ClipperUtils.CrossProduct(PrevPrevVertex(resident).Point, newcomer.Bot, PrevPrevVertex(newcomer).Point) > 0) == newcomerIsLeft; + return (PolygonClipperUtilities.CrossProduct(PrevPrevVertex(resident).Point, newcomer.Bot, PrevPrevVertex(newcomer).Point) > 0) == newcomerIsLeft; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -2933,7 +2933,7 @@ private void CheckJoinLeft(Active e, Vector2 pt, bool checkCurrX = false) if (checkCurrX) { - if (ClipperUtils.PerpendicDistFromLineSqrd(pt, prev.Bot, prev.Top) > 0.25) + if (PolygonClipperUtilities.PerpendicDistFromLineSqrd(pt, prev.Bot, prev.Top) > 0.25) { return; } @@ -2943,7 +2943,7 @@ private void CheckJoinLeft(Active e, Vector2 pt, bool checkCurrX = false) return; } - if (ClipperUtils.CrossProduct(e.Top, pt, prev.Top) != 0) + if (PolygonClipperUtilities.CrossProduct(e.Top, pt, prev.Top) != 0) { return; } @@ -2988,7 +2988,7 @@ private void CheckJoinRight(Active e, Vector2 pt, bool checkCurrX = false) if (checkCurrX) { - if (ClipperUtils.PerpendicDistFromLineSqrd(pt, next.Bot, next.Top) > 0.25) + if (PolygonClipperUtilities.PerpendicDistFromLineSqrd(pt, next.Bot, next.Top) > 0.25) { return; } @@ -2998,7 +2998,7 @@ private void CheckJoinRight(Active e, Vector2 pt, bool checkCurrX = false) return; } - if (ClipperUtils.CrossProduct(e.Top, pt, next.Top) != 0) + if (PolygonClipperUtilities.CrossProduct(e.Top, pt, next.Top) != 0) { return; } @@ -3383,7 +3383,7 @@ public PolyPathF AddChild(PathF p) [MethodImpl(MethodImplOptions.AggressiveInlining)] public float Area() { - float result = this.Polygon == null ? 0 : ClipperUtils.Area(this.Polygon); + float result = this.Polygon == null ? 0 : PolygonClipperUtilities.SignedArea(this.Polygon); for (int i = 0; i < this.items.Count; i++) { PolyPathF child = this.items[i]; diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperUtils.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipperUtilities.cs similarity index 54% rename from src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperUtils.cs rename to src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipperUtilities.cs index 6ed77da4..dae10d2d 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClipperUtils.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipperUtilities.cs @@ -6,18 +6,16 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; -internal static class ClipperUtils +internal static class PolygonClipperUtilities { - public const float DefaultArcTolerance = .25F; - public const float FloatingPointTolerance = 1e-05F; - public const float DefaultMinimumEdgeLength = .1F; - - // TODO: rename to Pow2? - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float Sqr(float value) => value * value; - + /// + /// Computes the signed area of a path using the shoelace formula. + /// + /// + /// Positive values indicate clockwise orientation in screen space. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float Area(PathF path) + public static float SignedArea(PathF path) { // https://en.wikipedia.org/wiki/Shoelace_formula float a = 0F; @@ -26,7 +24,8 @@ public static float Area(PathF path) return a; } - Vector2 prevPt = path[path.Count - 1]; + // Sum over edges (prev -> current). + Vector2 prevPt = path[^1]; for (int i = 0; i < path.Count; i++) { Vector2 pt = path[i]; @@ -37,87 +36,34 @@ public static float Area(PathF path) return a * .5F; } - public static PathF StripDuplicates(PathF path, bool isClosedPath) - { - int cnt = path.Count; - PathF result = new(cnt); - if (cnt == 0) - { - return result; - } - - PointF lastPt = path[0]; - result.Add(lastPt); - for (int i = 1; i < cnt; i++) - { - if (lastPt != path[i]) - { - lastPt = path[i]; - result.Add(lastPt); - } - } - - if (isClosedPath && lastPt == result[0]) - { - result.RemoveAt(result.Count - 1); - } - - return result; - } - - public static PathF Ellipse(Vector2 center, float radiusX, float radiusY = 0, int steps = 0) - { - if (radiusX <= 0) - { - return []; - } - - if (radiusY <= 0) - { - radiusY = radiusX; - } - - if (steps <= 2) - { - steps = (int)MathF.Ceiling(MathF.PI * MathF.Sqrt((radiusX + radiusY) * .5F)); - } - - float si = MathF.Sin(2 * MathF.PI / steps); - float co = MathF.Cos(2 * MathF.PI / steps); - float dx = co, dy = si; - PathF result = new(steps) { new Vector2(center.X + radiusX, center.Y) }; - Vector2 radiusXY = new(radiusX, radiusY); - for (int i = 1; i < steps; ++i) - { - result.Add(center + (radiusXY * new Vector2(dx, dy))); - float x = (dx * co) - (dy * si); - dy = (dy * co) + (dx * si); - dx = x; - } - - return result; - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static float DotProduct(Vector2 vec1, Vector2 vec2) => Vector2.Dot(vec1, vec2); + /// + /// Returns the dot product of the segments (pt1->pt2) and (pt2->pt3). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static float DotProduct(Vector2 pt1, Vector2 pt2, Vector2 pt3) + => Vector2.Dot(pt2 - pt1, pt3 - pt2); + + /// + /// Returns the 2D cross product magnitude of and . + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static float CrossProduct(Vector2 vec1, Vector2 vec2) => (vec1.Y * vec2.X) - (vec2.Y * vec1.X); + /// + /// Returns the cross product of the segments (pt1->pt2) and (pt2->pt3). + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static float CrossProduct(Vector2 pt1, Vector2 pt2, Vector2 pt3) => ((pt2.X - pt1.X) * (pt3.Y - pt2.Y)) - ((pt2.Y - pt1.Y) * (pt3.X - pt2.X)); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static float DotProduct(Vector2 pt1, Vector2 pt2, Vector2 pt3) - => Vector2.Dot(pt2 - pt1, pt3 - pt2); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool IsAlmostZero(float value) - => MathF.Abs(value) <= FloatingPointTolerance; - + /// + /// Returns the squared perpendicular distance from a point to a line segment. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static float PerpendicDistFromLineSqrd(Vector2 pt, Vector2 line1, Vector2 line2) { @@ -128,9 +74,18 @@ public static float PerpendicDistFromLineSqrd(Vector2 pt, Vector2 line1, Vector2 return 0; } - return Sqr(CrossProduct(cd, ab)) / DotProduct(cd, cd); + float cross = CrossProduct(cd, ab); + return (cross * cross) / DotProduct(cd, cd); } + /// + /// Returns true when two segments intersect. + /// + /// First endpoint of segment 1. + /// Second endpoint of segment 1. + /// First endpoint of segment 2. + /// Second endpoint of segment 2. + /// If true, allows shared endpoints; if false, requires a proper intersection. public static bool SegsIntersect(Vector2 seg1a, Vector2 seg1b, Vector2 seg2a, Vector2 seg2b, bool inclusive = false) { if (inclusive) @@ -158,26 +113,7 @@ public static bool SegsIntersect(Vector2 seg1a, Vector2 seg1b, Vector2 seg2a, Ve } [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static bool GetIntersectPt(Vector2 ln1a, Vector2 ln1b, Vector2 ln2a, Vector2 ln2b, out Vector2 ip) - { - Vector2 dxy1 = ln1b - ln1a; - Vector2 dxy2 = ln2b - ln2a; - float cp = CrossProduct(dxy1, dxy2); - if (cp == 0F) - { - ip = default; - return false; - } - - float qx = CrossProduct(ln1a, dxy1); - float qy = CrossProduct(ln2a, dxy2); - - ip = ((dxy1 * qy) - (dxy2 * qx)) / cp; - return ip != new Vector2(float.MaxValue); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool GetIntersectPoint(Vector2 ln1a, Vector2 ln1b, Vector2 ln2a, Vector2 ln2b, out Vector2 ip) + public static bool GetLineIntersectPoint(Vector2 ln1a, Vector2 ln1b, Vector2 ln2a, Vector2 ln2b, out Vector2 ip) { Vector2 dxy1 = ln1b - ln1a; Vector2 dxy2 = ln2b - ln2a; @@ -189,6 +125,8 @@ public static bool GetIntersectPoint(Vector2 ln1a, Vector2 ln1b, Vector2 ln2a, V } float t = (((ln1a.X - ln2a.X) * dxy2.Y) - ((ln1a.Y - ln2a.Y) * dxy2.X)) / det; + + // Clamp intersection to the segment endpoints. if (t <= 0F) { ip = ln1a; @@ -205,6 +143,12 @@ public static bool GetIntersectPoint(Vector2 ln1a, Vector2 ln1b, Vector2 ln2a, V return true; } + /// + /// Returns the closest point on a segment to an external point. + /// + /// The point to project onto the segment. + /// First endpoint of the segment. + /// Second endpoint of the segment. public static Vector2 GetClosestPtOnSegment(Vector2 offPt, Vector2 seg1, Vector2 seg2) { if (seg1 == seg2) @@ -227,10 +171,4 @@ public static Vector2 GetClosestPtOnSegment(Vector2 offPt, Vector2 seg1, Vector2 return seg1 + (dxy * q); } - - public static PathF ReversePath(PathF path) - { - path.Reverse(); - return path; - } } From 49aaf2583d893993143db50ed8793f259d1b70ed Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 5 Feb 2026 14:16:41 +1000 Subject: [PATCH 14/18] Fix build, more cleanup --- .../Shapes/PolygonGeometry/Clipper.cs | 111 ------------------ .../Shapes/PolygonGeometry/ClippingType.cs | 2 +- .../Shapes/PolygonGeometry/JoinWith.cs | 32 +++-- .../Shapes/PolygonGeometry/PolygonClipper.cs | 2 +- 4 files changed, 16 insertions(+), 131 deletions(-) delete mode 100644 src/ImageSharp.Drawing/Shapes/PolygonGeometry/Clipper.cs diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/Clipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/Clipper.cs deleted file mode 100644 index 71b26112..00000000 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/Clipper.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Six Labors. -// Licensed under the Six Labors Split License. - -namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; - -/// -/// Library to clip polygons. -/// -internal class Clipper -{ - private readonly PolygonClipper polygonClipper; - - /// - /// Initializes a new instance of the class. - /// - public Clipper() - => this.polygonClipper = new PolygonClipper() { PreserveCollinear = true }; - - /// - /// Generates the clipped shapes from the previously provided paths. - /// - /// The clipping operation. - /// The intersection rule. - /// The . - public IPath[] GenerateClippedShapes(BooleanOperation operation, IntersectionRule rule) - { - PathsF closedPaths = []; - PathsF openPaths = []; - - ClipperFillRule fillRule = rule == IntersectionRule.EvenOdd ? ClipperFillRule.EvenOdd : ClipperFillRule.NonZero; - this.polygonClipper.Execute(operation, fillRule, closedPaths, openPaths); - - IPath[] shapes = new IPath[closedPaths.Count + openPaths.Count]; - - int index = 0; - for (int i = 0; i < closedPaths.Count; i++) - { - PathF path = closedPaths[i]; - PointF[] points = new PointF[path.Count]; - - for (int j = 0; j < path.Count; j++) - { - points[j] = path[j]; - } - - shapes[index++] = new Polygon(points); - } - - for (int i = 0; i < openPaths.Count; i++) - { - PathF path = openPaths[i]; - PointF[] points = new PointF[path.Count]; - - for (int j = 0; j < path.Count; j++) - { - points[j] = path[j]; - } - - shapes[index++] = new Polygon(points); - } - - return shapes; - } - - /// - /// Adds the shapes. - /// - /// The paths. - /// The clipping type. - public void AddPaths(IEnumerable paths, ClippingType clippingType) - { - Guard.NotNull(paths, nameof(paths)); - - foreach (IPath p in paths) - { - this.AddPath(p, clippingType); - } - } - - /// - /// Adds the path. - /// - /// The path. - /// The clipping type. - public void AddPath(IPath path, ClippingType clippingType) - { - Guard.NotNull(path, nameof(path)); - - foreach (ISimplePath p in path.Flatten()) - { - this.AddPath(p, clippingType); - } - } - - /// - /// Adds the path. - /// - /// The path. - /// Type of the poly. - internal void AddPath(ISimplePath path, ClippingType clippingType) - { - ReadOnlySpan vectors = path.Points.Span; - PathF points = new(vectors.Length); - for (int i = 0; i < vectors.Length; i++) - { - points.Add(vectors[i]); - } - - this.polygonClipper.AddPath(points, clippingType, !path.IsClosed); - } -} diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippingType.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippingType.cs index f2e252f2..2ac4ef90 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippingType.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/ClippingType.cs @@ -6,7 +6,7 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; /// /// Defines the polygon clipping type. /// -public enum ClippingType +internal enum ClippingType { /// /// Represents a shape to act as a subject which will be clipped or merged. diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/JoinWith.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/JoinWith.cs index 83ca61ad..ee3272a8 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/JoinWith.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/JoinWith.cs @@ -3,27 +3,23 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; +/// +/// Specifies how a vertex should be joined with adjacent paths during polygon operations. +/// internal enum JoinWith { + /// + /// No joining operation. + /// None, - Left, - Right -} -internal enum HorzPosition -{ - Bottom, - Middle, - Top -} + /// + /// Join with the left adjacent path. + /// + Left, -// Vertex: a pre-clipping data structure. It is used to separate polygons -// into ascending and descending 'bounds' (or sides) that start at local -// minima and ascend to a local maxima, before descending again. -[Flags] -internal enum PointInPolygonResult -{ - IsOn = 0, - IsInside = 1, - IsOutside = 2 + /// + /// Join with the right adjacent path. + /// + Right } diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs index 4cb04247..e9101592 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs @@ -1024,7 +1024,7 @@ private void DoSplitOp(OutRec outrec, OutPt splitOp) OutPt nextNextOp = splitOp.Next.Next; outrec.Pts = prevOp; - PolygonClipperUtilities.GetIntersectPoint( + _ = PolygonClipperUtilities.GetLineIntersectPoint( prevOp.Point, splitOp.Point, splitOp.Next.Point, nextNextOp.Point, out Vector2 ip); float area1 = Area(prevOp); From 10066315aa1e41cb4a68a48017c213154a74a436 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 5 Feb 2026 14:55:07 +1000 Subject: [PATCH 15/18] Document PolygonClipper --- .../Shapes/PolygonGeometry/PolygonClipper.cs | 503 ++++++++++++++++-- 1 file changed, 464 insertions(+), 39 deletions(-) diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs index e9101592..f8a9fea8 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs @@ -14,24 +14,32 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; /// Ported from and originally licensed /// under /// +/// +/// This class implements the Vatti clipping algorithm using a scanline sweep approach. +/// It processes polygon edges by sweeping a horizontal line from bottom to top, +/// maintaining an active edge list (AEL) of edges that intersect the current scanline. +/// internal sealed class PolygonClipper { private BooleanOperation clipType; private ClipperFillRule fillRule; - private Active actives; - private Active flaggedHorizontal; - private readonly List minimaList; - private readonly List intersectList; - private readonly List vertexList; - private readonly List outrecList; - private readonly List scanlineList; - private readonly List horzSegList; - private readonly List horzJoinList; - private int currentLocMin; - private float currentBotY; - private bool isSortedMinimaList; - private bool hasOpenPaths; - + private Active actives; // Head of the active edge list + private Active flaggedHorizontal; // Linked list of horizontal edges awaiting processing + private readonly List minimaList; // Local minima sorted by Y coordinate + private readonly List intersectList; // Intersections at current scanbeam + private readonly List vertexList; // All vertices from input paths + private readonly List outrecList; // Output polygon records + private readonly List scanlineList; // Y coordinates requiring processing + private readonly List horzSegList; // Horizontal segments for joining + private readonly List horzJoinList; // Horizontal joins to process + private int currentLocMin; // Index of current local minimum being processed + private float currentBotY; // Y coordinate of current scanbeam bottom + private bool isSortedMinimaList; // Whether minimaList has been sorted + private bool hasOpenPaths; // Whether any input paths are open (not closed) + + /// + /// Initializes a new instance of the class. + /// public PolygonClipper() { this.minimaList = []; @@ -44,13 +52,31 @@ public PolygonClipper() this.PreserveCollinear = true; } + /// + /// Gets or sets a value indicating whether collinear vertices should be preserved in the output. + /// When true, only 180-degree spikes are removed. When false, all collinear vertices are removed. + /// public bool PreserveCollinear { get; set; } + /// + /// Gets or sets a value indicating whether the output polygon orientation should be reversed. + /// public bool ReverseSolution { get; set; } + /// + /// Adds subject paths to the clipping operation. + /// Subject paths are the primary polygons being clipped. + /// + /// The subject paths to add. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void AddSubject(PathsF paths) => this.AddPaths(paths, ClippingType.Subject); + /// + /// Adds a single path to the clipping operation. + /// + /// The path to add. + /// Whether this is a subject or clip path. + /// Whether the path is open (polyline) or closed (polygon). [MethodImpl(MethodImplOptions.AggressiveInlining)] public void AddPath(PathF path, ClippingType polytype, bool isOpen = false) { @@ -58,6 +84,12 @@ public void AddPath(PathF path, ClippingType polytype, bool isOpen = false) this.AddPaths(tmp, polytype, isOpen); } + /// + /// Adds multiple paths to the clipping operation. + /// + /// The paths to add. + /// Whether these are subject or clip paths. + /// Whether the paths are open (polylines) or closed (polygons). [MethodImpl(MethodImplOptions.AggressiveInlining)] public void AddPaths(PathsF paths, ClippingType polytype, bool isOpen = false) { @@ -70,10 +102,24 @@ public void AddPaths(PathsF paths, ClippingType polytype, bool isOpen = false) this.AddPathsToVertexList(paths, polytype, isOpen); } + /// + /// Executes the clipping operation and returns only closed paths. + /// + /// The boolean operation to perform. + /// The fill rule to use for polygon interiors. + /// Output collection for closed solution paths. [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Execute(BooleanOperation clipType, ClipperFillRule fillRule, PathsF solutionClosed) => this.Execute(clipType, fillRule, solutionClosed, []); + /// + /// Executes the clipping operation and returns both closed and open paths. + /// + /// The boolean operation to perform (union, intersection, difference, xor). + /// The fill rule to determine polygon interiors (even-odd, non-zero, positive, negative). + /// Output collection for closed solution paths (polygons). + /// Output collection for open solution paths (polylines). + /// Thrown when an error occurs during clipping. public void Execute(BooleanOperation clipType, ClipperFillRule fillRule, PathsF solutionClosed, PathsF solutionOpen) { solutionClosed.Clear(); @@ -94,11 +140,19 @@ public void Execute(BooleanOperation clipType, ClipperFillRule fillRule, PathsF } } + /// + /// Executes the core clipping algorithm using the Vatti scanbeam sweep. + /// Processes all edges from bottom to top, handling intersections and building output polygons. + /// + /// The boolean operation type. + /// The fill rule for determining polygon interiors. private void ExecuteInternal(BooleanOperation ct, ClipperFillRule fillRule) { this.fillRule = fillRule; this.clipType = ct; this.Reset(); + + // Get the first scanline Y coordinate if (!this.PopScanline(out float y)) { return; @@ -106,36 +160,53 @@ private void ExecuteInternal(BooleanOperation ct, ClipperFillRule fillRule) while (true) { + // Add local minima edges that start at current Y this.InsertLocalMinimaIntoAEL(y); + + // Process all horizontal edges at this Y Active ae; while (this.PopHorz(out ae)) { this.DoHorizontal(ae); } + // Convert horizontal segments to joins for later processing if (this.horzSegList.Count > 0) { this.ConvertHorzSegsToJoins(); this.horzSegList.Clear(); } - this.currentBotY = y; // bottom of scanbeam + this.currentBotY = y; // bottom of current scanbeam + + // Get next scanline; break if no more if (!this.PopScanline(out y)) { - break; // y new top of scanbeam + break; } + // Process intersections between current and next scanline this.DoIntersections(y); + + // Update edges at top of scanbeam this.DoTopOfScanbeam(y); + + // Process any horizontal edges that emerged while (this.PopHorz(out ae)) { this.DoHorizontal(ae!); } } + // Complete horizontal joins this.ProcessHorzJoins(); } + /// + /// Processes edge intersections at the top of the current scanbeam. + /// Builds intersection list, processes intersections in order, then cleans up. + /// + /// The Y coordinate of the top of the scanbeam. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void DoIntersections(float topY) { @@ -146,23 +217,37 @@ private void DoIntersections(float topY) } } + /// + /// Clears the intersection node list. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void DisposeIntersectNodes() => this.intersectList.Clear(); + /// + /// Adds a new intersection node for two edges at the specified Y coordinate. + /// Calculates the exact intersection point, adjusting for numerical precision when needed. + /// + /// First edge. + /// Second edge. + /// Top Y coordinate of the scanbeam. [MethodImpl(MethodImplOptions.AggressiveInlining)] private void AddNewIntersectNode(Active ae1, Active ae2, float topY) { + // Calculate line intersection point if (!PolygonClipperUtilities.GetLineIntersectPoint(ae1.Bot, ae1.Top, ae2.Bot, ae2.Top, out Vector2 ip)) { + // Lines are parallel; use current X position ip = new Vector2(ae1.CurX, topY); } + // Adjust intersection point if it's outside the scanbeam bounds if (ip.Y > this.currentBotY || ip.Y < topY) { float absDx1 = MathF.Abs(ae1.Dx); float absDx2 = MathF.Abs(ae2.Dx); + // For very steep edges, project the point onto the edge // TODO: Check threshold here once we remove upscaling. if (absDx1 > 100 && absDx2 > 100) { @@ -185,6 +270,7 @@ private void AddNewIntersectNode(Active ae1, Active ae2, float topY) } else { + // Clamp Y to scanbeam bounds if (ip.Y < topY) { ip.Y = topY; @@ -194,6 +280,7 @@ private void AddNewIntersectNode(Active ae1, Active ae2, float topY) ip.Y = this.currentBotY; } + // Use the less steep edge to determine X if (absDx1 < absDx2) { ip.X = TopX(ae1, ip.Y); @@ -209,6 +296,13 @@ private void AddNewIntersectNode(Active ae1, Active ae2, float topY) this.intersectList.Add(node); } + /// + /// Sets the heading direction for a horizontal segment based on two output points. + /// + /// The horizontal segment to configure. + /// Previous output point. + /// Next output point. + /// True if the segment has a valid direction; false if the points have the same X coordinate. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool SetHorzSegHeadingForward(HorzSegment hs, OutPt opP, OutPt opN) { @@ -233,6 +327,11 @@ private static bool SetHorzSegHeadingForward(HorzSegment hs, OutPt opP, OutPt op return true; } + /// + /// Updates a horizontal segment by extending it to include all consecutive horizontal output points. + /// + /// The horizontal segment to update. + /// True if the segment was successfully updated; false otherwise. private static bool UpdateHorzSegment(HorzSegment hs) { OutPt op = hs.LeftOp; @@ -240,6 +339,8 @@ private static bool UpdateHorzSegment(HorzSegment hs) bool outrecHasEdges = outrec.FrontEdge != null; float curr_y = op.Point.Y; OutPt opP = op, opN = op; + + // Extend the segment backwards and forwards along the horizontal line if (outrecHasEdges) { OutPt opA = outrec.Pts!, opZ = opA.Next; @@ -274,12 +375,18 @@ private static bool UpdateHorzSegment(HorzSegment hs) } else { - hs.RightOp = null; // (for sorting) + hs.RightOp = null; // Mark as invalid for sorting } return result; } + /// + /// Duplicates an output point, inserting it either after or before the original. + /// + /// The output point to duplicate. + /// If true, insert after op; otherwise insert before. + /// The newly created output point. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static OutPt DuplicateOp(OutPt op, bool insert_after) { @@ -302,9 +409,15 @@ private static OutPt DuplicateOp(OutPt op, bool insert_after) return result; } + /// + /// Converts horizontal segments into join operations. + /// Finds overlapping horizontal segments and creates joins between them. + /// private void ConvertHorzSegsToJoins() { int k = 0; + + // Update all segments and count valid ones foreach (HorzSegment hs in this.horzSegList) { if (UpdateHorzSegment(hs)) @@ -315,19 +428,23 @@ private void ConvertHorzSegsToJoins() if (k < 2) { - return; + return; // Need at least 2 segments to join } + // Sort segments by left X coordinate this.horzSegList.Sort(default(HorzSegSorter)); + // Find overlapping segments and create joins for (int i = 0; i < k - 1; i++) { HorzSegment hs1 = this.horzSegList[i]; - // for each HorzSegment, find others that overlap + // Check each subsequent segment for overlap for (int j = i + 1; j < k; j++) { HorzSegment hs2 = this.horzSegList[j]; + + // Skip if no overlap or same direction if ((hs2.LeftOp.Point.X >= hs1.RightOp.Point.X) || (hs2.LeftToRight == hs1.LeftToRight) || (hs2.RightOp.Point.X <= hs1.LeftOp.Point.X)) @@ -336,6 +453,8 @@ private void ConvertHorzSegsToJoins() } float curr_y = hs1.LeftOp.Point.Y; + + // Adjust segment endpoints to find join points if (hs1.LeftToRight) { while (hs1.LeftOp.Next.Point.Y == curr_y && @@ -374,6 +493,9 @@ private void ConvertHorzSegsToJoins() } } + /// + /// Clears the solution data while preserving input paths. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void ClearSolutionOnly() { @@ -389,6 +511,13 @@ private void ClearSolutionOnly() this.horzJoinList.Clear(); } + /// + /// Builds output paths from the output record list. + /// Processes each output record, cleaning collinear points and building the final paths. + /// + /// Collection to receive closed paths (polygons). + /// Collection to receive open paths (polylines). + /// True if paths were successfully built. private bool BuildPaths(PathsF solutionClosed, PathsF solutionOpen) { solutionClosed.Clear(); @@ -398,7 +527,7 @@ private bool BuildPaths(PathsF solutionClosed, PathsF solutionOpen) int i = 0; - // _outrecList.Count is not static here because + // Note: outrecList.Count is not static here because // CleanCollinear can indirectly add additional OutRec while (i < this.outrecList.Count) { @@ -418,9 +547,10 @@ private bool BuildPaths(PathsF solutionClosed, PathsF solutionOpen) } else { + // Clean collinear points from closed paths this.CleanCollinear(outrec); - // closed paths should always return a Positive orientation + // Closed paths should always return a positive orientation // except when ReverseSolution == true if (BuildPath(outrec.Pts, this.ReverseSolution, false, path)) { @@ -432,8 +562,17 @@ private bool BuildPaths(PathsF solutionClosed, PathsF solutionOpen) return true; } + /// + /// Builds a path from an output point list. + /// + /// Starting output point. + /// Whether to traverse the list in reverse. + /// Whether this is an open path. + /// The path to populate. + /// True if a valid path was created. private static bool BuildPath(OutPt op, bool reverse, bool isOpen, PathF path) { + // Validate minimum path requirements if (op == null || op.Next == op || (!isOpen && op.Next == op.Prev)) { return false; @@ -443,6 +582,8 @@ private static bool BuildPath(OutPt op, bool reverse, bool isOpen, PathF path) Vector2 lastPt; OutPt op2; + + // Set starting point and direction if (reverse) { lastPt = op.Point; @@ -457,6 +598,7 @@ private static bool BuildPath(OutPt op, bool reverse, bool isOpen, PathF path) path.Add(lastPt); + // Traverse the output point list, adding unique points while (op2 != op) { if (op2.Point != lastPt) @@ -475,6 +617,7 @@ private static bool BuildPath(OutPt op, bool reverse, bool isOpen, PathF path) } } + // Filter out very small triangles return path.Count != 3 || !IsVerySmallTriangle(op2); } @@ -3117,18 +3260,42 @@ private void Split(Active e, Vector2 currPt) private static bool IsFront(Active ae) => ae == ae.Outrec.FrontEdge; + /// + /// Comparer for sorting local minima by Y coordinate (descending). + /// private struct LocMinSorter : IComparer { public readonly int Compare(LocalMinima locMin1, LocalMinima locMin2) => locMin2.Vertex.Point.Y.CompareTo(locMin1.Vertex.Point.Y); } + /// + /// Represents a local minimum in a polygon path. + /// A local minimum is a vertex where the path changes from descending to ascending. + /// private readonly struct LocalMinima { + /// + /// Gets the vertex at the local minimum. + /// public readonly Vertex Vertex; + + /// + /// Gets the polygon type (subject or clip). + /// public readonly ClippingType Polytype; + + /// + /// Gets a value indicating whether this is an open path (polyline). + /// public readonly bool IsOpen; + /// + /// Initializes a new instance of the struct. + /// + /// The vertex at the local minimum. + /// The polygon type. + /// Whether this is an open path. public LocalMinima(Vertex vertex, ClippingType polytype, bool isOpen = false) { this.Vertex = vertex; @@ -3137,7 +3304,7 @@ public LocalMinima(Vertex vertex, ClippingType polytype, bool isOpen = false) } public static bool operator ==(LocalMinima lm1, LocalMinima lm2) - + // Use reference equality for vertex comparison // TODO: Check this. Why ref equals. => ReferenceEquals(lm1.Vertex, lm2.Vertex); @@ -3151,15 +3318,34 @@ public override int GetHashCode() => this.Vertex.GetHashCode(); } - // IntersectNode: a structure representing 2 intersecting edges. - // Intersections must be sorted so they are processed from the largest - // Y coordinates to the smallest while keeping edges adjacent. + /// + /// Represents an intersection between two active edges. + /// Intersections must be sorted so they are processed from the largest + /// Y coordinates to the smallest while keeping edges adjacent. + /// private readonly struct IntersectNode { + /// + /// Gets the intersection point. + /// public readonly Vector2 Point; + + /// + /// Gets the first intersecting edge. + /// public readonly Active Edge1; + + /// + /// Gets the second intersecting edge. + /// public readonly Active Edge2; + /// + /// Initializes a new instance of the struct. + /// + /// The intersection point. + /// The first intersecting edge. + /// The second intersecting edge. public IntersectNode(Vector2 pt, Active edge1, Active edge2) { this.Point = pt; @@ -3168,6 +3354,9 @@ public IntersectNode(Vector2 pt, Active edge1, Active edge2) } } + /// + /// Comparer for sorting horizontal segments by left X coordinate. + /// private struct HorzSegSorter : IComparer { public readonly int Compare(HorzSegment hs1, HorzSegment hs2) @@ -3192,6 +3381,9 @@ public readonly int Compare(HorzSegment hs1, HorzSegment hs2) } } + /// + /// Comparer for sorting intersection nodes by Y coordinate (descending), then X coordinate. + /// private struct IntersectListSort : IComparer { public readonly int Compare(IntersectNode a, IntersectNode b) @@ -3210,8 +3402,16 @@ public readonly int Compare(IntersectNode a, IntersectNode b) } } + /// + /// Represents a horizontal segment in the output polygon. + /// Used to identify and join horizontal edges. + /// private class HorzSegment { + /// + /// Initializes a new instance of the class. + /// + /// The starting output point. public HorzSegment(OutPt op) { this.LeftOp = op; @@ -3219,29 +3419,60 @@ public HorzSegment(OutPt op) this.LeftToRight = true; } + /// + /// Gets or sets the left output point. + /// public OutPt LeftOp { get; set; } + /// + /// Gets or sets the right output point. + /// public OutPt RightOp { get; set; } + /// + /// Gets or sets a value indicating whether the segment is oriented left-to-right. + /// public bool LeftToRight { get; set; } } + /// + /// Represents a horizontal join operation between two output points. + /// private class HorzJoin { + /// + /// Initializes a new instance of the class. + /// + /// Left-to-right output point. + /// Right-to-left output point. public HorzJoin(OutPt ltor, OutPt rtol) { this.Op1 = ltor; this.Op2 = rtol; } + /// + /// Gets the first output point in the join. + /// public OutPt Op1 { get; } + /// + /// Gets the second output point in the join. + /// public OutPt Op2 { get; } } - // OutPt: vertex data structure for clipping solutions + /// + /// Output point: represents a vertex in a clipping solution polygon. + /// Forms a circular doubly-linked list of vertices. + /// private class OutPt { + /// + /// Initializes a new instance of the class. + /// + /// The point coordinates. + /// The output record this point belongs to. public OutPt(Vector2 pt, OutRec outrec) { this.Point = pt; @@ -3251,43 +3482,101 @@ public OutPt(Vector2 pt, OutRec outrec) this.HorizSegment = null; } + /// + /// Gets the point coordinates. + /// public Vector2 Point { get; } + /// + /// Gets or sets the next output point in the circular list. + /// public OutPt Next { get; set; } + /// + /// Gets or sets the previous output point in the circular list. + /// public OutPt Prev { get; set; } + /// + /// Gets or sets the output record this point belongs to. + /// public OutRec OutRec { get; set; } + /// + /// Gets or sets the horizontal segment this point is part of (if any). + /// public HorzSegment HorizSegment { get; set; } } - // OutRec: path data structure for clipping solutions + /// + /// Output record: represents a complete output polygon path. + /// Contains a circular doubly-linked list of output points. + /// private class OutRec { + /// + /// Gets or sets the index of this output record in the output list. + /// public int Idx { get; set; } + /// + /// Gets or sets the parent output record (for holes). + /// public OutRec Owner { get; set; } + /// + /// Gets or sets the front (ascending) edge of the output polygon. + /// public Active FrontEdge { get; set; } + /// + /// Gets or sets the back (descending) edge of the output polygon. + /// public Active BackEdge { get; set; } + /// + /// Gets or sets the starting point in the circular output point list. + /// public OutPt Pts { get; set; } + /// + /// Gets or sets the polytree path (for hierarchical output). + /// public PolyPathF PolyPath { get; set; } + /// + /// Gets or sets the bounding rectangle. + /// public BoundsF Bounds { get; set; } + /// + /// Gets or sets the final output path. + /// public PathF Path { get; set; } = []; + /// + /// Gets or sets a value indicating whether this is an open path. + /// public bool IsOpen { get; set; } + /// + /// Gets or sets the list of split indices (for self-intersecting polygons). + /// public List Splits { get; set; } } + /// + /// Represents a vertex in an input polygon path. + /// Forms a circular doubly-linked list of vertices. + /// private class Vertex { + /// + /// Initializes a new instance of the class. + /// + /// The point coordinates. + /// Vertex flags (local min/max, open start/end). + /// The previous vertex in the list. public Vertex(Vector2 pt, VertexFlags flags, Vertex prev) { this.Point = pt; @@ -3296,78 +3585,172 @@ public Vertex(Vector2 pt, VertexFlags flags, Vertex prev) this.Prev = prev; } + /// + /// Gets the point coordinates. + /// public Vector2 Point { get; } + /// + /// Gets or sets the next vertex in the circular list. + /// public Vertex Next { get; set; } + /// + /// Gets or sets the previous vertex in the circular list. + /// public Vertex Prev { get; set; } + /// + /// Gets or sets the vertex flags indicating properties like local minima/maxima. + /// public VertexFlags Flags { get; set; } } + /// + /// Active edge: represents an edge currently intersecting the scanline. + /// Stored in the Active Edge List (AEL) during scanline processing. + /// private class Active { + /// + /// Gets or sets the bottom point of the edge. + /// public Vector2 Bot { get; set; } + /// + /// Gets or sets the top point of the edge. + /// public Vector2 Top { get; set; } - public float CurX { get; set; } // current (updated at every new scanline) + /// + /// Gets or sets the current X coordinate at the scanline (updated at every scanline). + /// + public float CurX { get; set; } + /// + /// Gets or sets the edge's reciprocal slope (dx/dy). + /// public float Dx { get; set; } - public int WindDx { get; set; } // 1 or -1 depending on winding direction + /// + /// Gets or sets the winding direction (1 for ascending, -1 for descending). + /// + public int WindDx { get; set; } + /// + /// Gets or sets the winding count for this edge's polygon type. + /// public int WindCount { get; set; } - public int WindCount2 { get; set; } // winding count of the opposite polytype + /// + /// Gets or sets the winding count for the opposite polygon type. + /// + public int WindCount2 { get; set; } + /// + /// Gets or sets the output record this edge contributes to. + /// public OutRec Outrec { get; set; } - // AEL: 'active edge list' (Vatti's AET - active edge table) - // a linked list of all edges (from left to right) that are present - // (or 'active') within the current scanbeam (a horizontal 'beam' that - // sweeps from bottom to top over the paths in the clipping operation). + /// + /// Gets or sets the previous edge in the Active Edge List. + /// The AEL is a doubly-linked list of all edges intersecting the current scanbeam, + /// ordered from left to right. + /// public Active PrevInAEL { get; set; } + /// + /// Gets or sets the next edge in the Active Edge List. + /// public Active NextInAEL { get; set; } - // SEL: 'sorted edge list' (Vatti's ST - sorted table) - // linked list used when sorting edges into their new positions at the - // top of scanbeams, but also (re)used to process horizontals. + /// + /// Gets or sets the previous edge in the Sorted Edge List. + /// The SEL is used when sorting edges into their new positions at scanbeam tops, + /// and is also reused to process horizontal edges. + /// public Active PrevInSEL { get; set; } + /// + /// Gets or sets the next edge in the Sorted Edge List. + /// public Active NextInSEL { get; set; } + /// + /// Gets or sets the jump pointer used during merge sort operations. + /// public Active Jump { get; set; } + /// + /// Gets or sets the vertex at the top of this edge segment. + /// public Vertex VertexTop { get; set; } - public LocalMinima LocalMin { get; set; } // the bottom of an edge 'bound' (also Vatti) + /// + /// Gets or sets the local minimum this edge belongs to. + /// + public LocalMinima LocalMin { get; set; } + /// + /// Gets or sets a value indicating whether this is a left bound edge. + /// public bool IsLeftBound { get; set; } + /// + /// Gets or sets the join status indicating if this edge is joined with an adjacent edge. + /// public JoinWith JoinWith { get; set; } } } +/// +/// Represents a node in a hierarchical polygon tree structure. +/// Can contain child paths representing holes or nested polygons. +/// internal class PolyPathF : IEnumerable { private readonly PolyPathF parent; private readonly List items = []; + /// + /// Initializes a new instance of the class. + /// + /// The parent path, or null for the root. public PolyPathF(PolyPathF parent = null) => this.parent = parent; - public PathF Polygon { get; private set; } // polytree root's polygon == null + /// + /// Gets the polygon path. The polytree root's polygon is null. + /// + public PathF Polygon { get; private set; } + /// + /// Gets the nesting level in the tree (0 for root). + /// public int Level => this.GetLevel(); + /// + /// Gets a value indicating whether this path represents a hole. + /// public bool IsHole => this.GetIsHole(); + /// + /// Gets the number of child paths. + /// public int Count => this.items.Count; + /// + /// Gets the child path at the specified index. + /// + /// The child index. + /// The child path. public PolyPathF this[int index] => this.items[index]; + /// + /// Adds a child path to this polytree node. + /// + /// The polygon path to add. + /// The created child node. [MethodImpl(MethodImplOptions.AggressiveInlining)] public PolyPathF AddChild(PathF p) { @@ -3380,6 +3763,10 @@ public PolyPathF AddChild(PathF p) return child; } + /// + /// Calculates the total area of this polygon and all its children. + /// + /// The signed area. [MethodImpl(MethodImplOptions.AggressiveInlining)] public float Area() { @@ -3393,6 +3780,9 @@ public float Area() return result; } + /// + /// Removes all child paths. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Clear() => this.items.Clear(); @@ -3417,43 +3807,78 @@ private int GetLevel() return result; } + /// + /// Returns an enumerator that iterates through the child paths. + /// + /// An enumerator for the children. public IEnumerator GetEnumerator() => this.items.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => this.items.GetEnumerator(); } +/// +/// Root of a polytree structure containing hierarchical polygon data. +/// internal class PolyTreeF : PolyPathF { } +/// +/// Collection of polygon paths. +/// internal class PathsF : List { + /// + /// Initializes a new instance of the class. + /// public PathsF() { } + /// + /// Initializes a new instance of the class with items. + /// + /// Initial paths. public PathsF(IEnumerable items) : base(items) { } + /// + /// Initializes a new instance of the class with capacity. + /// + /// Initial capacity. public PathsF(int capacity) : base(capacity) { } } +/// +/// Represents a polygon path as a list of points. +/// internal class PathF : List { + /// + /// Initializes a new instance of the class. + /// public PathF() { } + /// + /// Initializes a new instance of the class with items. + /// + /// Initial points. public PathF(IEnumerable items) : base(items) { } + /// + /// Initializes a new instance of the class with capacity. + /// + /// Initial capacity. public PathF(int capacity) : base(capacity) { From 3b1b88d69e7812122eac82054faddbba22a2c980 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 5 Feb 2026 15:42:24 +1000 Subject: [PATCH 16/18] Handle degenerate inbound path. --- .../Shapes/PolygonGeometry/PolygonStroker.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs index 14ea9c70..c75d115e 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs @@ -118,6 +118,11 @@ public double Width /// The input points to stroke. /// Whether the input is a closed ring. /// The stroked outline as a closed point array. + /// + /// When a 2-point input contains identical points (degenerate case), this method generates + /// a cap shape at that point: a circle for round caps or a square for square/butt caps. + /// This ensures that even degenerate input produces visible output when stroked. + /// public PointF[] ProcessPath(ReadOnlySpan linePoints, bool isClosed) { if (linePoints.Length < 2) @@ -125,6 +130,21 @@ public PointF[] ProcessPath(ReadOnlySpan linePoints, bool isClosed) return []; } + // Special case: for 2-point inputs, check if both points are identical (degenerate case) + // This avoids overhead for longer paths where the filtering logic handles near-duplicates + if (linePoints.Length == 2) + { + PointF p0 = linePoints[0]; + PointF p1 = linePoints[1]; + + if (Math.Abs(p1.X - p0.X) <= Constants.Misc.VertexDistanceEpsilon && + Math.Abs(p1.Y - p0.Y) <= Constants.Misc.VertexDistanceEpsilon) + { + // Both points are identical - generate a point cap shape + return this.GeneratePointCap(p0.X, p0.Y); + } + } + this.Reset(); this.AddLinePath(linePoints); @@ -762,6 +782,52 @@ private void CalcJoin(ref VertexDistance v0, ref VertexDistance v1, ref VertexDi [MethodImpl(MethodImplOptions.AggressiveInlining)] private void AddPoint(double x, double y) => this.outVertices.Add(new PointF((float)x, (float)y)); + /// + /// Generates a cap shape for a degenerate point (when all input points are identical). + /// Creates a circle for round caps or a square for square/butt caps. + /// + /// The X coordinate of the point. + /// The Y coordinate of the point. + /// The vertices forming the cap shape. + private PointF[] GeneratePointCap(double x, double y) + { + if (this.LineCap == LineCap.Round) + { + // Generate a circle with radius = strokeWidth + double da = Math.Acos(this.widthAbs / (this.widthAbs + (0.125 / this.ApproximationScale))) * 2; + int n = Math.Max(4, (int)(Constants.Misc.PiMul2 / da)); + double angleStep = Constants.Misc.PiMul2 / n; + + PointF[] points = new PointF[n + 1]; + + for (int i = 0; i < n; i++) + { + double angle = i * angleStep; + points[i] = new PointF( + (float)(x + (Math.Cos(angle) * this.strokeWidth)), + (float)(y + (Math.Sin(angle) * this.strokeWidth))); + } + + // Close the circle + points[n] = points[0]; + + return points; + } + else + { + // Generate a square cap (used for both Square and Butt caps) + double w = this.strokeWidth; + return + [ + new PointF((float)(x - w), (float)(y - w)), + new PointF((float)(x + w), (float)(y - w)), + new PointF((float)(x + w), (float)(y + w)), + new PointF((float)(x - w), (float)(y + w)), + new PointF((float)(x - w), (float)(y - w)) // Close the square + ]; + } + } + private enum Status { Initial, From f41dc743819cdc9d8f57cba1381b1b526e4912a3 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 5 Feb 2026 16:31:02 +1000 Subject: [PATCH 17/18] Remove unnecessary checks --- .../Shapes/PolygonGeometry/PolygonClipper.cs | 64 +++---------------- .../Shapes/PolygonGeometry/PolygonStroker.cs | 6 -- 2 files changed, 9 insertions(+), 61 deletions(-) diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs index f8a9fea8..87f3291c 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs @@ -63,14 +63,6 @@ public PolygonClipper() /// public bool ReverseSolution { get; set; } - /// - /// Adds subject paths to the clipping operation. - /// Subject paths are the primary polygons being clipped. - /// - /// The subject paths to add. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void AddSubject(PathsF paths) => this.AddPaths(paths, ClippingType.Subject); - /// /// Adds a single path to the clipping operation. /// @@ -244,52 +236,13 @@ private void AddNewIntersectNode(Active ae1, Active ae2, float topY) // Adjust intersection point if it's outside the scanbeam bounds if (ip.Y > this.currentBotY || ip.Y < topY) { + // Clamp Y to scanbeam bounds + ip.Y = ip.Y < topY ? topY : this.currentBotY; + + // Use the more vertical edge (smaller |Dx|) to compute X for numerical stability float absDx1 = MathF.Abs(ae1.Dx); float absDx2 = MathF.Abs(ae2.Dx); - - // For very steep edges, project the point onto the edge - // TODO: Check threshold here once we remove upscaling. - if (absDx1 > 100 && absDx2 > 100) - { - if (absDx1 > absDx2) - { - ip = PolygonClipperUtilities.GetClosestPtOnSegment(ip, ae1.Bot, ae1.Top); - } - else - { - ip = PolygonClipperUtilities.GetClosestPtOnSegment(ip, ae2.Bot, ae2.Top); - } - } - else if (absDx1 > 100) - { - ip = PolygonClipperUtilities.GetClosestPtOnSegment(ip, ae1.Bot, ae1.Top); - } - else if (absDx2 > 100) - { - ip = PolygonClipperUtilities.GetClosestPtOnSegment(ip, ae2.Bot, ae2.Top); - } - else - { - // Clamp Y to scanbeam bounds - if (ip.Y < topY) - { - ip.Y = topY; - } - else - { - ip.Y = this.currentBotY; - } - - // Use the less steep edge to determine X - if (absDx1 < absDx2) - { - ip.X = TopX(ae1, ip.Y); - } - else - { - ip.X = TopX(ae2, ip.Y); - } - } + ip.X = absDx1 < absDx2 ? TopX(ae1, ip.Y) : TopX(ae2, ip.Y); } IntersectNode node = new(ip, ae1, ae2); @@ -1107,9 +1060,10 @@ private void ProcessHorzJoins() [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool PtsReallyClose(Vector2 pt1, Vector2 pt2) - - // TODO: Check scale once we can remove upscaling. - => (Math.Abs(pt1.X - pt2.X) < 2F) && (Math.Abs(pt1.Y - pt2.Y) < 2F); + { + Vector2 delta = Vector2.Abs(pt1 - pt2); + return delta.X < 1e-6f && delta.Y < 1e-6f; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CleanCollinear(OutRec outrec) diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs index c75d115e..772c37ca 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonStroker.cs @@ -118,11 +118,6 @@ public double Width /// The input points to stroke. /// Whether the input is a closed ring. /// The stroked outline as a closed point array. - /// - /// When a 2-point input contains identical points (degenerate case), this method generates - /// a cap shape at that point: a circle for round caps or a square for square/butt caps. - /// This ensures that even degenerate input produces visible output when stroked. - /// public PointF[] ProcessPath(ReadOnlySpan linePoints, bool isClosed) { if (linePoints.Length < 2) @@ -131,7 +126,6 @@ public PointF[] ProcessPath(ReadOnlySpan linePoints, bool isClosed) } // Special case: for 2-point inputs, check if both points are identical (degenerate case) - // This avoids overhead for longer paths where the filtering logic handles near-duplicates if (linePoints.Length == 2) { PointF p0 = linePoints[0]; From 6b42ee3fc16d4c68379c2d6b52eb44c518a3edfd Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 5 Feb 2026 16:44:16 +1000 Subject: [PATCH 18/18] Use scale appropriate tolerances --- .../Shapes/PolygonGeometry/PolygonClipper.cs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs index 87f3291c..58a6fd2c 100644 --- a/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs +++ b/src/ImageSharp.Drawing/Shapes/PolygonGeometry/PolygonClipper.cs @@ -21,6 +21,11 @@ namespace SixLabors.ImageSharp.Drawing.Shapes.PolygonGeometry; /// internal sealed class PolygonClipper { + private const float MinimumDistanceThreshold = 1e-6f; + private const float MinimumAreaThreshold = 1e-6f; + private const float JoinYTolerance = 1e-6f; + private const float JoinDistanceSqrdThreshold = 1e-12f; + private BooleanOperation clipType; private ClipperFillRule fillRule; private Active actives; // Head of the active edge list @@ -1062,7 +1067,8 @@ private void ProcessHorzJoins() private static bool PtsReallyClose(Vector2 pt1, Vector2 pt2) { Vector2 delta = Vector2.Abs(pt1 - pt2); - return delta.X < 1e-6f && delta.Y < 1e-6f; + return delta.X < MinimumDistanceThreshold && + delta.Y < MinimumDistanceThreshold; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -1125,16 +1131,16 @@ private void DoSplitOp(OutRec outrec, OutPt splitOp) prevOp.Point, splitOp.Point, splitOp.Next.Point, nextNextOp.Point, out Vector2 ip); float area1 = Area(prevOp); - float absArea1 = Math.Abs(area1); + float absArea1 = MathF.Abs(area1); - if (absArea1 < 2) + if (absArea1 < MinimumAreaThreshold) { outrec.Pts = null; return; } float area2 = AreaTriangle(ip, splitOp.Point, splitOp.Next.Point); - float absArea2 = Math.Abs(area2); + float absArea2 = MathF.Abs(area2); // de-link splitOp and splitOp.next from the path // while inserting the intersection point @@ -1160,7 +1166,7 @@ private void DoSplitOp(OutRec outrec, OutPt splitOp) // So the only way for these areas to have the same sign is if // the split triangle is larger than the path containing prevOp or // if there's more than one self=intersection. - if (absArea2 > 1 && (absArea2 > absArea1 || ((area2 > 0) == (area1 > 0)))) + if (absArea2 > MinimumAreaThreshold && (absArea2 > absArea1 || ((area2 > 0) == (area1 > 0)))) { OutRec newOutRec = this.NewOutRec(); newOutRec.Owner = outrec.Owner; @@ -3022,7 +3028,7 @@ private void CheckJoinLeft(Active e, Vector2 pt, bool checkCurrX = false) } // Avoid trivial joins - if ((pt.Y < e.Top.Y + 2 || pt.Y < prev.Top.Y + 2) + if ((pt.Y < e.Top.Y + JoinYTolerance || pt.Y < prev.Top.Y + JoinYTolerance) && ((e.Bot.Y > pt.Y) || (prev.Bot.Y > pt.Y))) { return; @@ -3030,7 +3036,7 @@ private void CheckJoinLeft(Active e, Vector2 pt, bool checkCurrX = false) if (checkCurrX) { - if (PolygonClipperUtilities.PerpendicDistFromLineSqrd(pt, prev.Bot, prev.Top) > 0.25) + if (PolygonClipperUtilities.PerpendicDistFromLineSqrd(pt, prev.Bot, prev.Top) > JoinDistanceSqrdThreshold) { return; } @@ -3077,7 +3083,7 @@ private void CheckJoinRight(Active e, Vector2 pt, bool checkCurrX = false) } // Avoid trivial joins - if ((pt.Y < e.Top.Y + 2 || pt.Y < next.Top.Y + 2) + if ((pt.Y < e.Top.Y + JoinYTolerance || pt.Y < next.Top.Y + JoinYTolerance) && ((e.Bot.Y > pt.Y) || (next.Bot.Y > pt.Y))) { return; @@ -3085,7 +3091,7 @@ private void CheckJoinRight(Active e, Vector2 pt, bool checkCurrX = false) if (checkCurrX) { - if (PolygonClipperUtilities.PerpendicDistFromLineSqrd(pt, next.Bot, next.Top) > 0.25) + if (PolygonClipperUtilities.PerpendicDistFromLineSqrd(pt, next.Bot, next.Top) > JoinDistanceSqrdThreshold) { return; }