From 10fe00d8ab7b5e369cdd9acf44f7bf86f83fcece Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 09:24:37 -0400 Subject: [PATCH] refactor(geometry): delete RemoveSelfIntersections Its callers now take Clipper region offsets, which never produce the self-intersections it patched over (and it only caught proper crossings, so spikes survived it anyway). Polygon.OffsetEntity was its last caller; the override is required by Entity but has no callers, so it becomes a Clipper miter offset that keeps the Left/Right semantics and the input winding. FindCrossing, SplitAtCrossing, SegmentsIntersect and the static CalculateArea helper go with it. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/ClipperBridge.cs | 38 ++++ OpenNest.Core/Geometry/Polygon.cs | 210 +----------------- OpenNest.Tests/Geometry/ClipperBridgeTests.cs | 25 +++ 3 files changed, 75 insertions(+), 198 deletions(-) diff --git a/OpenNest.Core/Geometry/ClipperBridge.cs b/OpenNest.Core/Geometry/ClipperBridge.cs index a02e1d4..37e470b 100644 --- a/OpenNest.Core/Geometry/ClipperBridge.cs +++ b/OpenNest.Core/Geometry/ClipperBridge.cs @@ -189,6 +189,44 @@ namespace OpenNest.Geometry return result; } + /// + /// Miter-offsets a closed polygon by (positive grows it, + /// negative shrinks it). Returns the largest resulting polygon (CCW), or null + /// when the polygon collapses. + /// + public static Polygon OffsetMiter(Polygon polygon, double delta) + { + var path = ToPath(polygon, positive: true); + + if (path.Count < 3) + return null; + + var inflated = Clipper.InflatePaths( + new PathsD { path }, + delta, + JoinType.Miter, + EndType.Polygon, + MiterLimit, + Precision + ); + + PathD largest = null; + var largestArea = 0.0; + + foreach (var candidate in inflated) + { + var area = Clipper.Area(candidate); + + if (area > largestArea) + { + largest = candidate; + largestArea = area; + } + } + + return largest == null ? null : ToPolygon(largest); + } + private static PathsD Union(PathsD region) { var clipper = new ClipperD(Precision); diff --git a/OpenNest.Core/Geometry/Polygon.cs b/OpenNest.Core/Geometry/Polygon.cs index dec134e..a8e66dd 100644 --- a/OpenNest.Core/Geometry/Polygon.cs +++ b/OpenNest.Core/Geometry/Polygon.cs @@ -328,66 +328,29 @@ namespace OpenNest.Geometry boundingBox.Width = maxY - minY; } + /// + /// Miter-offsets the closed polygon to the given side, keeping its winding. + /// Corners sharper than the miter limit are squared off, and features that + /// collapse under the offset are dropped. When the offset splits the polygon, + /// the largest piece is returned. + /// public override Entity OffsetEntity(double distance, OffsetSide side) { if (Vertices.Count < 3) return null; - var isClosed = IsClosed(); - var count = isClosed ? Vertices.Count - 1 : Vertices.Count; - if (count < 3) - return null; - var ccw = CalculateArea() > 0; var outward = ccw ? OffsetSide.Left : OffsetSide.Right; - var sign = side == outward ? 1.0 : -1.0; - var d = distance * sign; + var delta = side == outward ? distance : -distance; - var normals = new Vector[count]; - for (var i = 0; i < count; i++) - { - var next = (i + 1) % count; - var dx = Vertices[next].X - Vertices[i].X; - var dy = Vertices[next].Y - Vertices[i].Y; - var len = System.Math.Sqrt(dx * dx + dy * dy); - if (len < Tolerance.Epsilon) - return null; - normals[i] = new Vector(-dy / len * d, dx / len * d); - } + var result = ClipperBridge.OffsetMiter(this, delta); - var result = new Polygon(); - for (var i = 0; i < count; i++) - { - var prev = (i - 1 + count) % count; + if (result == null) + return null; - var a1 = new Vector( - Vertices[prev].X + normals[prev].X, - Vertices[prev].Y + normals[prev].Y - ); - var a2 = new Vector( - Vertices[i].X + normals[prev].X, - Vertices[i].Y + normals[prev].Y - ); - var b1 = new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y); - var b2 = new Vector( - Vertices[(i + 1) % count].X + normals[i].X, - Vertices[(i + 1) % count].Y + normals[i].Y - ); + if (!ccw) + result.Reverse(); - var edgeA = new Line(a1, a2); - var edgeB = new Line(b1, b2); - - if (edgeA.Intersects(edgeB, out var pt) && pt.IsValid()) - result.Vertices.Add(pt); - else - result.Vertices.Add( - new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y) - ); - } - - result.Close(); - result.RemoveSelfIntersections(); - result.UpdateBounds(); return result; } @@ -556,155 +519,6 @@ namespace OpenNest.Geometry get { return EntityType.Polygon; } } - /// - /// Removes self-intersecting loops from the polygon by finding non-adjacent - /// edge crossings and keeping the larger contour at each crossing. - /// - public void RemoveSelfIntersections() - { - if (!IsClosed() || Vertices.Count < 5) - return; - - while (FindCrossing(out var edgeI, out var edgeJ, out var pt)) - { - Vertices = SplitAtCrossing(edgeI, edgeJ, pt); - } - } - - private bool FindCrossing(out int edgeI, out int edgeJ, out Vector pt) - { - var n = Vertices.Count - 1; - - // Pre-calculate edge bounding boxes to speed up intersection checks. - var edgeBounds = new (double minX, double maxX, double minY, double maxY)[n]; - for (var i = 0; i < n; i++) - { - var v1 = Vertices[i]; - var v2 = Vertices[i + 1]; - edgeBounds[i] = ( - System.Math.Min(v1.X, v2.X) - Tolerance.Epsilon, - System.Math.Max(v1.X, v2.X) + Tolerance.Epsilon, - System.Math.Min(v1.Y, v2.Y) - Tolerance.Epsilon, - System.Math.Max(v1.Y, v2.Y) + Tolerance.Epsilon - ); - } - - for (var i = 0; i < n; i++) - { - var bi = edgeBounds[i]; - for (var j = i + 2; j < n; j++) - { - if (i == 0 && j == n - 1) - continue; - - var bj = edgeBounds[j]; - - // Prune with bounding box check. - if ( - bi.maxX < bj.minX - || bj.maxX < bi.minX - || bi.maxY < bj.minY - || bj.maxY < bi.minY - ) - { - continue; - } - - if ( - SegmentsIntersect( - Vertices[i], - Vertices[i + 1], - Vertices[j], - Vertices[j + 1], - out pt - ) - ) - { - edgeI = i; - edgeJ = j; - return true; - } - } - } - - edgeI = edgeJ = -1; - pt = Vector.Zero; - return false; - } - - private List SplitAtCrossing(int edgeI, int edgeJ, Vector pt) - { - var n = Vertices.Count - 1; - - var loopA = Vertices.GetRange(0, edgeI + 1); - loopA.Add(pt); - loopA.AddRange(Vertices.GetRange(edgeJ + 1, n - edgeJ - 1)); - loopA.Add(loopA[0]); - - var loopB = new List { pt }; - loopB.AddRange(Vertices.GetRange(edgeI + 1, edgeJ - edgeI)); - loopB.Add(pt); - - var areaA = System.Math.Abs(CalculateArea(loopA)); - var areaB = System.Math.Abs(CalculateArea(loopB)); - - return areaA >= areaB ? loopA : loopB; - } - - private static bool SegmentsIntersect( - Vector a1, - Vector a2, - Vector b1, - Vector b2, - out Vector pt - ) - { - var da = a2 - a1; - var db = b2 - b1; - var cross = da.X * db.Y - da.Y * db.X; - - if (cross.IsEqualTo(0.0)) - { - pt = Vector.Zero; - return false; - } - - var dc = b1 - a1; - var t = (dc.X * db.Y - dc.Y * db.X) / cross; - var u = (dc.X * da.Y - dc.Y * da.X) / cross; - - if ( - t > Tolerance.Epsilon - && t < 1.0 - Tolerance.Epsilon - && u > Tolerance.Epsilon - && u < 1.0 - Tolerance.Epsilon - ) - { - pt = new Vector(a1.X + t * da.X, a1.Y + t * da.Y); - return true; - } - - pt = Vector.Zero; - return false; - } - - private static double CalculateArea(List vertices) - { - double xsum = 0; - double ysum = 0; - - for (int i = 0; i < vertices.Count - 1; i++) - { - var current = vertices[i]; - var next = vertices[i + 1]; - - xsum += current.X * next.Y; - ysum += current.Y * next.X; - } - - return (xsum - ysum) * 0.5; - } - internal void Cleanup() { for (int i = Vertices.Count - 1; i > 0; i--) diff --git a/OpenNest.Tests/Geometry/ClipperBridgeTests.cs b/OpenNest.Tests/Geometry/ClipperBridgeTests.cs index 55e2f22..0aeaa71 100644 --- a/OpenNest.Tests/Geometry/ClipperBridgeTests.cs +++ b/OpenNest.Tests/Geometry/ClipperBridgeTests.cs @@ -174,6 +174,31 @@ public class ClipperBridgeTests } } + [Theory] + [InlineData(true, OffsetSide.Left, 12 * 12)] + [InlineData(true, OffsetSide.Right, 8 * 8)] + [InlineData(false, OffsetSide.Left, 8 * 8)] + [InlineData(false, OffsetSide.Right, 12 * 12)] + public void PolygonOffsetEntity_MitersToSideAndKeepsWinding( + bool ccw, + OffsetSide side, + double expectedArea + ) + { + var square = new Polygon(); + square.Vertices.AddRange(new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10) }); + + if (!ccw) + square.Vertices.Reverse(); + + square.Close(); + + var result = (Polygon)square.OffsetEntity(1, side); + + Assert.Equal(expectedArea, result.Area(), 6); + Assert.Equal(square.RotationDirection(), result.RotationDirection()); + } + private static bool SegmentsCross(Vector a, Vector b, Vector c, Vector d) { static double Cross(Vector o, Vector p, Vector q) =>