fix(geometry): correct overlap detection for real-world CNC shapes

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.
This commit is contained in:
aj
2026-09-22 09:51:02 -04:00
parent c28bc0da21
commit 6f250b5730
3 changed files with 30 additions and 4 deletions
+2
View File
@@ -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;
}
+9 -2
View File
@@ -20,6 +20,13 @@ namespace OpenNest
public class Part : IPart, IBoundable
{
/// <summary>
/// Chord tolerance used to polygonize arcs/circles for overlap testing in
/// <see cref="Intersects"/>. Matches the tolerance used elsewhere for
/// geometry-sensitive checks (e.g. <see cref="PartGeometry"/>).
/// </summary>
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;
+19 -2
View File
@@ -13,6 +13,15 @@ namespace OpenNest.Engine.BestFit
{
private const double ChordTolerance = 0.01;
/// <summary>
/// Tighter chord tolerance for the overlap check only. Rounded-corner arcs
/// polygonized at the coarser <see cref="ChordTolerance"/> 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.
/// </summary>
private const double OverlapChordTolerance = 0.001;
public List<BestFitResult> EvaluateAll(List<PairCandidate> 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);