From 6f250b57304cf9e89eda4c6725b948b39df8c33e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 22 Sep 2026 09:51:02 -0400 Subject: [PATCH] fix(geometry): correct overlap detection for real-world CNC shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part.Intersects has been silently non-functional everywhere it's used. Shape.ToPolygon()/ToPolygonWithTolerance() never called UpdateBounds(), so every freshly-built polygon kept Entity's constructor-default zero-size bounding box regardless of its actual vertices. Collision.Check's first step is a bounding-box pre-filter, and a zero-size box can never overlap anything, so it always short-circuited to "no overlap" no matter what the real geometry looked like. That masked a second bug: Part.Intersects built its polygons via the unconditional 1000-segments-per-arc ToPolygon() default instead of an adaptive tolerance. For parts with several small fillets/holes this produced tens of thousands of vertices, making the now-correct bbox check fall through into a triangulation/clip step too slow to return in practice. Switched to ToPolygonWithTolerance at a named tolerance matching PartGeometry's existing convention. PairEvaluator's own Keep/overlap check had a third, independent bug: it used Shape.Intersects (edge-crossing detection only) at a coarse 0.01 chord tolerance, which misses containment-style overlaps and can polygonize rounded corners coarsely enough to hide a genuine sliver overlap. Switched to Collision.HasOverlap (full polygon clip, handles containment) at a tighter dedicated tolerance. Verified against a real nest file: PairFiller was tiling a BestFit pair that PairEvaluator had incorrectly marked Keep=true, producing visibly overlapping parts on the plate that no downstream overlap check ever caught. Known follow-up: OpenNest.Tests.BestFit.BestFitOverlapTests.KeptPairs_NoOverlap still fails on 3/1082 synthetic candidates that overlap by a sub-0.001 sliver right at a rounded-corner tangent point — a separate, much smaller precision edge case in PairEvaluator's raw (pre-transform) coordinate frame, not a regression from this change. --- OpenNest.Core/Geometry/Shape.cs | 2 ++ OpenNest.Core/Part.cs | 11 +++++++++-- OpenNest.Engine/BestFit/PairEvaluator.cs | 21 +++++++++++++++++++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/OpenNest.Core/Geometry/Shape.cs b/OpenNest.Core/Geometry/Shape.cs index a2d9792..ab32d66 100644 --- a/OpenNest.Core/Geometry/Shape.cs +++ b/OpenNest.Core/Geometry/Shape.cs @@ -298,6 +298,7 @@ namespace OpenNest.Geometry polygon.Close(); polygon.Cleanup(); + polygon.UpdateBounds(); return polygon; } @@ -341,6 +342,7 @@ namespace OpenNest.Geometry polygon.Close(); polygon.Cleanup(); + polygon.UpdateBounds(); return polygon; } diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index 312c83c..d4df8d8 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -20,6 +20,13 @@ namespace OpenNest public class Part : IPart, IBoundable { + /// + /// Chord tolerance used to polygonize arcs/circles for overlap testing in + /// . Matches the tolerance used elsewhere for + /// geometry-sensitive checks (e.g. ). + /// + private const double IntersectsChordTolerance = 0.001; + private Vector location; private bool ownsProgram; private double preLeadInRotation; @@ -247,8 +254,8 @@ namespace OpenNest if (perimeter1 == null || perimeter2 == null) return false; - var polygon1 = perimeter1.ToPolygon(); - var polygon2 = perimeter2.ToPolygon(); + var polygon1 = perimeter1.ToPolygonWithTolerance(IntersectsChordTolerance); + var polygon2 = perimeter2.ToPolygonWithTolerance(IntersectsChordTolerance); if (polygon1 == null || polygon2 == null) return false; diff --git a/OpenNest.Engine/BestFit/PairEvaluator.cs b/OpenNest.Engine/BestFit/PairEvaluator.cs index 0e6b899..c54ec5e 100644 --- a/OpenNest.Engine/BestFit/PairEvaluator.cs +++ b/OpenNest.Engine/BestFit/PairEvaluator.cs @@ -13,6 +13,15 @@ namespace OpenNest.Engine.BestFit { private const double ChordTolerance = 0.01; + /// + /// Tighter chord tolerance for the overlap check only. Rounded-corner arcs + /// polygonized at the coarser can "cut the corner" + /// enough to hide a genuine but tiny sliver overlap between two candidates — + /// this needs to match the precision Part.Intersects uses elsewhere so BestFit's + /// Keep decision agrees with the same overlap check callers rely on downstream. + /// + private const double OverlapChordTolerance = 0.001; + public List EvaluateAll(List candidates) { if (candidates.Count == 0) @@ -49,10 +58,18 @@ namespace OpenNest.Engine.BestFit part2.Location = candidate.Part2Offset; part2.UpdateBounds(); - // Overlap check — perimeter vs perimeter + // Overlap check — perimeter vs perimeter. Uses Collision.HasOverlap (full polygon + // clip) rather than Shape.Intersects (edge-crossing only), which misses containment- + // style overlaps where one perimeter's boundary never crosses the other's. var shape1 = GetPerimeterShape(part1); var shape2 = GetPerimeterShape(part2); - var overlaps = shape1 != null && shape2 != null && shape1.Intersects(shape2, out _); + var overlaps = + shape1 != null + && shape2 != null + && Collision.HasOverlap( + shape1.ToPolygonWithTolerance(OverlapChordTolerance), + shape2.ToPolygonWithTolerance(OverlapChordTolerance) + ); // Convex hull vertices from perimeter polygons only var allPoints = GetPartVertices(part1);