fix(verify): close contours whose cut runs past its start as a seam overcut

Converted part programs keep a source CAM's overcut: a hole's arc or a slot's
last line runs about 0.03 past the pierce along the first cut. The rapid check
only counted a contour closed when it ended exactly at its start, so every
overcut contour became an open fragment, the open-cut proof refused them, and
each part reported "Multiple interrupted/open cutting fragments" and an
unverifiable lead-out instead of being checked (P260810-02: all 50 parts).

ReleasedContourState now closes such a contour at its seam (matched at the
1e-6 seam tolerance, since a hole's two arcs carry centers ~1e-7 apart) and
lets the rapid leaving the overcut end ignore contact within that tolerance of
its departure. The nest now validates fully and reports the real crossings it
was hiding: rapids that cross the hole just cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 13:25:22 -04:00
1 parent 4b982e8eeb
commit 455c543501
4 files changed
+181 -6

No files matched your search

@@ -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
}
}
/// <summary>
/// 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 <see cref="SeamOvercut"/> 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 <see cref="PostVerificationGeometry.RaySeamTolerance"/>, the closure seam
/// ray parity already admits; the obstacle keeps that seam, never the retraced tail.
/// </summary>
private static PostVerificationGeometry.Curve OvercutClosure(IReadOnlyList<PostVerificationGeometry.Curve> 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;
}
/// <summary>
/// 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<PostVerificationGeometry.Curve> Curves { get; } = new();
internal List<PostVerificationGeometry.Curve> 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<PostVerificationGeometry.Curve> Curves)
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves,
Vector? OvercutEnd)
{
internal PostVerificationGeometry.Extent Extent { get; } =
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
@@ -279,6 +279,18 @@ internal static class PostVerificationGeometry
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
/// <summary>The native point of this finite curve closest to <paramref name="point"/>.</summary>
internal Vector ClosestPoint(Vector point) => nativeEntity.Value.ClosestPointTo(point);
/// <summary>
/// This curve from its start up to <paramref name="point"/>, which must be one of its own
/// interior points (see <see cref="ClosestPoint"/>); an arc keeps its center and radius.
/// </summary>
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);
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
@@ -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()
{
+4 -1
View File
@@ -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