fix(planning): accept arc leads tangent to a straight edge at their joint

An arc lead-in meeting a straight window edge tangentially has a double
root at its joint. Rounding split it into two contacts a few 1e-8 apart,
or dropped it so the exact ray fallback reported the joint as an
uncertain contact. Either refused a valid entry, so N26-27U's windows
lost most of their entries and the plate never planned.

Treat a contact within 1e-6 of a joint its boundary passes through as
the joint, and let the exact line fallback skip ray hits at contacts
already recorded. The pinned-hole test now expects the other hole's
nearest entry rather than the fallback both runs used to share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-29 09:35:28 -04:00
1 parent c798be9b95
commit 6a85593bd4
4 files changed
+67 -10

No files matched your search

@@ -294,13 +294,25 @@ public static class LeadPathValidator
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
var contacts = curve.Contacts(boundary, out var overlap);
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
}
// With all other boundary contacts excluded, the connected open path
// has constant material membership. Use the native arc midpoint, not
// the chord midpoint or endpoints (which can both lie in scrap).
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
// A lead tangent to the boundary it joins (an arc lead-in meeting a straight edge) has a
// double root at the joint that rounding splits into two contacts a few 1e-8 apart; one
// within JointTolerance of a joint the boundary itself passes through is still the joint.
static bool AtJoint(Vector point, Vector joint, PostVerificationGeometry.Curve boundary)
{
var distance = point.DistanceTo(joint);
return distance <= PostVerificationGeometry.Epsilon
|| distance <= JointTolerance && boundary.Contains(joint);
}
}
// As SlideContact's: far below any kerf, far above a tangent root's rounding drift.
private const double JointTolerance = 1e-6;
}
@@ -397,9 +397,9 @@ internal static class PostVerificationGeometry
points.Add(point);
// Existing exact line/ray contact semantics guard native queries which
// suppress very short or nearly parallel intersections. Uncertainty refuses.
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other))
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other, points))
throw new NotSupportedException("Native contact query is numerically uncertain.");
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this))
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this, points))
throw new NotSupportedException("Native contact query is numerically uncertain.");
return points;
@@ -415,16 +415,35 @@ internal static class PostVerificationGeometry
/// endpoint (both stop half-way when both endpoints are contained): a line meeting a
/// tangent arc at their shared vertex is otherwise rediscovered there as a contact the
/// native query missed, because rounding can drop the tangent root of its quadratic.
/// For the same reason a ray hit within <see cref="RaySeamTolerance"/> of a
/// <paramref name="recorded"/> contact is that contact: an arc lead ending tangent on a
/// straight edge, mid-edge, has its end recorded and its double root dropped.
/// </summary>
private static bool ExactLineContact(Curve line, Curve curve)
private static bool ExactLineContact(Curve line, Curve curve, IReadOnlyList<Vector> recorded)
{
var direction = (line.End - line.Start) * (1 / line.Length);
var startRecorded = curve.Contains(line.Start);
var endRecorded = curve.Contains(line.End);
var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length;
var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length;
return (forward > 0 && curve.ContactAfterStart(line.Start, direction, forward))
|| (backward > 0 && curve.ContactAfterStart(line.End, direction * -1, backward));
return (forward > 0 && Unrecorded(line.Start, direction, forward))
|| (backward > 0 && Unrecorded(line.End, direction * -1, backward));
bool Unrecorded(Vector origin, Vector ray, double length)
{
var hits = new List<double>();
curve.ContactAfterStart(origin, ray, length, hits);
foreach (var distance in hits)
{
var hit = origin + ray * distance;
var known = false;
foreach (var point in recorded)
known |= point.DistanceTo(hit) <= RaySeamTolerance;
if (!known)
return true;
}
return false;
}
}
@@ -46,6 +46,30 @@ public class LeadPathValidationTests
AssertUnsafe(straight, target);
}
[Theory]
[InlineData(0, 0)]
[InlineData(69.833896, 18.839189)] // A rotated part's window edge, as N26-27U's.
[InlineData(-14.2733, 31.610594)]
public void ArcLeadTangentToAWindowEdge_TouchesOnlyAtItsJoint(double dx, double dy)
{
// The semicircle's double root at the joint rounds into two contacts a few 1e-8 apart.
var clean = Rectangle(20 + dx, 10 + dy, 40 + dx, 40 + dy);
clean.Codes.AddRange(Rectangle(25.1 + dx, 17.776689 + dy, 38.378 + dx, 30.026689 + dy).Codes);
var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero);
Assert.True(target.IsComplete, target.Reason);
for (var joint = 17.901689; joint < 30; joint += 1.37)
{
var radius = 0.0625;
var lead = new Program();
lead.MoveTo(38.378 - 2 * radius + dx, joint + dy);
lead.Codes.Add(new ArcMove(new Vector(38.378 + dx, joint + dy), new Vector(38.378 - radius + dx, joint + dy),
RotationType.CW)
{ Layer = LayerType.Leadin });
lead.LineTo(38.378 + dx, 17.776689 + dy);
AssertClear(lead, target);
}
}
[Fact]
public void OtherHoleIsScrap_AndLineArcTangenciesReject()
{
@@ -79,10 +79,12 @@ public class PinnedContourTests
Assert.True(pinnedResult.IndependentlyReplayed);
var choices = Assert.Single(pinnedResult.ProposedOrder).ContourChoices;
Assert.Equal(new Vector(4, 3), Assert.Single(choices, c => c.ContourOrdinal == 0).Point);
// The other hole and the outside are chosen exactly as for the same drawing without pins.
// The outside is chosen exactly as for the same drawing without pins. The other hole stays
// automatic, entered nearest where the pinned hole is left: (6, 3), facing the pin at (4, 3).
var plain = Assert.Single(plainResult.ProposedOrder).ContourChoices;
Assert.Equal(plain.Where(c => c.ContourOrdinal != 0).Select(c => (c.ContourOrdinal, c.EntityOrdinal, c.Point)),
choices.Where(c => c.ContourOrdinal != 0).Select(c => (c.ContourOrdinal, c.EntityOrdinal, c.Point)));
Assert.Equal(plain.Where(c => c.ContourOrdinal == 2).Select(c => (c.ContourOrdinal, c.EntityOrdinal, c.Point)),
choices.Where(c => c.ContourOrdinal == 2).Select(c => (c.ContourOrdinal, c.EntityOrdinal, c.Point)));
Assert.True(Assert.Single(choices, c => c.ContourOrdinal == 1).Point.DistanceTo(new Vector(6, 3)) < 1e-6);
// The pinned hole uses the pin's straight lead; the others use the nest's arc lead-ins.
var leadIns = ExecutionMotionReader.Read(pinnedResult.ProposedOrder[0].CopyProgram(), Vector.Zero, null, default)
.Motions.Where(m => m.Layer == LayerType.Leadin).ToArray();