fix(engine): reject small corner overlaps in placement validation

The witness-probe overlap test missed small corner intersections: its
candidate points (crossing-edge midpoints and vertex-centroid midpoints)
can all land on a part boundary or outside the intersection, so two 10x10
parts at (0,0) and (9,9) with zero spacing were accepted despite sharing
a 1x1 unit of material.

Route the overlap decision through Collision, which clips triangulated
polygons and keeps only positive-area regions, catching corner overlaps,
containment, and coincident poses while legal edge/corner contact stays
legal. Collision's hole subtraction was conservative (partially-clipped
triangles were kept whole), so a part inside another part's cutout could
false-positive depending on triangulation alignment; subtract holes
exactly instead: a piece outside a convex hole triangle is the union of
its clips against each edge's outside half-space.
This commit is contained in:
aj
2026-09-20 13:40:45 -04:00
parent ea4bd836cd
commit 1a05391d94
3 changed files with 84 additions and 87 deletions
+53 -20
View File
@@ -286,8 +286,10 @@ namespace OpenNest.Geometry
}
/// <summary>
/// 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.
/// </summary>
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
{
@@ -295,29 +297,25 @@ namespace OpenNest.Geometry
foreach (var holeTri in holeTris)
{
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
continue;
var next = new List<Polygon>();
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;
}
/// <summary>
/// Sutherland-Hodgman clip of a convex polygon to the strict outside of the
/// infinite line edgeStart->edgeEnd of a CCW hole edge (Cross &lt; -Epsilon).
/// </summary>
private static List<Vector> ClipOutsideHalfSpace(Polygon piece, Vector edgeStart, Vector edgeEnd)
{
var verts = piece.Vertices;
var count = piece.IsClosed() ? verts.Count - 1 : verts.Count;
var kept = new List<Vector>();
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<Polygon> polygons, List<Vector> 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;
}
}
}
@@ -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<InvalidOperationException>(() => 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()
{
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
private static IEnumerable<Vector> 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));
}
/// <summary>
@@ -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;