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