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;