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.
This commit is contained in:
aj
2026-09-27 12:54:20 -04:00
parent a98a49c00a
commit 2b5485f6cf
4 changed files with 679 additions and 30 deletions
+44 -30
View File
@@ -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<Polygon>();
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<CollisionResult> CheckAll(
@@ -121,6 +98,42 @@ namespace OpenNest.Geometry
return false;
}
/// <summary>
/// Positive-area overlap regions left after hole subtraction: the verdict shared by
/// <see cref="Check"/> and <see cref="HasOverlap"/>. Callers apply the polygon-level
/// bounding-box pre-filter first.
/// </summary>
private static List<Polygon> OverlapRegions(
Polygon a,
Polygon b,
List<Polygon> holesA,
List<Polygon> holesB
)
{
var trisA = TriangulateWithBounds(a);
var trisB = TriangulateWithBounds(b);
var regions = new List<Polygon>();
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<Vector> FindCrossingPoints(Polygon a, Polygon b)
{
PerfCounters.CountCrossingPointScan();
if (!Intersect.Intersects(a, b, out var rawPts))
return new List<Vector>();