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