diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
index 4de99cc..b4106ee 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
@@ -221,20 +221,10 @@ 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))
- {
- var direction = (End - Start) * (1 / Length);
- if ((other.ContactAfterStart(Start, direction, Length)
- || other.ContactAfterStart(End, direction * -1, Length)) && nativeContactCount == 0)
- throw new NotSupportedException("Native contact query is numerically uncertain.");
- }
- if (other.Center is null && Center.HasValue)
- {
- var direction = (other.End - other.Start) * (1 / other.Length);
- if ((ContactAfterStart(other.Start, direction, other.Length)
- || ContactAfterStart(other.End, direction * -1, other.Length)) && nativeContactCount == 0)
- throw new NotSupportedException("Native contact query is numerically uncertain.");
- }
+ if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other))
+ throw new NotSupportedException("Native contact query is numerically uncertain.");
+ if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this))
+ throw new NotSupportedException("Native contact query is numerically uncertain.");
return points;
static bool InteriorWitness(Vector point, Curve curve) => curve.Contains(point)
@@ -242,6 +232,25 @@ internal static class PostVerificationGeometry
|| (point.DistanceTo(curve.Start) > Epsilon && point.DistanceTo(curve.End) > Epsilon));
}
+ ///
+ /// Whether exact ray semantics find touching
+ /// anywhere except at a line endpoint the curve contains, which is already a recorded contact.
+ /// Rays from both ends together cover the whole line. A ray stops short of a contained
+ /// 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.
+ ///
+ private static bool ExactLineContact(Curve line, Curve curve)
+ {
+ 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));
+ }
+
internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise)
{
diff --git a/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs b/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs
index 6ed58ab..dd81ee2 100644
--- a/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs
@@ -1,6 +1,7 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
+using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
@@ -67,4 +68,84 @@ public class NativeContactRegressionTests
Assert.False(snapshot.IsComplete);
Assert.NotNull(snapshot.Reason);
}
+
+ // A line meeting a fillet arc tangentially at their shared vertex: rounding can drop the
+ // tangent root of the native line/circle quadratic, and the exact ray from the line's far end
+ // then rediscovered the already-recorded vertex as a missed contact.
+ [Theory]
+ [InlineData(0.125, 7, 0, 0)]
+ [InlineData(1.0, 33, 55.6, 4.8)]
+ [InlineData(0.5, 61, 110.3, 57.1)]
+ [InlineData(0.25, 80, 0, 0)]
+ public void TangentFilletRing_CertifiesAsSimpleMaterial(double radius, double degrees, double x, double y)
+ {
+ var snapshot = LeadMaterialSnapshot.Capture(
+ RoundedRectangle(7, 3, radius, degrees * System.Math.PI / 180, new Vector(x, y)), Vector.Zero);
+
+ Assert.True(snapshot.IsComplete, snapshot.Reason);
+ }
+
+ [Fact]
+ public void RotatedFilletedPart_PlansReadyOnAPlate()
+ {
+ var plate = new Nest().CreatePlate();
+ plate.Size = new Size(100, 100);
+ plate.Parts.Add(new Part(new Drawing("filleted",
+ RoundedRectangle(7, 3, 1, 33 * System.Math.PI / 180, Vector.Zero)), new Vector(20, 20)));
+
+ var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
+
+ Assert.True(proposal.CanApply, string.Join(Environment.NewLine, proposal.Describe("in")));
+ }
+
+ // The line ends 0.0000015 above a radius-0.003 circle: inside the circle's exact contact band,
+ // where the native query finds nothing. That end is not a recorded contact, so the ray that
+ // reaches it from the other end must still run.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void LineEndingInsideACircleContactBand_StaysUncertain(bool reversed)
+ {
+ const double radius = 0.003;
+ var near = new Vector(0, radius + 0.0000015);
+ var far = new Vector(-0.01, near.Y);
+ var line = reversed
+ ? PostVerificationGeometry.Curve.Create(near, far, null, false)
+ : PostVerificationGeometry.Curve.Create(far, near, null, false);
+ var circle = PostVerificationGeometry.Curve.Create(new Vector(radius, 0), new Vector(radius, 0), Vector.Zero, false);
+ Assert.False(circle.Contains(near));
+
+ Assert.Throws(() => line.Contacts(circle, out _));
+ Assert.Throws(() => circle.Contacts(line, out _));
+ }
+
+ private static Program RoundedRectangle(double width, double height, double radius, double angle, Vector at)
+ {
+ var cos = System.Math.Cos(angle);
+ var sin = System.Math.Sin(angle);
+ Vector Place(double x, double y) => new(at.X + x * cos - y * sin, at.Y + x * sin + y * cos);
+ var program = new Program();
+ var start = Place(radius, 0);
+ program.MoveTo(start.X, start.Y);
+ void Line(double x, double y)
+ {
+ var end = Place(x, y);
+ program.LineTo(end.X, end.Y);
+ }
+ void Fillet(double x, double y, double cx, double cy)
+ {
+ var end = Place(x, y);
+ var center = Place(cx, cy);
+ program.ArcTo(end.X, end.Y, center.X, center.Y, RotationType.CCW);
+ }
+ Line(width - radius, 0);
+ Fillet(width, radius, width - radius, radius);
+ Line(width, height - radius);
+ Fillet(width - radius, height, width - radius, height - radius);
+ Line(radius, height);
+ Fillet(0, height - radius, radius, height - radius);
+ Line(0, radius);
+ Fillet(radius, 0, radius, radius);
+ return program;
+ }
}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index fffed57..3d52e2f 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -116,7 +116,10 @@ lead-in and lead-out line/arc paths must stay in target scrap and avoid other
placed material; holes in other parts remain scrap. Tangent/coincident contacts
outside the genuine target contour joint and numerically uncertain queries refuse.
Material capture supports a simple closed perimeter minus disjoint, non-nested
-holes; unsupported topology is not a bounding-box approximation.
+holes; unsupported topology is not a bounding-box approximation. A line meeting a
+tangent arc at a shared vertex, such as a fillet, is an ordinary joint: an exact
+contact that the native query rounds away is not uncertain at a line endpoint the
+other curve already touches, while a contact anywhere else on the line still refuses.
Candidates rank by actual modeled rapid distance with stable source/contour/entry
ordinals. Hash values and drawing names are not tie breakers. The expansion budget