From c1c8d5fc36abc71ba63a04a5f7c575d7ff7d799f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 6 Oct 2026 00:03:00 -0400 Subject: [PATCH] fix(cutting): widen arc extents to their native contact band Review of the extent filter (bfe2e51) found it could skip checks the native queries would have flagged. ContactAfterStart counts a point as touching an arc while its squared distance from the centre is within 1e-8 x max(1, 2r) of r^2, which for small arcs reaches well past the radius (up to 1e-4 as r -> 0), more than the filter's margin. A rapid 0.000002 outside a radius-0.001 circle lost its crossing, the pre-post review lost the same finding, and a lead grazing a radius-0.003 circle turned from an uncertain check into a clear one. An arc's extent now uses that contact reach, computed from the same slack expression as ContactAfterStart, so every point a native query can count as contact lies inside it. The three cases above are regression tests. --- .../Diagnostics/PostVerificationGeometry.cs | 17 +++- .../NearbyMaterialCheckTests.cs | 83 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index b577764..c8aeda7 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -106,11 +106,13 @@ internal static class PostVerificationGeometry : new Line(Start, End); /// - /// A conservative axis-aligned extent: an arc's whole supporting circle, a line's endpoints. - /// Nonfinite geometry yields NaN bounds, which no separation test can pass. + /// A conservative axis-aligned extent of everything a native query may count as touching + /// this curve: for an arc, its whole supporting circle widened by the contact band of + /// ; for a line, its endpoints. Nonfinite geometry yields NaN + /// bounds, which no separation test can pass. /// internal Extent Extent => Center is { } center - ? new(center.X - Radius, center.Y - Radius, center.X + Radius, center.Y + Radius) + ? new(center.X - ContactReach, center.Y - ContactReach, center.X + ContactReach, center.Y + ContactReach) : new(System.Math.Min(Start.X, End.X), System.Math.Min(Start.Y, End.Y), System.Math.Max(Start.X, End.X), System.Math.Max(Start.Y, End.Y)); @@ -246,6 +248,13 @@ internal static class PostVerificationGeometry point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius || point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon; + // How far past r^2 a squared distance from the centre still counts as touching the arc. + // For small arcs this band reaches well beyond the radius (sqrt(1e-8) = 1e-4 as r -> 0). + private double ContactSlack => Epsilon * System.Math.Max(1, Radius * 2); + + // The largest distance from the centre that ContactAfterStart can count as contact. + private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack); + internal bool ContactAfterStart(Vector origin, Vector direction, double length) { if (Center is { } center) @@ -256,7 +265,7 @@ internal static class PostVerificationGeometry var projection = Dot(relative, direction); var perpendicular = Cross(relative, direction); var square = Radius * Radius - perpendicular * perpendicular; - if (square < -Epsilon * System.Math.Max(1, Radius * 2)) + if (square < -ContactSlack) return false; var offset = System.Math.Sqrt(System.Math.Max(0, square)); return Hit(projection - offset) || Hit(projection + offset); diff --git a/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs b/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs index 4f83c89..a9455d8 100644 --- a/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs +++ b/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs @@ -80,6 +80,89 @@ public class NearbyMaterialCheckTests Assert.Contains(findings, f => f.Kind == PostVerificationKind.RapidCrossing && f.OtherPartNumber == 1); } + [Fact] + public void RapidJustOutsideASmallCompletedCircle_IsStillACrossing() + { + // A circle of radius 0.001 counts as touched up to sqrt(r^2 + 1e-8) from its centre, beyond + // its radius; the rapid passes 0.000002 outside the radius, inside that band. + var state = new ReleasedContourState(); + Assert.Empty(state.Check(Read(Circle(0.001)), Vector.Zero, 1) + .Where(f => f.Kind == PostVerificationKind.RapidCrossing)); + var next = new Program(); + next.MoveTo(0.001002, 0.002); + + var findings = state.Check(Read(next), new Vector(0.001002, -0.002), 2); + + Assert.Contains(findings, f => f.Kind == PostVerificationKind.RapidCrossing && f.OtherPartNumber == 1); + } + + [Fact] + public void PrePostReview_RapidJustOutsideASmallCircle_IsStillReported() + { + const double radius = 0.003; + const double y = radius + 0.0000015; + var first = new Part(new Drawing("circle", CleanCircle(radius))); + var circle = Circle(radius); + circle.Codes.Add(new LinearMove(-0.01, y) { Layer = LayerType.Scribe }); // Ends left of the circle. + Assert.True(first.RestoreLeadInProgram(circle, true)); + var second = new Part(new Drawing("square", + LeadPathValidationTests.Rectangle(0.01, y + 0.0002, 0.02, y + 0.0102))); + var square = new Program(); + square.MoveTo(0.01, y); + square.Codes.Add(new LinearMove(0.01, y + 0.0002) { Layer = LayerType.Leadin }); + square.LineTo(0.01, y + 0.0102); square.LineTo(0.02, y + 0.0102); + square.LineTo(0.02, y + 0.0002); square.LineTo(0.01, y + 0.0002); + Assert.True(second.RestoreLeadInProgram(square, true)); + var nest = new Nest(); + var plate = nest.CreatePlate(); + plate.Parts.Add(first); + plate.Parts.Add(second); + + var report = PostVerificationAnalyzer.Analyze(nest); + + Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.RapidCrossing + && f.PartNumber == 2 && f.OtherPartNumber == 1); + } + + [Fact] + public void LeadGrazingASmallCircle_StaysAnUncertainCheck() + { + // The lead passes 0.0000015 outside a radius-0.003 circle: inside its native contact band, + // where the native query cannot certify contact either way. + const double radius = 0.003; + const double y = radius + 0.0000015; + var target = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(0.01, y, 0.02, y + 0.01), Vector.Zero); + var other = LeadMaterialSnapshot.Capture(CleanCircle(radius), Vector.Zero); + Assert.True(other.IsComplete, other.Reason); + var lead = new Program(); + lead.MoveTo(-0.01, y); + lead.Codes.Add(new LinearMove(0.01, y) { Layer = LayerType.Leadin }); + lead.LineTo(0.01, y + 0.01); + + var result = LeadPathValidator.Check(Read(lead), target, [target, other]); + + Assert.False(result.IsComplete); + Assert.Contains("uncertain", result.Reason); + } + + private static Program CleanCircle(double radius) + { + var p = new Program(); + p.MoveTo(radius, 0); + p.Codes.Add(new ArcMove(new Vector(radius, 0), Vector.Zero, RotationType.CCW)); + return p; + } + + // A full circle about the origin cut after a lead-in from 0.0002 outside it. + private static Program Circle(double radius) + { + var p = new Program(); + p.MoveTo(radius + 0.0002, 0); + p.Codes.Add(new LinearMove(radius, 0) { Layer = LayerType.Leadin }); + p.Codes.Add(new ArcMove(new Vector(radius, 0), Vector.Zero, RotationType.CCW)); + return p; + } + // A 2 x 2 square at (x, y) with a lead-in from 0.5 below its corner, optionally preceded by // a rapid to a given point. private static Program Square(double x, double y, Vector? via = null)