From 53d46303cd36b78a8cb30f291a655182b84bc180 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 3 Nov 2026 13:45:12 -0500 Subject: [PATCH] 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 --- AGENTS.md | 2 +- .../CuttingPlanning/ReleasedContourState.cs | 34 ++++- .../CuttingStrategy/ContourCuttingStrategy.cs | 22 ++- OpenNest.Core/CNC/HeadRaise.cs | 3 +- .../CuttingPlanning/CutoutLeadOutTests.cs | 131 ++++++++++++++++++ docs/cutting-planner.md | 18 ++- docs/post-verification.md | 3 +- 7 files changed, 201 insertions(+), 12 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/CutoutLeadOutTests.cs 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