diff --git a/OpenNest.Core/Geometry/Collision.cs b/OpenNest.Core/Geometry/Collision.cs index 6855121..66c05ab 100644 --- a/OpenNest.Core/Geometry/Collision.cs +++ b/OpenNest.Core/Geometry/Collision.cs @@ -35,35 +35,11 @@ namespace OpenNest.Geometry // Step 2: Quick intersection test for crossing points var intersectionPoints = FindCrossingPoints(a, b); - // Step 3: Convex decomposition - var trisA = TriangulateWithBounds(a); - var trisB = TriangulateWithBounds(b); - - // Step 4: Clip all triangle pairs - var regions = new List(); - - foreach (var triA in trisA) - { - foreach (var triB in trisB) - { - if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox)) - continue; - - var clipped = ClipConvex(triA, triB); - if (clipped != null) - regions.Add(clipped); - } - } - - // Step 5: Hole subtraction - if (regions.Count > 0) - regions = SubtractHoles(regions, holesA, holesB); - - if (regions.Count == 0) - return new CollisionResult(false, regions, intersectionPoints); + // Steps 3-5: Convex decomposition, triangle-pair clipping, hole subtraction + var regions = OverlapRegions(a, b, holesA, holesB); // Step 6: Build result - return new CollisionResult(true, regions, intersectionPoints); + return new CollisionResult(regions.Count > 0, regions, intersectionPoints); } public static bool HasOverlap( @@ -76,9 +52,10 @@ namespace OpenNest.Geometry if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox)) return false; - // Full check is needed: crossing points alone miss containment cases - // (one polygon entirely inside another has zero edge crossings). - return Check(a, b, holesA, holesB).Overlaps; + // Clipping decides the verdict, including containment (one polygon entirely + // inside another has zero edge crossings). Crossing points never affect it, + // so this overlap-only path skips them. + return OverlapRegions(a, b, holesA, holesB).Count > 0; } public static List CheckAll( @@ -121,6 +98,42 @@ namespace OpenNest.Geometry return false; } + /// + /// Positive-area overlap regions left after hole subtraction: the verdict shared by + /// and . Callers apply the polygon-level + /// bounding-box pre-filter first. + /// + private static List OverlapRegions( + Polygon a, + Polygon b, + List holesA, + List holesB + ) + { + var trisA = TriangulateWithBounds(a); + var trisB = TriangulateWithBounds(b); + + var regions = new List(); + + foreach (var triA in trisA) + { + foreach (var triB in trisB) + { + if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox)) + continue; + + var clipped = ClipConvex(triA, triB); + if (clipped != null) + regions.Add(clipped); + } + } + + if (regions.Count > 0) + regions = SubtractHoles(regions, holesA, holesB); + + return regions; + } + private static bool BoundingBoxesOverlap(Box a, Box b) { var overlapX = System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left); @@ -131,6 +144,7 @@ namespace OpenNest.Geometry private static List FindCrossingPoints(Polygon a, Polygon b) { + PerfCounters.CountCrossingPointScan(); if (!Intersect.Intersects(a, b, out var rawPts)) return new List(); diff --git a/OpenNest.Core/PerfCounters.cs b/OpenNest.Core/PerfCounters.cs index 95383f7..a1b449b 100644 --- a/OpenNest.Core/PerfCounters.cs +++ b/OpenNest.Core/PerfCounters.cs @@ -16,6 +16,7 @@ namespace OpenNest private static long partBoundaryPreparations; private static long partBoundsUpdates; private static long featureBitmaskCells; + private static long crossingPointScans; public static long FindBestFits => Interlocked.Read(ref findBestFits); public static long OffsetPerimeterEntities => Interlocked.Read(ref offsetPerimeterEntities); @@ -24,6 +25,7 @@ namespace OpenNest public static long PartBoundaryPreparations => Interlocked.Read(ref partBoundaryPreparations); public static long PartBoundsUpdates => Interlocked.Read(ref partBoundsUpdates); public static long FeatureBitmaskCells => Interlocked.Read(ref featureBitmaskCells); + public static long CrossingPointScans => Interlocked.Read(ref crossingPointScans); [Conditional("DEBUG")] public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits); @@ -47,6 +49,9 @@ namespace OpenNest [Conditional("DEBUG")] public static void CountFeatureBitmaskCell() => Interlocked.Increment(ref featureBitmaskCells); + [Conditional("DEBUG")] + public static void CountCrossingPointScan() => Interlocked.Increment(ref crossingPointScans); + public static void Reset() { Interlocked.Exchange(ref findBestFits, 0); @@ -56,6 +61,7 @@ namespace OpenNest Interlocked.Exchange(ref partBoundaryPreparations, 0); Interlocked.Exchange(ref partBoundsUpdates, 0); Interlocked.Exchange(ref featureBitmaskCells, 0); + Interlocked.Exchange(ref crossingPointScans, 0); } } } diff --git a/OpenNest.Tests/Geometry/CollisionOverlapOnlyTests.cs b/OpenNest.Tests/Geometry/CollisionOverlapOnlyTests.cs new file mode 100644 index 0000000..210b030 --- /dev/null +++ b/OpenNest.Tests/Geometry/CollisionOverlapOnlyTests.cs @@ -0,0 +1,253 @@ +using OpenNest.Geometry; +using Xunit.Abstractions; +using static OpenNest.Tests.Geometry.NoFitPolygonTests; + +namespace OpenNest.Tests.Geometry; + +/// +/// The overlap-only path skips crossing points. These tests +/// compare it and against the frozen pre-change +/// (commit 82feb78). +/// +[Collection(nameof(OpenNest.Tests.BestFit.FillCacheCollection))] +public class CollisionOverlapOnlyTests +{ + private readonly ITestOutputHelper output; + + public CollisionOverlapOnlyTests(ITestOutputHelper output) => this.output = output; + + [Fact] + public void SeededHasOverlap_MatchesLegacyVerdicts() + { + var random = new Random(27092026); + var decisions = 0; + var overlaps = 0; + var boxOverlapClear = 0; + var centerAligned = 0; + for (var pair = 0; pair < 200; pair++) + { + var a = Make(random, pair % 6); + var b = Make(random, (pair / 6) % 6); + var holesA = pair % 4 == 0 ? new List { Move(Square(0.7), 0.2, 0.2) } : null; + var holesB = pair % 7 == 0 ? new List { Move(Square(0.6), 0.3, 0.3) } : null; + for (var sample = 0; sample < 250; sample++) + { + var (ax, ay, bx, by) = Offsets(random, a, b, sample); + var ma = Move(a, ax, ay); + var mb = Move(b, bx, by); + var ha = holesA?.Select(h => Move(h, ax, ay)).ToList(); + var hb = holesB?.Select(h => Move(h, bx, by)).ToList(); + var legacyCheck = LegacyCollision.Check(ma, mb, ha, hb).Overlaps; + var legacyHas = LegacyCollision.HasOverlap(ma, mb, ha, hb); + var actual = Collision.HasOverlap(ma, mb, ha, hb); + Assert.True(legacyCheck == legacyHas, $"legacy self-disagreement pair={pair} sample={sample}"); + Assert.True(actual == legacyHas, + $"pair={pair} sample={sample} a=({ax:R},{ay:R}) b=({bx:R},{by:R}) expected={legacyHas}"); + Assert.Equal(legacyHas, Collision.Check(ma, mb, ha, hb).Overlaps); + if (legacyHas) + overlaps++; + else if (BoxesOverlap(ma.BoundingBox, mb.BoundingBox)) + boxOverlapClear++; + if (sample % 10 == 9) + centerAligned++; + decisions++; + } + } + output.WriteLine($"decisions={decisions}; overlaps={overlaps}; bbox-overlap but clear={boxOverlapClear}; center-aligned samples={centerAligned}"); + Assert.Equal(50000, decisions); + Assert.True(overlaps > 5000); + Assert.True(boxOverlapClear > 5000); + } + + [Fact] + public void SeededCheck_MatchesLegacyBitwise() + { + var random = new Random(9272026); + var compared = 0; + for (var pair = 0; pair < 60; pair++) + { + var a = Make(random, pair % 6); + var b = Make(random, (pair / 6) % 6); + var holesA = pair % 3 == 0 ? new List { Move(Square(0.7), 0.2, 0.2) } : null; + for (var sample = 0; sample < 40; sample++) + { + var (ax, ay, bx, by) = Offsets(random, a, b, sample); + var ma = Move(a, ax, ay); + var mb = Move(b, bx, by); + var ha = holesA?.Select(h => Move(h, ax, ay)).ToList(); + var expected = LegacyCollision.Check(ma, mb, ha); + var actual = Collision.Check(ma, mb, ha); + Assert.Equal(expected.Overlaps, actual.Overlaps); + Assert.Equal(Bits(expected.OverlapArea), Bits(actual.OverlapArea)); + Assert.Equal(expected.IntersectionPoints.SelectMany(VectorBits), actual.IntersectionPoints.SelectMany(VectorBits)); + Assert.Equal(expected.OverlapRegions.Count, actual.OverlapRegions.Count); + for (var r = 0; r < expected.OverlapRegions.Count; r++) + { + Assert.Equal(expected.OverlapRegions[r].Vertices.SelectMany(VectorBits), + actual.OverlapRegions[r].Vertices.SelectMany(VectorBits)); + Assert.Equal(BoxBits(expected.OverlapRegions[r].BoundingBox), BoxBits(actual.OverlapRegions[r].BoundingBox)); + } + compared++; + } + } + Assert.Equal(2400, compared); + } + + [Theory] + [InlineData(0.0, 0.0, true)] // identical squares + [InlineData(4.0, 4.0, true)] // b entirely inside a: no edge crossings + [InlineData(10.0, 0.0, false)] // exact edge contact + [InlineData(10.0000001, 0.0, false)] // separated by less than Epsilon + [InlineData(9.99999, 0.0, false)] // overlap below the area floor + [InlineData(9.9, 0.0, true)] // thin positive overlap + public void Containment_Contact_AndThinOverlap_MatchLegacy(double bx, double by, bool expected) + { + var a = Square(10); + var b = Move(bx == 0 && by == 0 ? Square(10) : Square(bx == 4 ? 1 : 10), bx, by); + Assert.Equal(expected, LegacyCollision.HasOverlap(a, b)); + Assert.Equal(expected, Collision.HasOverlap(a, b)); + Assert.Equal(expected, Collision.Check(a, b).Overlaps); + } + + [Fact] + public void HoleContainment_MatchesLegacy() + { + var outer = Square(10); + var holes = new List { Move(Square(6), 2, 2) }; + foreach (var (x, y, expected) in new[] { (4.0, 4.0, false), (1.5, 4.0, true), (2.0, 2.0, false), (1.9, 2.0, true) }) + { + var inner = Move(Square(1), x, y); + Assert.Equal(expected, LegacyCollision.HasOverlap(outer, inner, holes)); + Assert.Equal(expected, Collision.HasOverlap(outer, inner, holes)); + Assert.Equal(expected, Collision.HasOverlap(inner, outer, null, holes)); + } + } + + [Fact] + public void HasOverlap_DoesNotMutateInputs() + { + var a = Make(new Random(5), 1); + var b = Move(Make(new Random(6), 2), 0.5, 0.5); + var holes = new List { Move(Square(0.7), 0.2, 0.2) }; + var before = Snapshot(a, b, holes[0]); + Collision.HasOverlap(a, b, holes, null); + Collision.HasOverlap(b, a, null, holes); + Assert.Equal(before, Snapshot(a, b, holes[0])); + } + + /// + /// Malformed input (a null outer vertex list after bounds were cached) must still fail loudly + /// once the bounding boxes overlap, rather than reporting "no overlap". The exception type + /// is intentionally not pinned: skipping crossing points moves the first dereference from + /// Polygon.ToLines (NullReferenceException) to triangulation (ArgumentNullException). + /// Bounding-box rejection still returns false without touching the vertices. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NullOuterVertices_StillFailLoudly_UnlessBoxesAreSeparate(bool nullFirst) + { + var random = new Random(5); + var a = Make(random, 0); + var b = Move(Make(random, 0), 0.5, 0.5); + (nullFirst ? a : b).Vertices = null; + + Assert.ThrowsAny(() => LegacyCollision.Check(a, b)); + Assert.ThrowsAny(() => Collision.HasOverlap(a, b)); + Assert.ThrowsAny(() => Collision.Check(a, b)); + + var far = Move(Make(new Random(5), 0), 100, 100); + var broken = nullFirst ? a : b; + Assert.False(LegacyCollision.Check(broken, far).Overlaps); + Assert.False(Collision.HasOverlap(broken, far)); + } + +#if DEBUG + [Fact] + public void Work_HasOverlapSkipsCrossingPointScans() + { + var random = new Random(11); + var a = Make(random, 1); + var b = Move(Make(random, 2), 0.25, 0.25); + PerfCounters.Reset(); + try + { + for (var i = 0; i < 5; i++) + Collision.HasOverlap(a, b); + var overlapOnly = PerfCounters.CrossingPointScans; + PerfCounters.Reset(); + for (var i = 0; i < 5; i++) + Collision.Check(a, b); + var full = PerfCounters.CrossingPointScans; + output.WriteLine($"crossing-point scans: HasOverlap={overlapOnly}; Check={full}"); + Assert.Equal(0, overlapOnly); + Assert.Equal(5, full); + } + finally + { + PerfCounters.Reset(); + } + } +#endif + + internal static Polygon Make(Random random, int kind) + { + var size = 2 + random.NextDouble() * 2; + switch (kind) + { + case 0: + return Square(size); + case 1: + return Star(random); + case 2: + return Ring((0, 0), (size, 0), (size, 1), (1, 1), (1, size), (0, size)); + case 3: + return Ring((0, 0), (size, 0), (0, size)); + case 4: + // Concave comb: several notches that interlock with a translated copy. + return Ring((0, 0), (size, 0), (size, 1), (size * 0.75, 1), (size * 0.75, 0.4), + (size * 0.5, 0.4), (size * 0.5, 1), (size * 0.25, 1), (size * 0.25, 0.4), (0, 0.4)); + default: + var shape = new Shape(); + shape.Entities.Add(new Arc(0, 0, size / 2, 0, System.Math.PI)); + shape.Entities.Add(new Arc(0, 0, size / 2, System.Math.PI, 2 * System.Math.PI)); + return ClipperBridge.Flatten(shape, 0.08, circumscribe: false); + } + } + + private static (double, double, double, double) Offsets(Random random, Polygon a, Polygon b, int sample) + { + var ax = random.NextDouble() * 200 - 100; + var ay = random.NextDouble() * 200 - 100; + var bx = ax + random.NextDouble() * 8 - 4; + var by = ay + random.NextDouble() * 8 - 4; + if (sample % 5 == 0) + { + // Exact, near-touching and thin positive-area contacts at a box edge. + var gap = new[] { 0, -1e-7, 1e-7, -1e-5, 1e-5, -1e-4, 1e-4 }[(sample / 5) % 7]; + bx = ax + a.BoundingBox.Right - b.BoundingBox.Left + gap; + by = ay + a.BoundingBox.Bottom - b.BoundingBox.Bottom; + } + else if (sample % 10 == 9) + { + // Box centers coincide: probes containment and deep overlap (not verified per sample). + bx = ax + a.BoundingBox.Center.X - b.BoundingBox.Center.X; + by = ay + a.BoundingBox.Center.Y - b.BoundingBox.Center.Y; + } + return (ax, ay, bx, by); + } + + private static bool BoxesOverlap(Box a, Box b) => + System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left) > OpenNest.Math.Tolerance.Epsilon + && System.Math.Min(a.Top, b.Top) - System.Math.Max(a.Bottom, b.Bottom) > OpenNest.Math.Tolerance.Epsilon; + + private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); + + private static long[] VectorBits(Vector v) => new[] { Bits(v.X), Bits(v.Y) }; + + private static long[] BoxBits(Box box) => new[] { Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width) }; + + private static long[] Snapshot(params Polygon[] polygons) => + polygons.SelectMany(p => p.Vertices.SelectMany(VectorBits).Concat(BoxBits(p.BoundingBox)) + .Append(p.Vertices.Count)).ToArray(); +} diff --git a/OpenNest.Tests/Geometry/LegacyCollision.cs b/OpenNest.Tests/Geometry/LegacyCollision.cs new file mode 100644 index 0000000..71e6320 --- /dev/null +++ b/OpenNest.Tests/Geometry/LegacyCollision.cs @@ -0,0 +1,376 @@ +#nullable disable +// Frozen, test-only copy of OpenNest.Core/Geometry/Collision.cs at commit +// 82feb78b0fefdffc9ef9306205eeaf44e80e1d8d (before the overlap-only HasOverlap path). +// Only #nullable disable (restores the original compile context), the imports, namespace, type +// name and visibility differ from the original. Do not edit: +// differential tests use it as the independent pre-change oracle. +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.Geometry +{ + /// + /// Polygon overlap test with hole subtraction. This is the reference implementation + /// for a future GPU kernel, so it deliberately stays hand-rolled instead of using + /// Clipper (which is CPU-only and allocation-heavy; see + /// for the CPU preparation that feeds it). + /// + /// GPU-port contract. Per-polygon preparation, done once per drawing and rotation, + /// then cached and uploaded: the spacing offset (), + /// triangulation () of the outline and + /// each hole, and the bounding box of every polygon and triangle. Per-pair work, + /// kernel-shaped (fixed-size, loop-only, no recursion): the bounding-box rejects, + /// Sutherland-Hodgman clipping of convex triangle pairs (ClipConvex), and + /// subtraction of hole triangles from the clipped regions (SubtractTriangles). + /// Inputs are closed, lines-only polygons; winding is normalized by triangulation. + /// + /// + internal static class LegacyCollision + { + public static CollisionResult Check( + Polygon a, + Polygon b, + List holesA = null, + List holesB = null + ) + { + // Step 1: Bounding box pre-filter + if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox)) + return CollisionResult.None; + + // Step 2: Quick intersection test for crossing points + var intersectionPoints = FindCrossingPoints(a, b); + + // Step 3: Convex decomposition + var trisA = TriangulateWithBounds(a); + var trisB = TriangulateWithBounds(b); + + // Step 4: Clip all triangle pairs + var regions = new List(); + + foreach (var triA in trisA) + { + foreach (var triB in trisB) + { + if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox)) + continue; + + var clipped = ClipConvex(triA, triB); + if (clipped != null) + regions.Add(clipped); + } + } + + // Step 5: Hole subtraction + if (regions.Count > 0) + regions = SubtractHoles(regions, holesA, holesB); + + if (regions.Count == 0) + return new CollisionResult(false, regions, intersectionPoints); + + // Step 6: Build result + return new CollisionResult(true, regions, intersectionPoints); + } + + public static bool HasOverlap( + Polygon a, + Polygon b, + List holesA = null, + List holesB = null + ) + { + if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox)) + return false; + + // Full check is needed: crossing points alone miss containment cases + // (one polygon entirely inside another has zero edge crossings). + return Check(a, b, holesA, holesB).Overlaps; + } + + public static List CheckAll( + List polygons, + List> holes = null + ) + { + var results = new List(); + + for (var i = 0; i < polygons.Count; i++) + { + for (var j = i + 1; j < polygons.Count; j++) + { + var holesA = holes != null && i < holes.Count ? holes[i] : null; + var holesB = holes != null && j < holes.Count ? holes[j] : null; + var result = Check(polygons[i], polygons[j], holesA, holesB); + + if (result.Overlaps) + results.Add(result); + } + } + + return results; + } + + public static bool HasAnyOverlap(List polygons, List> holes = null) + { + for (var i = 0; i < polygons.Count; i++) + { + for (var j = i + 1; j < polygons.Count; j++) + { + var holesA = holes != null && i < holes.Count ? holes[i] : null; + var holesB = holes != null && j < holes.Count ? holes[j] : null; + + if (HasOverlap(polygons[i], polygons[j], holesA, holesB)) + return true; + } + } + + return false; + } + + private static bool BoundingBoxesOverlap(Box a, Box b) + { + var overlapX = System.Math.Min(a.Right, b.Right) - System.Math.Max(a.Left, b.Left); + var overlapY = System.Math.Min(a.Top, b.Top) - System.Math.Max(a.Bottom, b.Bottom); + + return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon; + } + + private static List FindCrossingPoints(Polygon a, Polygon b) + { + if (!Intersect.Intersects(a, b, out var rawPts)) + return new List(); + + // Filter boundary contacts (vertex touches) + var vertsA = CollectVertices(a); + var vertsB = CollectVertices(b); + var filtered = new List(); + + foreach (var pt in rawPts) + { + if (IsNearAnyVertex(pt, vertsA) || IsNearAnyVertex(pt, vertsB)) + continue; + filtered.Add(pt); + } + + return filtered; + } + + private static List CollectVertices(Polygon polygon) + { + var verts = new List(polygon.Vertices.Count); + foreach (var v in polygon.Vertices) + verts.Add(v); + return verts; + } + + private static bool IsNearAnyVertex(Vector pt, List vertices) + { + foreach (var v in vertices) + { + if (pt.X.IsEqualTo(v.X) && pt.Y.IsEqualTo(v.Y)) + return true; + } + return false; + } + + /// + /// Triangulates a polygon and ensures each triangle has its bounding box updated. + /// + private static List TriangulateWithBounds(Polygon polygon) + { + var tris = ConvexDecomposition.Triangulate(polygon); + foreach (var tri in tris) + tri.UpdateBounds(); + return tris; + } + + /// + /// Sutherland-Hodgman polygon clipping. Clips subject against each edge + /// of clip. Both must be convex. Returns null if no overlap. + /// + private static Polygon ClipConvex(Polygon subject, Polygon clip) + { + var output = OpenVertices(subject); + var clipVerts = OpenVertices(clip); + for (var i = 0; i < clipVerts.Count && output.Count >= 3; i++) + { + output = ClipHalfSpace(output, clipVerts[i], clipVerts[(i + 1) % clipVerts.Count], true); + } + + return PositiveAreaPolygon(output); + } + + /// + /// Cross product of vectors (edgeStart->edgeEnd) and (edgeStart->point). + /// Positive = point is left of edge (inside for CCW polygon). + /// + private static double Cross(Vector edgeStart, Vector edgeEnd, Vector point) + { + return (edgeEnd.X - edgeStart.X) * (point.Y - edgeStart.Y) + - (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X); + } + + /// + /// Subtracts holes from overlap regions. + /// + private static List SubtractHoles( + List regions, + List holesA, + List holesB + ) + { + var allHoles = new List(); + if (holesA != null) + allHoles.AddRange(holesA); + if (holesB != null) + allHoles.AddRange(holesB); + + if (allHoles.Count == 0) + return regions; + + foreach (var hole in allHoles) + { + var holeTris = TriangulateWithBounds(hole); + var surviving = new List(); + + foreach (var region in regions) + { + var pieces = SubtractTriangles(region, holeTris); + surviving.AddRange(pieces); + } + + regions = surviving; + + if (regions.Count == 0) + break; + } + + return regions; + } + + /// + /// Subtracts hole triangles from a convex region. At each edge, emit the outside + /// portion and carry only the inside remainder to the next edge. The emitted + /// pieces are disjoint and convex, so no repeated triangulation is needed. + /// + private static List SubtractTriangles(Polygon region, List holeTris) + { + var current = new List { region }; + + foreach (var holeTri in holeTris) + { + var next = new List(); + + foreach (var piece in current) + { + // Subtraction must also remove thin fragments created by clipping. + // The pair-level length tolerance would skip some of these even + // when their area is large enough to count as an overlap. + var a = piece.BoundingBox; + var b = holeTri.BoundingBox; + if (a.Right <= b.Left || b.Right <= a.Left || a.Top <= b.Bottom || b.Top <= a.Bottom) + { + next.Add(piece); + continue; + } + + var remainder = OpenVertices(piece); + var holeVerts = OpenVertices(holeTri); + for (var i = 0; i < holeVerts.Count && remainder.Count >= 3; i++) + { + var start = holeVerts[i]; + var end = holeVerts[(i + 1) % holeVerts.Count]; + var outside = PositiveAreaPolygon(ClipHalfSpace(remainder, start, end, false)); + if (outside != null) + next.Add(outside); + remainder = ClipHalfSpace(remainder, start, end, true); + } + } + + current = next; + if (current.Count == 0) + break; + } + + return current; + } + + /// + /// Clips an open vertex list against one half-space. Classification and + /// interpolation use the same signed cross products: intersections always + /// lie on the input segment. An epsilon-shifted inside test combined with + /// intersections on the unshifted line can extrapolate and create material. + /// Apply the area tolerance only to the resulting polygons, not to edge signs. + /// + private static List ClipHalfSpace( + List vertices, + Vector edgeStart, + Vector edgeEnd, + bool inside + ) + { + var kept = new List(); + for (var i = 0; i < vertices.Count; i++) + { + var current = vertices[i]; + var next = vertices[(i + 1) % vertices.Count]; + var currentDistance = Cross(edgeStart, edgeEnd, current); + var nextDistance = Cross(edgeStart, edgeEnd, next); + if (inside ? currentDistance >= 0 : currentDistance <= 0) + AddDistinct(kept, current); + + // Only strict opposite signs cross the line. Boundary endpoints + // are already kept, and near-parallel crossings need no cutoff. + if ((currentDistance < 0 && nextDistance > 0) || (currentDistance > 0 && nextDistance < 0)) + { + var t = currentDistance / (currentDistance - nextDistance); + AddDistinct(kept, new Vector( + current.X + t * (next.X - current.X), + current.Y + t * (next.Y - current.Y))); + } + } + if (kept.Count > 1 && SamePoint(kept[0], kept[kept.Count - 1])) + kept.RemoveAt(kept.Count - 1); + return kept; + } + + private static bool SamePoint(Vector a, Vector b) => a.X == b.X && a.Y == b.Y; + + private static void AddDistinct(List vertices, Vector point) + { + if (vertices.Count == 0 || !SamePoint(vertices[vertices.Count - 1], point)) + vertices.Add(point); + } + + private static List OpenVertices(Polygon polygon) + { + var vertices = new List(polygon.Vertices); + if (vertices.Count > 1 && SamePoint(vertices[0], vertices[vertices.Count - 1])) + vertices.RemoveAt(vertices.Count - 1); + return vertices; + } + + private static Polygon PositiveAreaPolygon(List vertices) + { + if (vertices.Count < 3) + return null; + + // Measure relative to a vertex to avoid cancellation of world-coordinate + // products when a small clipped fragment is far from the origin. + var twiceArea = 0.0; + for (var i = 1; i + 1 < vertices.Count; i++) + twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]); + if (System.Math.Abs(twiceArea) <= 2 * Tolerance.Epsilon) + return null; + + var polygon = new Polygon(); + polygon.Vertices.AddRange(vertices); + // Polygon.Close uses fuzzy Vector equality; clipping needs an exact + // closing vertex even when the last edge is shorter than Epsilon. + polygon.Vertices.Add(vertices[0]); + polygon.UpdateBounds(); + return polygon; + } + } +}