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.
This commit is contained in:
aj committed 2026-10-06 00:03:00 -04:00
1 parent 2419a9e2e3
commit c1c8d5fc36
2 files changed
+96 -4

No files matched your search

@@ -106,11 +106,13 @@ internal static class PostVerificationGeometry
: new Line(Start, End);
/// <summary>
/// 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
/// <see cref="ContactAfterStart"/>; for a line, its endpoints. Nonfinite geometry yields NaN
/// bounds, which no separation test can pass.
/// </summary>
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);
@@ -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)