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:
@@ -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 < -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;
|
||||
|
||||
Reference in New Issue
Block a user