fix(planning): raise the head out of a cutout's slug after its lead-out

A cutout's lead-out ends in its slug, so the rapid after it always crossed
the cutout just cut and the strict search refused every plan with cutout
lead-outs. The slug is free and may drop or tip, so emission now marks that
rapid to raise the head, and the rapid check accepts a raised rapid that
starts at the lead-out's end and leaves its cutout exactly once. Cutouts
without a lead-out, or with an overcut, still leave from the cut head down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-03 13:45:12 -05:00
1 parent 6c4ebe49b3
commit 53d46303cd
7 files changed
+201 -12

No files matched your search

+1 -1
View File
@@ -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.
@@ -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
}
}
/// <summary>
/// Whether the rapid from <paramref name="start"/> inside <paramref name="cutout"/> to
/// <paramref name="end"/> 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.
/// </summary>
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;
/// <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
@@ -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(
/// <returns>Whether the contour is a cutout left by a lead-out, which ends in its slug.</returns>
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;
}
/// <summary>
+2 -1
View File
@@ -6,7 +6,8 @@ namespace OpenNest.CNC
{
/// <summary>
/// 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
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>, or before the
/// <see cref="SubProgramCall"/> 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,
@@ -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);
}
}
+15 -3
View File
@@ -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.
+2 -1
View File
@@ -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