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>
|
/// <summary>
|
||||||
/// Subtracts hole triangles from a region. Conservative: partial overlaps
|
/// Subtracts hole triangles from a region. Exact: a piece outside a convex hole
|
||||||
/// keep the full piece triangle (acceptable for visual shading).
|
/// 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>
|
/// </summary>
|
||||||
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
||||||
{
|
{
|
||||||
@@ -295,29 +297,25 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
foreach (var holeTri in holeTris)
|
foreach (var holeTri in holeTris)
|
||||||
{
|
{
|
||||||
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var next = new List<Polygon>();
|
var next = new List<Polygon>();
|
||||||
|
|
||||||
foreach (var piece in current)
|
foreach (var piece in current)
|
||||||
{
|
{
|
||||||
var pieceTris = TriangulateWithBounds(piece);
|
if (!BoundingBoxesOverlap(piece.BoundingBox, holeTri.BoundingBox))
|
||||||
|
|
||||||
foreach (var pieceTri in pieceTris)
|
|
||||||
{
|
{
|
||||||
var inside = ClipConvex(pieceTri, holeTri);
|
next.Add(piece);
|
||||||
if (inside == null)
|
continue;
|
||||||
{
|
}
|
||||||
// No overlap with hole - keep
|
|
||||||
next.Add(pieceTri);
|
foreach (var pieceTri in TriangulateWithBounds(piece))
|
||||||
}
|
{
|
||||||
else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon)
|
var holeVerts = holeTri.Vertices;
|
||||||
{
|
var holeCount = holeTri.IsClosed() ? holeVerts.Count - 1 : holeVerts.Count;
|
||||||
// Partial overlap - keep the piece (conservative)
|
var survived = false;
|
||||||
next.Add(pieceTri);
|
for (var i = 0; i < holeCount; i++)
|
||||||
}
|
survived |= AddIfPositiveArea(next,
|
||||||
// else: fully inside hole - discard
|
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;
|
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);
|
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]
|
[Fact]
|
||||||
public void UnknownOrOverproducingCandidateFailsBeforeCommitWithoutChangingInput()
|
public void UnknownOrOverproducingCandidateFailsBeforeCommitWithoutChangingInput()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -262,65 +262,10 @@ internal static class NestJobPlacementValidator
|
|||||||
return false;
|
return false;
|
||||||
// True material overlap requires shared interior area, not boundary touching.
|
// True material overlap requires shared interior area, not boundary touching.
|
||||||
// Edge/corner contact (zero clearance) is a valid placement when part spacing is zero.
|
// Edge/corner contact (zero clearance) is a valid placement when part spacing is zero.
|
||||||
return InteriorOverlap(leftPoly, left, rightPoly, right);
|
// 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.
|
||||||
private static bool InteriorOverlap(Polygon leftPoly, ShapeTopology left, Polygon rightPoly, ShapeTopology right)
|
return Collision.HasOverlap(leftPoly, rightPoly, ToPolygons(left.Cutouts), ToPolygons(right.Cutouts));
|
||||||
{
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -360,14 +305,6 @@ internal static class NestJobPlacementValidator
|
|||||||
private static double IsLeft(Vector p1, Vector p2, Vector p) =>
|
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);
|
(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)
|
private static double Distance(ShapeTopology left, ShapeTopology right)
|
||||||
{
|
{
|
||||||
var result = double.PositiveInfinity;
|
var result = double.PositiveInfinity;
|
||||||
|
|||||||
Reference in New Issue
Block a user