diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs index 48653550..8f106e26 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs @@ -159,7 +159,14 @@ public sealed class ReleasedContourState // A rapid well clear of a contour's extent can neither cross nor touch it. if (reach.IsClearOf(obstacle.Extent)) continue; - if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token)) + // A rapid leaving an overcut departs from its retraced tail, which lies on the first + // cut only within the seam tolerance its arcs were matched at. Contact within that + // much of the departure is the departure itself. + var from = start; + if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon + && start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance) + from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End)); + if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token)) Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, $"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + $"of part {obstacle.Part}.") @@ -167,7 +174,7 @@ public sealed class ReleasedContourState Location = (start + move.End) * 0.5, RapidStart = start, RapidEnd = move.End, - ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token) + ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token) }, owned); } } @@ -233,6 +240,12 @@ public sealed class ReleasedContourState // A completed contour becomes an obstacle immediately, not at part end. if (PostVerificationGeometry.Closed(current.Curves)) Finish(); + else if (OvercutClosure(current.Curves) is { } closing) + { + current.OvercutEnd = current.Curves[^1].End; + current.Curves[^1] = closing; + Finish(); + } } } Finish(); @@ -279,9 +292,9 @@ public sealed class ReleasedContourState return; // A real uncut gap leaves the contour attached. CuttingParameters can be stale; // no flag or tab configuration is used as evidence of retention. - if (!cutoff && PostVerificationGeometry.Closed(current.Curves)) + if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves))) { - obstacles.Add(new(part, contourNumber, current.Curves.ToArray())); + obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd)); closed.Add(current); } else if (!cutoff) @@ -290,6 +303,31 @@ public sealed class ReleasedContourState } } + /// + /// The closing piece of a contour cut on past its own start along its first cut, as a source + /// CAM's seam overcut is (see for the clean-contour form): the last + /// cut passes back through the start and its tail retraces the first cut. The tail encloses + /// nothing, so the contour is closed by the last cut up to its start. Null for any other shape. + /// Arcs of one circle may carry centers derived from their own rounded endpoints, so the seam + /// is matched at , the closure seam + /// ray parity already admits; the obstacle keeps that seam, never the retraced tail. + /// + private static PostVerificationGeometry.Curve OvercutClosure(IReadOnlyList curves) + { + if (curves.Count < 2) + return null; + const double tolerance = PostVerificationGeometry.RaySeamTolerance; + var first = curves[0]; + var last = curves[^1]; + if (!last.SameDirection(first, tolerance) || !last.Contains(first.Start, tolerance) + || !first.Contains(last.End, tolerance)) + return null; + var seam = last.ClosestPoint(first.Start); + var along = last.DistanceAlong(seam); + var tail = last.Length - along; + return along > tolerance && tail > tolerance && tail <= first.Length + tolerance ? last.Until(seam) : null; + } + /// /// Whether one part's open cuts, with their leads, can release no material. A closed /// contour with its own leads already encloses whatever it releases and counts as one node. @@ -431,6 +469,9 @@ public sealed class ReleasedContourState internal List Curves { get; } = new(); internal List Leads { get; } = new(); internal bool Fixed { get; set; } + // Where the cut really ended when OvercutClosure closed it: the retraced tail's end, on the + // first cut. Its last curve now ends at the seam instead. + internal Vector? OvercutEnd { get; set; } } private sealed class DisjointSet(int count) @@ -447,7 +488,8 @@ public sealed class ReleasedContourState internal void Union(int a, int b) => parents[Find(a)] = Find(b); } - private sealed record Obstacle(int Part, int Contour, IReadOnlyList Curves) + private sealed record Obstacle(int Part, int Contour, IReadOnlyList Curves, + Vector? OvercutEnd) { internal PostVerificationGeometry.Extent Extent { get; } = Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent)); diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 8cbd4b1d..cb31ab04 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -279,6 +279,18 @@ internal static class PostVerificationGeometry /// Exact native distance from to this finite curve. internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point); + /// The native point of this finite curve closest to . + internal Vector ClosestPoint(Vector point) => nativeEntity.Value.ClosestPointTo(point); + + /// + /// This curve from its start up to , which must be one of its own + /// interior points (see ); an arc keeps its center and radius. + /// + internal Curve Until(Vector point) => Center is { } center + ? new Curve(Start, point, center, Radius, + System.Math.Sign(Sweep) * Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X))) + : new Curve(Start, point, null, 0, 0); + internal bool Contains(Vector point) => Contains(point, Epsilon); /// On-support test with an admitted positional allowance (see ). diff --git a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs index 4c980dcf..63c19c36 100644 --- a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs +++ b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs @@ -1,7 +1,9 @@ +using System.Text; using OpenNest.CNC; using OpenNest.CNC.CuttingStrategy; using OpenNest.Diagnostics; using OpenNest.Geometry; +using OpenNest.IO; namespace OpenNest.Tests.Diagnostics; @@ -220,6 +222,122 @@ public class PostVerificationAnalyzerTests Assert.Empty(Find(Analyze(part, Scribe(-2, 2), Scribe(6, 2)), PostVerificationKind.RapidCrossing)); } + [Theory] + [InlineData(2.5, true)] // The overcut retraces part of the first cut: the square is cut free. + [InlineData(3.5, false)] // It runs on past the whole first cut: not a recognized overcut. + public void Rapid_SeamOvercutAlongTheFirstCutClosesTheContour(double overcutEnd, bool closed) + { + var part = Rectangle(0, 0, 4); + var program = new Program(); + program.MoveTo(2, 0); + program.LineTo(3, 0); + program.LineTo(4, 0); + program.LineTo(4, 4); + program.LineTo(0, 4); + program.LineTo(0, 0); + program.LineTo(overcutEnd, 0); + part.RestoreLeadInProgram(program, false); + var report = Analyze(part, Scribe(-2, 2), Scribe(6, 2)); + Assert.Equal(closed, Find(report, PostVerificationKind.RapidCrossing).Any(f => f.PartNumber == 3)); + Assert.Empty(Find(report, PostVerificationKind.Incomplete)); + } + + // P260810-02's SULLYS-009, as converted from a source CAM: each hole's arc runs about 0.03 past + // its pierce, the slot's last line runs 0.03 along its first, and the perimeter keeps a 0.02 tab. + // Arc centers carry their own endpoint rounding, so a hole's two halves disagree by about 1e-7. + [Fact] + public void Rapid_ConvertedSeamOvercutsAreCheckedNotLeftIncomplete() + { + var part = new Part(new Drawing("SULLYS-009", ReadProgram(Sullys009Clean))); + Assert.True(part.RestoreLeadInProgram(ReadProgram(Sullys009Cut), true)); + var report = Analyze(part); + Assert.Empty(Find(report, PostVerificationKind.Incomplete)); + // Holes 2 and 4 really are cut free and then crossed by the rapid leaving them; holes 1 + // and 3 are left from their overcut end without touching them again. + Assert.Equal(new[] { 2, 4 }, Find(report, PostVerificationKind.RapidCrossing) + .Select(f => int.Parse(f.Message.Split("contour ")[1].Split(' ')[0])).Order()); + } + + private static Program ReadProgram(string text) + { + var reader = new ProgramReader(new MemoryStream(Encoding.UTF8.GetBytes(text))); + var program = reader.Read(); + reader.Close(); + return program; + } + + private const string Sullys009Clean = """ + G91 + G00X0.07Y0 + G03X-0.14Y0I-0.07J0 + G03X0.133669Y0.02909I0.0699998916J-0.0000000236 + G01X0.006331Y-0.02909 + G00X0.11Y0 + G03X0.14Y0I0.07J0 + G03X-0.133669Y-0.02909I-0.0699998916J0.0000000236 + G01X-0.006331Y0.02909 + G00X0Y0.5 + G03X0.14Y0I0.07J0 + G03X-0.133669Y-0.02909I-0.0699998916J0.0000000236 + G01X-0.006331Y0.02909 + G00X-0.18Y-0.07 + G03X0Y0.14I0J0.07 + G03X0.02909Y-0.133669I-0.0000000236J-0.0699998916 + G01X-0.02909Y-0.006331 + G00X-1.3285Y-0.394 + G01X0.4765Y0 + G03X0Y0.428I0J0.214 + G01X-0.953Y0 + G03X0Y-0.428I0J-0.214 + G01X0.5065Y0 + G01X-0.03Y0 + G00X-0.2665Y-0.411 + G01X-0.03Y0 + G02X0Y1.25I0J0.625 + G01X2.1875Y0 + G02X0.0625Y-0.0625I0J-0.0625 + G01X0Y-1.125 + G02X-0.0625Y-0.0625I-0.0625J0 + G01X-2.1375Y0 + G01X-0.02Y0 + """; + + private const string Sullys009Cut = """ + G91 + G01X0.07Y0:LEADIN + G03X-0.14Y0I-0.07J0 + G03X0.133669Y0.02909I0.0699998916J-0.0000000236 + G00X0.186331Y-0.02909 + G01X-0.07Y0:LEADIN + G03X0.14Y0I0.07J0 + G03X-0.133669Y-0.02909I-0.0699998916J0.0000000236 + G00X0.063669Y0.52909 + G01X-0.07Y0:LEADIN + G03X0.14Y0I0.07J0 + G03X-0.133669Y-0.02909I-0.0699998916J0.0000000236 + G00X-0.186331Y0.02909 + G01X0Y-0.07:LEADIN + G03X0Y0.14I0J0.07 + G03X0.02909Y-0.133669I-0.0000000236J-0.0699998916 + G00X-1.35759Y-0.275331 + G01X0Y-0.125:LEADIN + G01X0.4765Y0 + G03X0Y0.428I0J0.214 + G01X-0.953Y0 + G03X0Y-0.428I0J-0.214 + G01X0.5065Y0 + G00X-0.3715Y-0.540904 + G01X0.075Y0.129904:LEADIN + G01X-0.03Y0 + G02X0Y1.25I0J0.625 + G01X2.1875Y0 + G02X0.0625Y-0.0625I0J-0.0625 + G01X0Y-1.125 + G02X-0.0625Y-0.0625I-0.0625J0 + G01X-2.1375Y0 + G01X0.03Y-0.051961:LEADOUT + """; + [Fact] public void Rapid_CutoffTravelStillCrossesEarlierRealCuts() { diff --git a/docs/post-verification.md b/docs/post-verification.md index 4c38d700..5e336206 100644 --- a/docs/post-verification.md +++ b/docs/post-verification.md @@ -79,7 +79,10 @@ shows the first three check categories, even when there are no findings: contours in cutting order, or an active clamp's physical footprint. This includes earlier holes within the same part and previously cut parts. A future cut is not an obstacle yet. Actual uncut gaps are respected; enabling tabs in settings alone - is not evidence that a gap exists. Clamp-rapid warnings are conservative: head + is not evidence that a gap exists. A cut that runs on past its own start along its + first cut (a converted program's seam overcut, matched within the 1e-6 seam + tolerance) is closed at its seam; the rapid leaving its overcut end ignores contact + within that tolerance of the departure. Clamp-rapid warnings are conservative: head height/retracts are not modeled, so inspect the emitted route and machine setup. A clamp whose footprint covers the program start point warns on every posting attempt by design; there is no exemption for the first rapid and no persisted