From 90ae3b6a84fd41c7ed71b146ca45db238e3d963e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 10 Oct 2026 15:57:16 -0400 Subject: [PATCH] Fix false self-intersection warnings at rounded contour joints --- .../CuttingPlanning/LeadMaterialSnapshot.cs | 12 ++- .../LeadMaterialSnapshotTests.cs | 82 +++++++++++++++++++ docs/cutting-planner.md | 7 ++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/LeadMaterialSnapshotTests.cs diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs index 7d930e8..de74850 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using OpenNest.Diagnostics; using OpenNest.Geometry; +using OpenNest.Math; namespace OpenNest.CNC.CuttingPlanning; @@ -66,11 +67,14 @@ public sealed class LeadMaterialSnapshot var b = rings[s][j]; var contacts = a.Contacts(b, out var overlap); var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1)); + // Native contacts at rounded arc/line joints can drift from the + // authored endpoint. Use the shared geometry tolerance only to + // recognize that joint; overlaps and other contacts still refuse. if (overlap || contacts.Any(p => !adjacent - || !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon - && p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon) - && !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon - && p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon))) + || !(p.DistanceTo(a.End) <= Tolerance.Epsilon + && p.DistanceTo(b.Start) <= Tolerance.Epsilon) + && !(p.DistanceTo(a.Start) <= Tolerance.Epsilon + && p.DistanceTo(b.End) <= Tolerance.Epsilon))) throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap})."); } var outer = -1; diff --git a/OpenNest.Tests/CuttingPlanning/LeadMaterialSnapshotTests.cs b/OpenNest.Tests/CuttingPlanning/LeadMaterialSnapshotTests.cs new file mode 100644 index 0000000..54a600f --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/LeadMaterialSnapshotTests.cs @@ -0,0 +1,82 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class LeadMaterialSnapshotTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RoundedCornerContact_RoundingAtAdjacentEndpointIsAccepted(bool atSeam) + { + // Rounded imported corner: its native tangent contact falls 1.52e-8 + // from the authored arc/line joint. Exercise both consecutive and seam adjacency. + var program = new Program(); + var start = new Vector(120.509177, 14.879559); + program.MoveTo(start); + program.Codes.Add(new ArcMove(new Vector(120.5, 14.9375), + new Vector(120.6875000024, 14.9374999848), RotationType.CW)); + program.LineTo(120.5, 28.5625); + program.Codes.Add(new ArcMove(new Vector(120.6875, 28.75), + new Vector(120.6875, 28.5625), RotationType.CW)); + program.LineTo(127.6875, 28.75); + program.Codes.Add(new ArcMove(new Vector(127.875, 28.5625), + new Vector(127.6875, 28.5625), RotationType.CW)); + program.LineTo(127.875, 14.9375); + program.Codes.Add(new ArcMove(new Vector(127.6875, 14.75), + new Vector(127.6875, 14.9375), RotationType.CW)); + program.LineTo(120.6875, 14.75); + program.Codes.Add(new ArcMove(start, + new Vector(120.6874999967, 14.9375000024), RotationType.CW)); + if (atSeam) + { + var first = program.Codes[1]; + program.Codes.RemoveAt(1); + program.Codes.Add(first); + program.Codes[0] = new RapidMove(120.5, 14.9375); + } + + var material = LeadMaterialSnapshot.Capture(program, Vector.Zero); + + Assert.True(material.IsComplete, material.Reason); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CrossingOrRetracedBoundary_RemainsRejected(bool retraced) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(8, 8); + if (retraced) + { + program.LineTo(4, 4); + program.LineTo(0, 8); + } + else + { + program.LineTo(0, 8); + program.LineTo(8, 0); + } + program.LineTo(0, 0); + + var material = LeadMaterialSnapshot.Capture(program, Vector.Zero); + + Assert.False(material.IsComplete); + Assert.Contains("Material boundaries", material.Reason); + } + + [Fact] + public void SeparateTouchingHoles_RemainRejected() + { + var part = BestEffortCuttingPlanTests.TouchingContours(); + + var material = LeadMaterialSnapshot.Capture(part.BaseDrawing.Program, Vector.Zero); + + Assert.False(material.IsComplete); + Assert.Contains("Material boundaries", material.Reason); + } +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index da1e6fe..0ef7feb 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -53,6 +53,13 @@ does not visit a globally shared descendant twice; it never changes the fixed pa ## Cutting dependencies +Nominal material validation recognizes contacts at shared endpoints of adjacent +contour segments using `OpenNest.Math.Tolerance.Epsilon` (0.00001 model units). +This avoids false self-intersection warnings from rounding at arc/line joints, +including the contour seam. Overlapping segments and contacts away from those +joints or between separate contours still fail validation. Contour closure and +the underlying native intersection queries retain their existing tolerances. + Capture builds whole-part prerequisites from owned values, and both the search and the final replay enforce them: