diff --git a/AGENTS.md b/AGENTS.md
index 28c72d19..a560aa0c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,7 +58,7 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
-- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. Every post must raise before a marked rapid or refuse the nest.
+- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. The one designed raise is the rapid out of a cutout's slug after its lead-out, marked at emission (see the cutting planner doc); no lead-out or an overcut keeps the head down. Every post must raise before a marked rapid or refuse the nest.
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
index 99cfc98a..3ad73baf 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
@@ -126,11 +126,19 @@ public sealed class ReleasedContourState
var contourNumber = contoursBefore;
var inBreakCut = false;
var reportedTab = false;
+ // The contour last closed, and the one a lead-out has just left it into: a cutout's
+ // lead-out ends in its slug, so the rapid after it must leave over that cutout's own cut.
+ CutGroup lastClosed = null;
+ Obstacle lastObstacle = null;
+ Obstacle exited = null;
+ var exitPoint = Vector.Zero;
for (var index = 0; index < moves.Count; index++)
{
token.ThrowIfCancellationRequested();
var move = moves[index];
var owned = isFixed?.Invoke(index) == true;
+ if (!move.Rapid && move.Layer != LayerType.Leadout)
+ exited = null;
if (!move.Rapid && move.Layer == LayerType.Break)
{
// A break cut is intentionally open and retained by its uncut tabs: it is never a
@@ -161,8 +169,15 @@ public sealed class ReleasedContourState
loose.Leads.AddRange(leadIns);
leadIns.Clear();
previous = null;
+ var leaving = exited;
+ exited = null;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue;
+ // A raised rapid out of a slug its cutout's lead-out ended in is the designed way
+ // out: it may cross that cutout once, and passes over the rest with the head up.
+ if (move.HeadRaised && leaving != null && start.DistanceTo(exitPoint) <= PostVerificationGeometry.Epsilon
+ && LeavesOnce(leaving, start, move.End, token))
+ continue;
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
System.Math.Max(start.Y, move.End.Y));
@@ -217,6 +232,11 @@ public sealed class ReleasedContourState
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."), owned);
(previous ?? loose).Leads.Add(move.Curve);
+ if (previous != null && ReferenceEquals(previous, lastClosed))
+ {
+ exited = lastObstacle;
+ exitPoint = move.Curve.End;
+ }
}
Finish();
hasLead = false;
@@ -324,8 +344,10 @@ public sealed class ReleasedContourState
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
{
- obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
+ lastObstacle = new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd);
+ obstacles.Add(lastObstacle);
closed.Add(current);
+ lastClosed = current;
}
else if (!cutoff)
unfinished.Add(current);
@@ -333,6 +355,16 @@ public sealed class ReleasedContourState
}
}
+ ///
+ /// Whether the rapid from inside to
+ /// outside it meets its cut just once, on the way out: a rapid that
+ /// leaves a re-entrant cutout and passes back over it crosses the cut where the slug may lie.
+ ///
+ private static bool LeavesOnce(Obstacle cutout, Vector start, Vector end, CancellationToken token) =>
+ PostVerificationGeometry.Inside(start, cutout.Curves, token)
+ && !PostVerificationGeometry.Inside(end, cutout.Curves, token)
+ && PostVerificationGeometry.ContactPoints(start, end, cutout.Curves, token).Count == 1;
+
///
/// 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
diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
index 94b99819..028224eb 100644
--- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
+++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
@@ -312,17 +312,25 @@ namespace OpenNest.CNC.CuttingStrategy
var shared = Parameters;
try
{
+ var inSlug = false;
foreach (var choice in choices)
{
// A contour's own settings apply to its emission alone; scribes and every
// other contour keep the shared settings.
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
var shape = shapes[choice.ContourOrdinal];
- EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
+ var arrival = result.Codes.Count;
+ var leftInSlug = EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
exactCirclePrograms: true, scrapPierce: choice.Pierce,
stopAtCenterLine: contourParameters?[choice.ContourOrdinal] == null,
memoContour: choice.ContourOrdinal);
+ // A cutout's lead-out ends in its slug, which may drop or tip once cut free, so
+ // the rapid out over the cut is made head up. An overcut or no lead-out leaves
+ // from the cut itself and stays head down.
+ if (inSlug && arrival < result.Codes.Count && result.Codes[arrival] is RapidMove or SubProgramCall)
+ result.Codes.Insert(arrival, new Comment(HeadRaise.Marker));
+ inSlug = leftInSlug;
}
}
finally
@@ -441,7 +449,8 @@ namespace OpenNest.CNC.CuttingStrategy
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
- private void EmitContour(
+ /// Whether the contour is a cutout left by a lead-out, which ends in its slug.
+ private bool EmitContour(
Program program,
Shape shape,
Vector point,
@@ -504,7 +513,8 @@ namespace OpenNest.CNC.CuttingStrategy
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
- subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
+ var holeLeadOut = leadOut.Generate(relativePoint, normal, winding);
+ subPgm.Codes.AddRange(holeLeadOut);
subPgm.Mode = Mode.Incremental;
// Deduplicate: check if an identical sub-program already exists
@@ -523,7 +533,7 @@ namespace OpenNest.CNC.CuttingStrategy
}
);
- return;
+ return holeLeadOut.Count > 0;
}
var turn = LeadTurn(contourType, winding);
@@ -587,7 +597,9 @@ namespace OpenNest.CNC.CuttingStrategy
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
- program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn));
+ var leadOutCodes = leadOut.Generate(leadOutPoint, leadOutNormal, turn);
+ program.Codes.AddRange(leadOutCodes);
+ return contourType != ContourType.External && leadOutCodes.Count > 0;
}
///
diff --git a/OpenNest.Core/CNC/HeadRaise.cs b/OpenNest.Core/CNC/HeadRaise.cs
index b67082e1..46bf7f4c 100644
--- a/OpenNest.Core/CNC/HeadRaise.cs
+++ b/OpenNest.Core/CNC/HeadRaise.cs
@@ -6,7 +6,8 @@ namespace OpenNest.CNC
{
///
/// A rapid the head must make raised because it passes over parts already cut, which may have
- /// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing: a
+ /// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing, and always
+ /// marks the rapid out of a cutout's slug after its lead-out (see docs/cutting-planner.md): a
/// comment immediately before the , or before the
/// whose arrival rapid it is, since a sub-program may be shared and
/// is never marked itself. Rapid checks accept a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
diff --git a/OpenNest.Tests/CuttingPlanning/CutoutLeadOutTests.cs b/OpenNest.Tests/CuttingPlanning/CutoutLeadOutTests.cs
new file mode 100644
index 00000000..41508fe1
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CutoutLeadOutTests.cs
@@ -0,0 +1,131 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Diagnostics;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+// A cutout's lead-out ends in its slug, so the rapid after it must leave over the cutout just cut.
+// Plan Cutting raises the head for that rapid; without a lead-out the tool leaves from the cut.
+public class CutoutLeadOutTests
+{
+ private static readonly Vector Start = new(-2, -2);
+
+ private static Program SquareCutoutAndHole()
+ {
+ var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
+ clean.MoveTo(3, 3);
+ clean.LineTo(5, 3);
+ clean.LineTo(5, 5);
+ clean.LineTo(3, 5);
+ clean.LineTo(3, 3);
+ clean.MoveTo(8, 7.5);
+ clean.ArcTo(8, 7.5, 7.5, 7.5, RotationType.CCW);
+ return clean;
+ }
+
+ private static CuttingParameters Parameters(LeadOut internalLeadOut)
+ {
+ var lead = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
+ return new CuttingParameters
+ {
+ ExternalLeadIn = lead,
+ InternalLeadIn = lead,
+ ExternalLeadOut = new LineLeadOut { Length = 0.1, ApproachAngle = 90 },
+ InternalLeadOut = internalLeadOut,
+ PierceClearance = 0
+ };
+ }
+
+ private static CuttingPlanResult Plan(LeadOut internalLeadOut, out Part part)
+ {
+ part = new Part(new Drawing("cutout", SquareCutoutAndHole()));
+ return CuttingPlanService.Plan(new CuttingPlanRequest([part], Start,
+ confirmedParameters: Parameters(internalLeadOut)));
+ }
+
+ private static int Raises(Program program) =>
+ program.Codes.Count(HeadRaise.IsMarker);
+
+ [Fact]
+ public void CutoutLeadOutsPlanWithTheHeadRaisedOutOfEachSlug()
+ {
+ var result = Plan(new LineLeadOut { Length = 0.1, ApproachAngle = 90 }, out _);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready,
+ $"{result.Status}: " + string.Join("; ", result.Findings.Select(f => f.Message)));
+ Assert.False(result.IsSafestRouteFallback);
+ Assert.Equal(0, result.HeadRaises);
+ Assert.Empty(result.Findings);
+ // Both cutouts are left from their slugs; the perimeter's lead-out ends outside the part.
+ Assert.Equal(2, Raises(result.ProposedOrder[0].CopyProgram()));
+ }
+
+ [Fact]
+ public void CutoutsWithoutLeadOutsKeepTheHeadDown()
+ {
+ var result = Plan(new NoLeadOut(), out _);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready,
+ $"{result.Status}: " + string.Join("; ", result.Findings.Select(f => f.Message)));
+ Assert.Equal(0, Raises(result.ProposedOrder[0].CopyProgram()));
+ }
+
+ // Pierce in the slug, cut the square cutout, lead out into the slug, then rapid away.
+ private static OwnedExecution SlugExit(bool raised)
+ {
+ var program = new Program(Mode.Absolute);
+ program.Codes.Add(new RapidMove(4, 4));
+ program.Codes.Add(new LinearMove(3, 4) { Layer = LayerType.Leadin });
+ foreach (var point in new[] { new Vector(3, 3), new Vector(5, 3), new Vector(5, 5), new Vector(3, 5), new Vector(3, 4) })
+ program.Codes.Add(new LinearMove(point) { Layer = LayerType.Cut });
+ program.Codes.Add(new LinearMove(3.2, 4.2) { Layer = LayerType.Leadout });
+ if (raised)
+ program.Codes.Add(new Comment(HeadRaise.Marker));
+ program.Codes.Add(new RapidMove(0, 8));
+ return ExecutionMotionReader.Read(program, Vector.Zero, Vector.Zero, default);
+ }
+
+ [Fact]
+ public void AHeadDownRapidOutOfASlugStillCrossesItsCutout()
+ {
+ var findings = new ReleasedContourState().Check(SlugExit(raised: false), Vector.Zero, 1);
+
+ Assert.Contains(findings, f => f.Kind == PostVerificationKind.RapidCrossing);
+ }
+
+ [Fact]
+ public void ARaisedRapidOutOfASlugIsTheDesignedWayOut()
+ {
+ var (strict, _, raised) = new ReleasedContourState()
+ .Check(SlugExit(raised: true), Vector.Zero, 1, false, null, default);
+
+ Assert.Empty(strict);
+ Assert.Empty(raised);
+ }
+
+ [Fact]
+ public void ARaisedRapidThatCrossesItsCutoutAgainIsNotASlugExit()
+ {
+ // A C-shaped cutout: the way out of its upper arm runs down across the lower arm too.
+ var program = new Program(Mode.Absolute);
+ program.Codes.Add(new RapidMove(4, 6.5));
+ program.Codes.Add(new LinearMove(4, 7) { Layer = LayerType.Leadin });
+ foreach (var point in new[]
+ {
+ new Vector(3, 7), new Vector(3, 6), new Vector(6, 6), new Vector(6, 4), new Vector(3, 4),
+ new Vector(3, 3), new Vector(7, 3), new Vector(7, 7), new Vector(4, 7),
+ })
+ program.Codes.Add(new LinearMove(point) { Layer = LayerType.Cut });
+ program.Codes.Add(new LinearMove(4.2, 6.5) { Layer = LayerType.Leadout });
+ program.Codes.Add(new Comment(HeadRaise.Marker));
+ program.Codes.Add(new RapidMove(4.2, 2));
+ var execution = ExecutionMotionReader.Read(program, Vector.Zero, Vector.Zero, default);
+
+ var (_, _, raised) = new ReleasedContourState().Check(execution, Vector.Zero, 1, false, null, default);
+
+ Assert.Contains(raised, f => f.Kind == PostVerificationKind.RapidCrossing);
+ }
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 5af0b442..7c4ba597 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -858,9 +858,21 @@ raise the head (`RaisedRapids`, `OpenNest.CNC.HeadRaise`): a `HEAD RAISE` commen
before the rapid in the part's program, which saves with the nest. A sub-program may be shared,
so it is never marked: when the rapid arrives at a hole sub-program (a round hole is cut as one),
the comment goes directly before the part's own call, and the call's arrival rapid is the raised
-one (`ExecutionMotion.CallCode` names the call a sub-program's motions run in). This is the only place a
-raise is added, so a plate the strict search can plan never gets one: it is the last resort for
-a route no search kept head-down, not a shortcut. Only rapids the planner chose in a
+one (`ExecutionMotion.CallCode` names the call a sub-program's motions run in). Apart from the
+slug exits below, this is the only place a raise is added, so a plate the strict search can plan
+never gets one: it is the last resort for a route no search kept head-down, not a shortcut.
+
+A cutout's lead-out ends in its slug, which is cut free and may drop or tip, so the rapid after
+it always leaves over the cutout just cut. Emission therefore marks that rapid to raise the head
+(or the next hole's call, as above) whenever a cutout has a lead-out; the checker accepts a raised
+rapid that starts at the lead-out's end, inside the cutout, and meets its cut exactly once on the
+way out, and reports none of its crossings, in the search, in replay and in pre-post
+verification. These designed raises are not counted in `HeadRaises` or listed as notes. A raised
+rapid that crosses its cutout again (a re-entrant slug) is treated as any raised rapid, and a
+head-down one still crosses its cutout. A cutout cut without a lead-out, or with its cut run on
+along itself as an overcut, leaves from the cut and keeps the head down. A cutout cut last in a
+part with no cut perimeter leaves into the next part's arrival rapid, which emission cannot mark,
+so that rapid is still checked strictly. Only rapids the planner chose in a
regenerated program are marked; a rapid into or inside a locked program or a cut-off, and a
rapid inside a hole sub-program after its arrival, keeps its crossing as a finding. The checker sets a raised rapid's crossings apart
instead of reporting them, in replay and in pre-post verification alike.
diff --git a/docs/post-verification.md b/docs/post-verification.md
index 3373930a..c52ed178 100644
--- a/docs/post-verification.md
+++ b/docs/post-verification.md
@@ -91,7 +91,8 @@ shows the first three check categories, even when there are no findings:
bypass. A rapid Plan Cutting marked to raise the head (see
[the safest-route fallback](cutting-planner.md#safest-route-fallback-when-the-search-runs-out))
passes over cut contours with the head up, so its contour crossings are not warnings; its
- clamp crossings still are.
+ clamp crossings still are. The raised rapid out of a cutout's slug after its lead-out is
+ accepted the same way; a head-down one still warns that it crosses the cutout.
- **Fixed leads:** Plan Cutting accepts fixed emissions as given and only warns (see
[cutting planner](cutting-planner.md#fixed-emissions-warn)), so posting checks them
again: every lead-in and lead-out of a locked program, and of each contour a part's