From 6a85593bd475c59317822893b5de2ed2f8a89d87 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 29 Oct 2026 01:44:06 -0400 Subject: [PATCH] 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 --- .../CNC/CuttingPlanning/LeadPathValidator.cs | 16 ++++++++-- .../Diagnostics/PostVerificationGeometry.cs | 29 +++++++++++++++---- .../LeadPathValidationTests.cs | 24 +++++++++++++++ .../CuttingPlanning/PinnedContourTests.cs | 8 +++-- 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs index c442d441..5cf3d4a1 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs @@ -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; } diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 545edc90..23a2b0c8 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -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 of a + /// contact is that contact: an arc lead ending tangent on a + /// straight edge, mid-edge, has its end recorded and its double root dropped. /// - private static bool ExactLineContact(Curve line, Curve curve) + private static bool ExactLineContact(Curve line, Curve curve, IReadOnlyList 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(); + 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; + } } diff --git a/OpenNest.Tests/CuttingPlanning/LeadPathValidationTests.cs b/OpenNest.Tests/CuttingPlanning/LeadPathValidationTests.cs index c05ce662..8e140d61 100644 --- a/OpenNest.Tests/CuttingPlanning/LeadPathValidationTests.cs +++ b/OpenNest.Tests/CuttingPlanning/LeadPathValidationTests.cs @@ -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() { diff --git a/OpenNest.Tests/CuttingPlanning/PinnedContourTests.cs b/OpenNest.Tests/CuttingPlanning/PinnedContourTests.cs index 5cee4b1f..352e547c 100644 --- a/OpenNest.Tests/CuttingPlanning/PinnedContourTests.cs +++ b/OpenNest.Tests/CuttingPlanning/PinnedContourTests.cs @@ -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();