diff --git a/OpenNest.Core/Geometry/Collision.cs b/OpenNest.Core/Geometry/Collision.cs
index 956f73d..d48ecf5 100644
--- a/OpenNest.Core/Geometry/Collision.cs
+++ b/OpenNest.Core/Geometry/Collision.cs
@@ -286,8 +286,10 @@ namespace OpenNest.Geometry
}
///
- /// Subtracts hole triangles from a region. Conservative: partial overlaps
- /// keep the full piece triangle (acceptable for visual shading).
+ /// Subtracts hole triangles from a region. Exact: a piece outside a convex hole
+ /// triangle equals the union of its clips against each triangle edge's outside
+ /// half-space, so overlap confined to a cutout disappears while any material
+ /// sliver outside the hole survives.
///
private static List SubtractTriangles(Polygon region, List holeTris)
{
@@ -295,29 +297,25 @@ namespace OpenNest.Geometry
foreach (var holeTri in holeTris)
{
- if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
- continue;
-
var next = new List();
foreach (var piece in current)
{
- var pieceTris = TriangulateWithBounds(piece);
-
- foreach (var pieceTri in pieceTris)
+ if (!BoundingBoxesOverlap(piece.BoundingBox, holeTri.BoundingBox))
{
- var inside = ClipConvex(pieceTri, holeTri);
- if (inside == null)
- {
- // No overlap with hole - keep
- next.Add(pieceTri);
- }
- else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon)
- {
- // Partial overlap - keep the piece (conservative)
- next.Add(pieceTri);
- }
- // else: fully inside hole - discard
+ next.Add(piece);
+ continue;
+ }
+
+ foreach (var pieceTri in TriangulateWithBounds(piece))
+ {
+ var holeVerts = holeTri.Vertices;
+ var holeCount = holeTri.IsClosed() ? holeVerts.Count - 1 : holeVerts.Count;
+ var survived = false;
+ for (var i = 0; i < holeCount; i++)
+ survived |= AddIfPositiveArea(next,
+ ClipOutsideHalfSpace(pieceTri, holeVerts[i], holeVerts[(i + 1) % holeCount]));
+ if (!survived) continue; // piece lies entirely within the hole
}
}
@@ -326,5 +324,40 @@ namespace OpenNest.Geometry
return current;
}
+
+ ///
+ /// Sutherland-Hodgman clip of a convex polygon to the strict outside of the
+ /// infinite line edgeStart->edgeEnd of a CCW hole edge (Cross < -Epsilon).
+ ///
+ private static List ClipOutsideHalfSpace(Polygon piece, Vector edgeStart, Vector edgeEnd)
+ {
+ var verts = piece.Vertices;
+ var count = piece.IsClosed() ? verts.Count - 1 : verts.Count;
+ var kept = new List();
+ for (var i = 0; i < count; i++)
+ {
+ var current = verts[i];
+ var next = verts[(i + 1) % count];
+ var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
+ var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
+ if (!currentInside) kept.Add(current);
+ if (currentInside == nextInside) continue;
+ var intersection = LineIntersection(edgeStart, edgeEnd, current, next);
+ if (intersection.IsValid()) kept.Add(intersection);
+ }
+ return kept;
+ }
+
+ private static bool AddIfPositiveArea(List polygons, List vertices)
+ {
+ if (vertices.Count < 3) return false;
+ var polygon = new Polygon();
+ polygon.Vertices.AddRange(vertices);
+ polygon.Close();
+ polygon.UpdateBounds();
+ if (polygon.Area() <= Tolerance.Epsilon) return false;
+ polygons.Add(polygon);
+ return true;
+ }
}
}
diff --git a/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs
index 2f6a433..2d751cb 100644
--- a/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs
@@ -41,6 +41,33 @@ public class NestJobValidationTests
Assert.Equal(2, result.Plates[0].Placements.Count);
}
+ [Fact]
+ public void SmallCornerOverlapIsRejected()
+ {
+ var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(10, 10)), 2);
+ var job = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
+
+ Assert.Throws(() => Solve(job,
+ new NestJobPlacement("part", 0, 0, 0, 0),
+ new NestJobPlacement("part", 1, 9, 9, 0)));
+ }
+
+ [Theory]
+ [InlineData(10.0, 0.0)]
+ [InlineData(10.0, 10.0)]
+ public void BoundaryContactWithZeroSpacingIsAccepted(double x, double y)
+ {
+ var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(10, 10)), 2);
+ var job = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
+
+ var result = Solve(job,
+ new NestJobPlacement("part", 0, 0, 0, 0),
+ new NestJobPlacement("part", 1, x, y, 0));
+
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
+ }
+
[Fact]
public void UnknownOrOverproducingCandidateFailsBeforeCommitWithoutChangingInput()
{
diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
index 84d7ff7..d119b3b 100644
--- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
+++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
@@ -262,65 +262,10 @@ internal static class NestJobPlacementValidator
return false;
// True material overlap requires shared interior area, not boundary touching.
// Edge/corner contact (zero clearance) is a valid placement when part spacing is zero.
- return InteriorOverlap(leftPoly, left, rightPoly, right);
- }
-
- private static bool InteriorOverlap(Polygon leftPoly, ShapeTopology left, Polygon rightPoly, ShapeTopology right)
- {
- // The intersection of two polygons is either empty, a region of positive area (true overlap),
- // or a zero-area line/point (boundary contact). Test the interior of the intersection region:
- // a point strictly inside BOTH perimeters and outside both parts' holes proves shared material.
- foreach (var point in InteriorWitnessPoints(leftPoly, rightPoly))
- {
- if (StrictlyInside(leftPoly, point) && !InAnyHole(left, point) &&
- StrictlyInside(rightPoly, point) && !InAnyHole(right, point))
- return true;
- }
- return false;
- }
-
- ///
- /// Points that lie in the interior of the perimeter-perimeter intersection when one exists.
- /// For each pair of crossing edges, the two interior-side vertices (one from each polygon)
- /// have their midpoint inside both perimeters; that midpoint is a witness of positive-area
- /// overlap. For containment, an interior vertex of the inner perimeter witnesses it.
- ///
- private static IEnumerable InteriorWitnessPoints(Polygon left, Polygon right)
- {
- foreach (var l in left.ToLines())
- foreach (var r in right.ToLines())
- if (l.Intersects(r, out var pt) && pt.IsValid())
- {
- yield return Midpoint(l, pt);
- yield return Midpoint(r, pt);
- }
- // Containment: an interior point of one polygon inside the other. Use a point pulled
- // toward the centroid of each polygon from a vertex (guaranteed interior for simple shapes).
- foreach (var poly in new[] { left, right })
- {
- foreach (var vertex in poly.Vertices)
- {
- var centroid = Centroid(poly);
- yield return (vertex + centroid) * 0.5;
- }
- }
- }
-
- private static Vector Midpoint(Line line, Vector point)
- {
- var other = line.StartPoint.DistanceTo(point) <= line.EndPoint.DistanceTo(point)
- ? line.EndPoint
- : line.StartPoint;
- return (other + point) * 0.5;
- }
-
- private static Vector Centroid(Polygon polygon)
- {
- var n = polygon.IsClosed() ? polygon.Vertices.Count - 1 : polygon.Vertices.Count;
- var sum = Vector.Zero;
- for (var i = 0; i < n; i++)
- sum += polygon.Vertices[i];
- return sum / n;
+ // Collision checks this by clipping triangulated polygons and rejecting zero-area
+ // slivers, so it catches containment and small corner intersections that a witness
+ // probe can miss, while contact stays legal; cutouts are subtracted from both sides.
+ return Collision.HasOverlap(leftPoly, rightPoly, ToPolygons(left.Cutouts), ToPolygons(right.Cutouts));
}
///
@@ -360,14 +305,6 @@ internal static class NestJobPlacementValidator
private static double IsLeft(Vector p1, Vector p2, Vector p) =>
(p2.X - p1.X) * (p.Y - p1.Y) - (p2.Y - p1.Y) * (p.X - p1.X);
- private static bool InAnyHole(ShapeTopology topology, Vector point)
- {
- foreach (var cutout in topology.Cutouts)
- if (ToPolygon(cutout).ContainsPoint(point))
- return true;
- return false;
- }
-
private static double Distance(ShapeTopology left, ShapeTopology right)
{
var result = double.PositiveInfinity;