From dceb5f7d18dd9e8a277345b73ecca4e718784424 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 09:25:26 -0400 Subject: [PATCH] test(geometry): cover Collision with ClipperBridge inputs; document GPU contract Collision stays hand-rolled because it is the reference for a future GPU kernel, but its inputs now come from Clipper region offsets. Pin down that lines-only, round-join, 1e-4-precision polygons keep the contact and part-in-part semantics: a neighbor inside a collapsed slot, a part inside a hole that shrank by the spacing, and zero-spacing edge contact. Document which steps are per-polygon preparation to cache and upload once, and which are per-pair kernel-shaped work. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/Collision.cs | 16 +++++ OpenNest.Tests/Geometry/CollisionTests.cs | 79 +++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/OpenNest.Core/Geometry/Collision.cs b/OpenNest.Core/Geometry/Collision.cs index 331f04d..4e639b4 100644 --- a/OpenNest.Core/Geometry/Collision.cs +++ b/OpenNest.Core/Geometry/Collision.cs @@ -3,6 +3,22 @@ using OpenNest.Math; namespace OpenNest.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. + /// + /// public static class Collision { public static CollisionResult Check( diff --git a/OpenNest.Tests/Geometry/CollisionTests.cs b/OpenNest.Tests/Geometry/CollisionTests.cs index 488107c..a716184 100644 --- a/OpenNest.Tests/Geometry/CollisionTests.cs +++ b/OpenNest.Tests/Geometry/CollisionTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using OpenNest.Geometry; using OpenNest.Math; @@ -204,6 +205,84 @@ public class CollisionTests Assert.False(Collision.HasAnyOverlap(new List())); } + // The cases below feed Collision with ClipperBridge offsets, the way the spacing + // checks prepare their inputs: lines only, round joins, 1e-4 precision. + + [Theory] + [InlineData(4.9, 5.1, true)] // Inside the collapsed slot: 0.05 from its walls. + [InlineData(10.3, 12, false)] // Beside the part, 0.3 away. + public void HasOverlap_NeighborOfPartWithCollapsedSlot( + double left, + double right, + bool expected + ) + { + // 10x10 part with a 0.3-wide slot down from the top, inflated by 0.25. + var part = MakeProfile( + MakePolygon((0, 0), (10, 0), (10, 10), (5.15, 10), (5.15, 7), (4.85, 7), (4.85, 10), (0, 10)) + ); + var inflated = ClipperBridge.Offset(part, 0.25, 0.001); + var neighbor = MakeSquare(left, 8, right, 10); + + Assert.Equal( + expected, + Collision.HasOverlap(inflated.LargestOuter(), neighbor, inflated.Holes) + ); + } + + [Theory] + [InlineData(5.5, 14.5, false)] // 0.5 from the hole's edges. + [InlineData(5.1, 14.9, true)] // 0.1 from the hole's edges. + public void HasOverlap_PartInsideHoleShrunkBySpacing(double min, double max, bool expected) + { + var part = MakeProfile( + MakePolygon((0, 0), (20, 0), (20, 20), (0, 20)), + MakePolygon((5, 5), (15, 5), (15, 15), (5, 15)) + ); + var inflated = ClipperBridge.Offset(part, 0.25, 0.001); + var inner = MakeSquare(min, min, max, max); + + Assert.Single(inflated.Holes); + Assert.Equal( + expected, + Collision.HasOverlap(inflated.LargestOuter(), inner, inflated.Holes) + ); + } + + [Fact] + public void HasOverlap_ZeroSpacingEdgeContact_ReturnsFalse() + { + var a = ClipperBridge.Offset( + MakeProfile(MakePolygon((0, 0), (10, 0), (10, 10), (0, 10))), + 0, + 0.001 + ); + var b = ClipperBridge.Offset( + MakeProfile(MakePolygon((10, 0), (20, 0), (20, 10), (10, 10))), + 0, + 0.001 + ); + + Assert.False(Collision.HasOverlap(a.LargestOuter(), b.LargestOuter())); + } + + private static Shape MakePolygon(params (double X, double Y)[] pts) + { + var shape = new Shape(); + + for (var i = 0; i < pts.Length; i++) + { + var from = pts[i]; + var to = pts[(i + 1) % pts.Length]; + shape.Entities.Add(new Line(from.X, from.Y, to.X, to.Y)); + } + + return shape; + } + + private static ShapeProfile MakeProfile(params Shape[] shapes) => + new(shapes.SelectMany(s => s.Entities).ToList()); + private static Polygon MakeSquare(double left, double bottom, double right, double top) { var p = new Polygon();