From 2b5485f6cf977d1a785ebcd5776dbe8b1eb6c6b1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 27 Sep 2026 12:54:20 -0400 Subject: [PATCH] perf(core): skip unused crossing points in overlap-only checks Collision.HasOverlap only needs the verdict, but it went through Check, which also collected crossing points. Triangulation, clipping and hole subtraction now live in one private OverlapRegions method shared by Check and HasOverlap, so verdict arithmetic stays single-sourced; Check output is unchanged. Tests: a frozen copy of the previous Collision is the oracle. 50,000 seeded HasOverlap verdicts and 2,400 bitwise Check results match it, plus containment, contact, hole and input-immutability cases. A Debug-only PerfCounters.CrossingPointScans counter proves HasOverlap no longer scans. Malformed polygons with null outer vertices still throw when the bounding boxes overlap (now ArgumentNullException from triangulation rather than NullReferenceException from ToLines). Measured (Release, same harness in both trees): about 44% less time per overlap-only polygon check, allocations 10.0 -> 7.9 MB per 155-pair sweep. The 169-part serialized corpus layout is byte-identical. --- OpenNest.Core/Geometry/Collision.cs | 74 ++-- OpenNest.Core/PerfCounters.cs | 6 + .../Geometry/CollisionOverlapOnlyTests.cs | 253 ++++++++++++ OpenNest.Tests/Geometry/LegacyCollision.cs | 376 ++++++++++++++++++ 4 files changed, 679 insertions(+), 30 deletions(-) create mode 100644 OpenNest.Tests/Geometry/CollisionOverlapOnlyTests.cs create mode 100644 OpenNest.Tests/Geometry/LegacyCollision.cs 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; + } + } +}