diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs index 1faca8f4..e3a989a1 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs @@ -55,11 +55,13 @@ internal static class ContourProgramVerifier var ringIndex = -1; var entityIndex = -1; for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++) + { + var admitted = AdmittedClosureSlack(material.Rings[r]); for (var e = 0; e < material.Rings[r].Length; e++) { Query(token, ref budget); var curve = material.Rings[r][e]; - if (curve.SameDirection(first) && curve.Contains(first.Start) + if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted) && curve.DistanceAlong(first.Start) < curve.Length - Epsilon) { ringIndex = r; @@ -67,28 +69,39 @@ internal static class ContourProgramVerifier break; } } + } if (ringIndex < 0 || !visited.Add(ringIndex)) return false; var ring = material.Rings[ringIndex]; + // Capture admits a nominal ring that closes within NominalContourClosure.Tolerance, and + // preparation agrees at that inclusive boundary. A rotated tool path bridges that one + // admitted joint, so this ring's positional and length agreement carries exactly that + // admitted slack. Direction, coverage order and retrace accounting stay at the epsilon. + var tolerance = AdmittedClosureSlack(ring); var nominalLength = ring.Sum(c => c.Length); var cutLength = run.Curves.Sum(c => c.Length); var missing = nominalLength - cutLength; if (missing < -Epsilon) return false; // Never a second circuit/retrace. - if (missing > Epsilon) + if (missing > tolerance) { // Fixed legacy payloads have no certified selected tab/entry metadata. // A merely open contour or a caller's current tab switch proves nothing. if (selected == null || ringIndex != 0 || selected.TabSize <= 0 - || System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > Epsilon) + || System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > tolerance) return false; // SameEmission has already bound this exact terminal gap to the owned // settings' independently emitted, rounded/clamped selected geometry. } if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start), - true, token, ref budget)) return false; + true, tolerance, token, ref budget)) return false; } return visited.Count == material.Rings.Count; } + // Zero for an exactly closed ring: a ring may lawfully close within the nominal closure + // tolerance, and only that admitted endpoint gap relaxes replay's positional agreement. + private static double AdmittedClosureSlack(IReadOnlyList ring) => ring.Count == 0 ? Epsilon + : Epsilon + System.Math.Max(0.0, ring[^1].End.DistanceTo(ring[0].Start)); + private static bool SameEmission(OwnedExecution actual, OwnedExecution expected, CancellationToken token, ref int budget) { @@ -107,15 +120,17 @@ internal static class ContourProgramVerifier if (a.End.DistanceTo(b.End) > Epsilon) return false; } else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon - || !Follows(a.Curves, b.Curves, 0, 0, false, token, ref budget)) return false; + || !Follows(a.Curves, b.Curves, 0, 0, false, Epsilon, token, ref budget)) return false; } return true; } // Consume directed native arc-length from both streams. Subdivision, merged // collinear moves and a cyclic ring's reindexing do not change coverage. + // `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon + // unless the nominal ring closes only within the nominal closure tolerance). private static bool Follows(IReadOnlyList actual, IReadOnlyList expected, - int index, double offset, bool cyclic, CancellationToken token, ref int budget) + int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget) { foreach (var curve in actual) { @@ -137,13 +152,13 @@ internal static class ContourProgramVerifier continue; } var length = System.Math.Min(remaining, curve.Length - consumed); - if (!nominal.SameDirection(curve) - || nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon - || nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon) + if (!nominal.SameDirection(curve, tolerance) + || nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > tolerance + || nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > tolerance) return false; consumed += length; offset += length; - if (offset >= nominal.Length - Epsilon) + if (offset >= nominal.Length - tolerance) { index++; offset = 0; @@ -151,7 +166,7 @@ internal static class ContourProgramVerifier } } return cyclic || index == expected.Count || index == expected.Count - 1 - && expected[index].Length - offset <= Epsilon; + && expected[index].Length - offset <= tolerance; } private static List Runs(OwnedExecution execution) diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 7dca378d..cfff6788 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -167,20 +167,31 @@ internal static class PostVerificationGeometry center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2)) : (Start + End) * 0.5; - internal bool SameSupport(Curve other) + internal bool SameSupport(Curve other) => SameSupport(other, Epsilon); + + /// + /// Support agreement with an explicit positional allowance. Used only where an upstream + /// stage has already admitted a larger nominal discrepancy — currently the inclusive + /// joint that a replayed ring bridges. It is + /// never a collision tolerance: safety queries keep the exact epsilon. + /// + internal bool SameSupport(Curve other, double positionTolerance) { if (Center is { } center) - return other.Center is { } c && center.DistanceTo(c) <= Epsilon - && System.Math.Abs(Radius - other.Radius) <= Epsilon; - if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon) + return other.Center is { } c && center.DistanceTo(c) <= positionTolerance + && System.Math.Abs(Radius - other.Radius) <= positionTolerance; + if (other.Center.HasValue || Length <= positionTolerance || other.Length <= positionTolerance) return false; var direction = (End - Start) * (1 / Length); - return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon - && System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon; + return System.Math.Abs(Cross(other.Start - Start, direction)) <= positionTolerance + && System.Math.Abs(Cross(other.End - Start, direction)) <= positionTolerance; } // Directed native arc-length coordinates for contour replay, not collision queries. - internal bool SameDirection(Curve other) => SameSupport(other) + internal bool SameDirection(Curve other) => SameDirection(other, Epsilon); + + /// Directed coverage check with an admitted positional allowance; direction sign stays exact. + internal bool SameDirection(Curve other, double positionTolerance) => SameSupport(other, positionTolerance) && (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep) : Dot(End - Start, other.End - other.Start) > 0); @@ -199,7 +210,11 @@ internal static class PostVerificationGeometry : Start + (End - Start) * (distance / Length); } - internal bool Contains(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= Epsilon; + internal bool Contains(Vector point) => Contains(point, Epsilon); + + /// On-support test with an admitted positional allowance (see ). + internal bool Contains(Vector point, double positionTolerance) + => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= positionTolerance; /// /// Filters supporting-circle contacts against the actual finite arc span. The diff --git a/OpenNest.Tests/CuttingPlanning/SeamClosureReplayTests.cs b/OpenNest.Tests/CuttingPlanning/SeamClosureReplayTests.cs new file mode 100644 index 00000000..77f42f27 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/SeamClosureReplayTests.cs @@ -0,0 +1,132 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// A nominal contour may lawfully close within , and +/// capture and preparation already agree on that inclusive boundary. Replay verification must +/// agree too: a rotated tool path legitimately bridges that one admitted joint, so refusing the +/// whole plate over it makes every such converted drawing unplanable. +/// +public class SeamClosureReplayTests +{ + private static readonly Vector Start = new(-2, 5); + + /// + /// Square entering mid-edge so the tool path wraps across the closing seam. The seam offset is + /// perpendicular to the edge that follows it, so bridging it shifts that edge's support by the + /// admitted gap instead of merely shortening it. + /// + private static Program SeamRing(double seam) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(0, 10); + program.LineTo(10, 10); + program.LineTo(10, 0); + program.LineTo(seam, 0); + return program; + } + + private static (Part Part, CuttingParameters Settings) Fixture(double seam) + { + var settings = ExplicitContourTests.Parameters(); + var program = SeamRing(seam); + var prepared = PreparedContours.Capture(program, settings); + Assert.Equal(1, prepared.Count); // Preparation accepts the same closure boundary as capture. + var part = new Part(new Drawing("seam", program)); + Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.Entry(0, 0, new Vector(0, 5))]), false)); + return (part, settings); + } + + private static CuttingPlanSnapshot Capture(Part part, CuttingParameters settings) + => CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings)); + + private static Program Path(params Vector[] points) + { + var program = new Program(); + program.MoveTo(-0.3, 5); + program.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin }); + foreach (var point in points) program.LineTo(point); + return program; + } + + private static OwnedExecution Read(Program program) => ExecutionMotionReader.Read(program, Vector.Zero, null, default); + + [Theory] + [InlineData(0.0)] // exactly closed: unchanged behaviour + [InlineData(5e-7)] // admitted closure seam + [InlineData(1e-6)] // inclusive closure boundary that capture already accepts + public void AdmittedClosureSeam_StillReplays(double seam) + { + var (part, settings) = Fixture(seam); + var snapshot = Capture(part, settings); + Assert.True(snapshot.Placements[0].Material!.IsComplete, snapshot.Placements[0].Material!.Reason); + + var source = snapshot.Placements[0]; + var choice = source.Prepared!.Entry(0, 0, new Vector(0, 5)); // path wraps across the seam + var result = CuttingPlanService.ReplayPrograms(snapshot, + [source.Propose(source.Prepared.Emit([choice]), Read(source.Prepared.Emit([choice])), [choice])], + 0, default); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.True(result.IndependentlyReplayed); + } + + [Fact] + public void AdmittedClosureSeam_StillPlansWithGeneratedLeadIns() + { + // The same plate through the planner entry point: a searched route over an admitted seam + // must not be refused at replay. + var (part, settings) = Fixture(9e-7); + var result = CuttingPlanService.Plan(Capture(part, settings)); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + } + + [Theory] + [InlineData("missing-edge")] // a real coverage hole next to an admitted seam still refuses + [InlineData("reversed-edge")] // wrong traversal direction still refuses + [InlineData("interior-shift")] // displacement larger than the admitted seam still refuses + [InlineData("second-circuit")] // retracing the ring still refuses + public void AdmittedClosureSeam_KeepsRefusingRealAccountingFaults(string fault) + { + var (part, settings) = Fixture(9e-7); + var snapshot = Capture(part, settings); + var source = snapshot.Placements[0]; + var choice = source.Prepared!.Entry(0, 0, new Vector(0, 5)); + var actual = fault switch + { + // Leaves the bottom edge uncut: coverage, not closure slack, is what fails. + "missing-edge" => Path(new Vector(0, 10), new Vector(10, 10), new Vector(10, 0)), + // Runs the right edge backwards against the nominal direction. + "reversed-edge" => Path(new(0, 5), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)), + // Closes exactly but shifts an interior vertex by far more than the admitted seam. + "interior-shift" => Path(new Vector(0, 5), new(0, 10), new(10, 9.9999), new(10, 0), new(0, 0), new(0, 5)), + // Two circuits are never closure slack. + "second-circuit" => Path(new(0, 5), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5), + new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)), + _ => throw new ArgumentOutOfRangeException(nameof(fault)) + }; + + var result = CuttingPlanService.ReplayPrograms(snapshot, [source.Propose(actual, Read(actual), [choice])], + 0, default); + + Assert.NotEqual(CuttingPlanStatus.Ready, result.Status); + Assert.Empty(result.ProposedOrder); + Assert.False(result.IndependentlyReplayed); + } + + [Fact] + public void GapBeyondAdmittedClosure_StillRefusesAtCapture() + { + // The fix must not widen closure admission itself: beyond tolerance capture still refuses. + var program = SeamRing(2e-6); + var material = LeadMaterialSnapshot.Capture(program, new Vector(30, 20)); + Assert.False(material.IsComplete); + Assert.Contains("closure tolerance", material.Reason); + } +}