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();