From 18a61717fe99aa2328be32e9ec3f3dc682628d67 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 6 Oct 2026 10:37:07 -0400 Subject: [PATCH] fix(cutting): accept tangent line-arc joints in material capture Plan Cutting refused ordinary filleted parts with "Native contact query is numerically uncertain." Material capture checks every curve pair of a ring, adjacent ones included. Where a line meets a tangent arc at their shared vertex, rounding can drop the tangent root of the native line/circle quadratic; the exact ray cast from the line's far end then reached the vertex, which is already recorded as an endpoint contact, and was read as a contact the native query missed. Rounded rectangles rotated off-axis were refused 1037 times in 1080 before this change and 0 times after. The exactness rays now stop short of a line endpoint the other curve already contains (half-way from each end when both are contained), so together they still cover every other point of the line and any unrecorded contact still refuses. The native kernel and tolerances are unchanged. Regressions: four tangent-fillet rings and a rotated filleted part planned through CuttingPlanBatch (red before, green after); a line ending inside a small circle's contact band stays uncertain in both directions. Mutations that treat either endpoint as always contained, drop the start ray, or count recorded endpoints as native contacts all fail. Project Memory: 41880037 --- .../Diagnostics/PostVerificationGeometry.cs | 37 +++++---- .../NativeContactRegressionTests.cs | 81 +++++++++++++++++++ docs/cutting-planner.md | 5 +- 3 files changed, 108 insertions(+), 15 deletions(-) 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