From ddd1e422bef30b191e195b0a86e0d820cd064d04 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 21:09:35 -0400 Subject: [PATCH] feat(cutoffs): cut sheet-separating cut-offs after every part --- OpenNest.Core/CutOff.cs | 55 ++++++++++++++++ .../CuttingPlanning/CuttingDependencyGraph.cs | 15 ++++- .../CuttingPlanning/CuttingPlanModels.cs | 5 +- .../CuttingPlanning/CuttingPlanService.cs | 13 +++- OpenNest.Engine/Sequencing/PlateSequencing.cs | 24 +++++-- .../CutOffs/CutOffSeparationTests.cs | 65 +++++++++++++++++++ .../CuttingPlanning/CuttingDependencyTests.cs | 61 +++++++++++++++++ .../Sequencing/PlateSequencingTests.cs | 44 +++++++++---- docs/automatic-scrap-cutoffs.md | 2 +- docs/cutting-planner.md | 15 ++++- 10 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 OpenNest.Tests/CutOffs/CutOffSeparationTests.cs diff --git a/OpenNest.Core/CutOff.cs b/OpenNest.Core/CutOff.cs index e967fbb..5cc8861 100644 --- a/OpenNest.Core/CutOff.cs +++ b/OpenNest.Core/CutOff.cs @@ -50,6 +50,61 @@ namespace OpenNest return (segments.Sum(s => s.End - s.Start), segments.Count); } + /// + /// True when , the placed part of , cuts + /// one continuous straight line on the definition's axis across the whole sheet, so it + /// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a + /// line on the sheet edge, a stale or unrecognised program) does not separate the sheet. + /// + internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet) + { + if (part?.Program == null || definition == null || sheet == null + || part.Program.Mode != Mode.Absolute) + return false; + var vertical = definition.Axis == CutOffAxis.Vertical; + var line = vertical ? definition.Position.X : definition.Position.Y; + var acrossMin = vertical ? sheet.Left : sheet.Bottom; + var acrossMax = vertical ? sheet.Right : sheet.Top; + var alongMin = vertical ? sheet.Bottom : sheet.Left; + var alongMax = vertical ? sheet.Top : sheet.Right; + if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon)) + return false; + + Vector? position = null; + double? low = null, high = null; + var cutting = false; + foreach (var code in part.Program.Codes) + { + switch (code) + { + case RapidMove rapid: + if (cutting) + return false; // A second pierce leaves an uncut gap. + position = rapid.EndPoint + part.Location; + break; + case LinearMove move when position != null: + var end = move.EndPoint + part.Location; + if (!OnLine(position.Value) || !OnLine(end)) + return false; + foreach (var value in new[] { Along(position.Value), Along(end) }) + { + low = low == null ? value : System.Math.Min(low.Value, value); + high = high == null ? value : System.Math.Max(high.Value, value); + } + position = end; + cutting = true; + break; + case Motion: + return false; + } + } + return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon; + + bool OnLine(Vector point) => + System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon; + double Along(Vector point) => vertical ? point.Y : point.X; + } + private string GetName() { var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H"; diff --git a/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs b/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs index 6d03bd6..e172886 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingDependencyGraph.cs @@ -83,6 +83,16 @@ internal sealed class CuttingDependencyGraph { if (!nodes[cut].IsCutOff) continue; + if (nodes[cut].SeparatesSheet) + { + // Cutting a line that splits the sheet comes after every part and every other + // cutoff, so the head never travels back across the separated edge. Such a line + // is continuous, so it passes through no part and needs no crossing edge. + for (var other = 0; other < nodes.Count; other++) + if (!nodes[other].SeparatesSheet) + edges[cut].Add(other); + continue; + } var definition = nodes[cut].CutOff; for (var part = 0; part < nodes.Count; part++) { @@ -190,9 +200,10 @@ internal sealed class DependencyNode private LeadMaterialSnapshot material; internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds, - Func materialFactory) + Func materialFactory, bool separatesSheet = false) { IsCutOff = isCutOff; + SeparatesSheet = isCutOff && separatesSheet; CutOff = cutOff; PlacedBounds = Copy(placedBounds); CleanBounds = cleanBounds == null ? null : Copy(cleanBounds); @@ -201,6 +212,8 @@ internal sealed class DependencyNode } internal bool IsCutOff { get; } + /// A cutoff whose placed program is one continuous cut across the whole sheet. + internal bool SeparatesSheet { get; } internal CutOffDefinition CutOff { get; } internal Box PlacedBounds { get; } internal Box CleanBounds { get; } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index 388266c..3936056 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -147,12 +147,15 @@ public sealed class FixedProgramPlacement } return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution, OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices) - { SelectedProgram = selected, IsCutOff = IsCutOff }; + { SelectedProgram = selected, IsCutOff = IsCutOff, SeparatesSheet = SeparatesSheet }; } /// A cutoff: always a fixed open-cut program, never material or regenerated. public bool IsCutOff { get; internal init; } + /// A cutoff that splits the whole sheet; it is cut after every other placement. + public bool SeparatesSheet { get; internal init; } + public Part SourcePart { get; } public int SourceOrdinal { get; } public Vector Location { get; } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index 5681a72..23b7eae 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -78,12 +78,15 @@ public static class CuttingPlanService var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token); if (!cut.HasCuttingContour) throw new ArgumentException("Placed cutoff program has no nonzero cutting motions."); - nodes.Add(new(true, definitions.TryGetValue(source.BaseDrawing, out var definition) + var defined = definitions.TryGetValue(source.BaseDrawing, out var definition); + var separates = defined && CutOff.SeparatesSheet(source, definition, + request.Plate.BoundingBox(includeParts: false)); + nodes.Add(new(true, defined ? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null, - source.BoundingBox, null, null)); + source.BoundingBox, null, null, separates)); placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked, cut, OwnedProgramCopy.Copy(source.Program, token)) - { IsCutOff = true }); + { IsCutOff = true, SeparatesSheet = separates }); continue; } var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token); @@ -382,6 +385,10 @@ public static class CuttingPlanService { var part = snapshot.Placements[violation.Part]; var prerequisite = snapshot.Placements[violation.Prerequisite]; + if (part.SeparatesSheet) + return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null, + $"{subject} cuts the sheet-separating cutoff (part {part.SourceOrdinal + 1}) before part " + + $"{prerequisite.SourceOrdinal + 1}; every part and other cutoff must be cut before the sheet is separated."); var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout"; return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null, $"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1})."); diff --git a/OpenNest.Engine/Sequencing/PlateSequencing.cs b/OpenNest.Engine/Sequencing/PlateSequencing.cs index 2e9b99f..b934d64 100644 --- a/OpenNest.Engine/Sequencing/PlateSequencing.cs +++ b/OpenNest.Engine/Sequencing/PlateSequencing.cs @@ -34,16 +34,27 @@ namespace OpenNest.Engine.Sequencing private static List OrderCutOffsFirst(List ordered, Plate plate) { - var cuts = ordered.Select((part, index) => (Part: part, Index: index)) - .Where(item => item.Part.BaseDrawing.IsCutOff).ToList(); - if (cuts.Count == 0) - return ordered; - var definitions = new Dictionary(ReferenceEqualityComparer.Instance); foreach (var cutOff in plate.CutOffs) definitions[cutOff.Drawing] = cutOff; - var bounds = plate.BoundingBox(includeParts: false); + + // A cutoff that splits the whole sheet goes after every part and other cutoff, so the + // head never travels back across it. It passes through no part. + var separating = ordered.Where(part => part.BaseDrawing.IsCutOff + && definitions.TryGetValue(part.BaseDrawing, out var definition) + && CutOff.SeparatesSheet(part, definition, bounds)).ToList(); + if (separating.Count > 0) + { + var last = new HashSet(separating, ReferenceEqualityComparer.Instance); + ordered = ordered.Where(part => !last.Contains(part)).ToList(); + } + + var cuts = ordered.Select((part, index) => (Part: part, Index: index)) + .Where(item => item.Part.BaseDrawing.IsCutOff).ToList(); + if (cuts.Count == 0) + return ordered.Concat(separating).ToList(); + var emitted = new bool[ordered.Count]; var result = new List(ordered.Count); for (var i = 0; i < ordered.Count; i++) @@ -75,6 +86,7 @@ namespace OpenNest.Engine.Sequencing } } + result.AddRange(separating); return result; } } diff --git a/OpenNest.Tests/CutOffs/CutOffSeparationTests.cs b/OpenNest.Tests/CutOffs/CutOffSeparationTests.cs new file mode 100644 index 0000000..a98643c --- /dev/null +++ b/OpenNest.Tests/CutOffs/CutOffSeparationTests.cs @@ -0,0 +1,65 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CutOffs; + +/// Which cut-offs split the whole sheet (and so must be cut after every part). +public class CutOffSeparationTests +{ + [Theory] + [InlineData(1, CutDirection.AwayFromOrigin)] + [InlineData(2, CutDirection.TowardOrigin)] + [InlineData(3, CutDirection.AwayFromOrigin)] + [InlineData(4, CutDirection.TowardOrigin)] + public void ContinuousFullSpanLine_SeparatesTheSheet_InEveryQuadrantAndDirection(int quadrant, CutDirection direction) + { + var plate = new Plate(60, 120) { Quadrant = quadrant }; + var bounds = plate.BoundingBox(includeParts: false); + plate.Parts.Add(TestHelpers.MakePartAt(bounds.Left + 10, bounds.Bottom + 10, 10)); + var vertical = new CutOff(new Vector(bounds.Left + 40, 0), CutOffAxis.Vertical); + var horizontal = new CutOff(new Vector(0, bounds.Bottom + 40), CutOffAxis.Horizontal); + var crossing = new CutOff(new Vector(bounds.Left + 15, 0), CutOffAxis.Vertical); + plate.CutOffs.AddRange([vertical, horizontal, crossing]); + plate.RegenerateCutOffs(new CutOffSettings { CutDirection = direction, Overtravel = 0.5 }); + + Assert.True(Separates(plate, vertical)); + Assert.True(Separates(plate, horizontal)); + Assert.False(Separates(plate, crossing)); // Trimmed around the part: two pieces. + } + + [Fact] + public void LimitedEdgeStaleOrBrokenLines_DoNotSeparateTheSheet() + { + var plate = new Plate(60, 120); + plate.Parts.Add(TestHelpers.MakePartAt(10, 10, 10)); + var limited = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { EndLimit = 30 }; + var onEdge = new CutOff(new Vector(0, 0), CutOffAxis.Vertical); + var stale = new CutOff(new Vector(50, 0), CutOffAxis.Vertical); + var split = new CutOff(new Vector(70, 0), CutOffAxis.Vertical); + plate.CutOffs.AddRange([limited, onEdge, stale, split]); + plate.RegenerateCutOffs(new CutOffSettings()); + Assert.True(Separates(plate, stale)); + Assert.True(Separates(plate, split)); + + stale.Position = new Vector(51, 0); // Moved without regeneration: its program is elsewhere. + var program = new Program(); + program.Codes.Add(new RapidMove(70, 0)); + program.Codes.Add(new LinearMove(70, 30)); + program.Codes.Add(new RapidMove(70, 30.5)); + program.Codes.Add(new LinearMove(70, 60)); + split.Drawing.Program = program; + var broken = new Part(split.Drawing); + + Assert.False(Separates(plate, limited)); + Assert.False(Separates(plate, onEdge)); + Assert.False(Separates(plate, stale)); + Assert.False(CutOff.SeparatesSheet(broken, split, plate.BoundingBox(false))); // Two pierces leave a gap. + Assert.False(CutOff.SeparatesSheet(Part(plate, limited), null, plate.BoundingBox(false))); + } + + private static bool Separates(Plate plate, CutOff cutOff) => + CutOff.SeparatesSheet(Part(plate, cutOff), cutOff, plate.BoundingBox(includeParts: false)); + + private static Part Part(Plate plate, CutOff cutOff) => + Assert.Single(plate.Parts, p => ReferenceEquals(p.BaseDrawing, cutOff.Drawing)); +} diff --git a/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs index 64270a7..7d4e845 100644 --- a/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs @@ -39,6 +39,67 @@ public class CuttingDependencyTests Assert.Contains(q, plate.Parts); } + [Fact] + public void Plan_SheetSeparatingCutOff_IsCutAfterEveryPartAndOtherCutOff() + { + var parameters = ExplicitContourTests.Parameters(); + var (plate, separator, crossing, left, right) = SeparatedPlate(); + var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters)); + var separatorIndex = plate.Parts.IndexOf(separator); + Assert.Equal(Enumerable.Range(0, plate.Parts.Count).Where(i => i != separatorIndex), + snapshot.Dependencies.PrerequisitesOf(separatorIndex)); + + var result = CuttingPlanService.Plan(snapshot); + + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + var order = result.ProposedOrder.Select(o => o.SourcePart).ToList(); + Assert.Same(separator, order[^1]); + Assert.True(result.ProposedOrder[^1].SeparatesSheet); + Assert.False(result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, crossing)).SeparatesSheet); + Assert.True(order.IndexOf(crossing) < order.IndexOf(left)); + Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status); + Assert.Same(separator, plate.Parts[^1]); + Assert.Contains(right, plate.Parts); + } + + [Fact] + public void Plan_KeptOrderCuttingTheSeparatorFirst_IsAConstraintConflictThatSaysWhy() + { + var (plate, separator, _, _, _) = SeparatedPlate(); + Assert.Same(separator, plate.Parts[0]); + + var result = CuttingPlanService.Plan(CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: ExplicitContourTests.Parameters(), preservePartOrder: true))); + + Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status); + Assert.Contains(result.Findings, f => f.Message.Contains("sheet-separating cutoff") + && f.Message.Contains("before the sheet is separated")); + } + + // 100 x 100 sheet: squares at X 1-11 and 60-70; a cutoff at X 5 is trimmed around the left + // square, while a continuous line at X 40 splits the sheet. Both cutoffs start the sequence. + private static (Plate, Part Separator, Part Crossing, Part Left, Part Right) SeparatedPlate() + { + var nest = new Nest(); + var plate = nest.CreatePlate(); + plate.Size = new Size(100, 100); + var left = new Part(new Drawing("left", ExplicitContourTests.Square(false)), new Vector(1, 1)); + var right = new Part(new Drawing("right", ExplicitContourTests.Square(false)), new Vector(60, 1)); + plate.Parts.Add(left); + plate.Parts.Add(right); + var separator = new CutOff(new Vector(40, 0), CutOffAxis.Vertical); + var crossing = new CutOff(new Vector(5, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(separator); + plate.CutOffs.Add(crossing); + plate.RegenerateCutOffs(new CutOffSettings()); + var cuts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToArray(); + Assert.Equal(2, cuts.Length); + var separatorPart = cuts.Single(p => ReferenceEquals(p.BaseDrawing, separator.Drawing)); + var crossingPart = cuts.Single(p => ReferenceEquals(p.BaseDrawing, crossing.Drawing)); + plate.Parts.Reorder([separatorPart, crossingPart, left, right]); + return (plate, separatorPart, crossingPart, left, right); + } + [Fact] public void Apply_CutOffReclassifiedAfterPlanning_IsStale() { diff --git a/OpenNest.Tests/Sequencing/PlateSequencingTests.cs b/OpenNest.Tests/Sequencing/PlateSequencingTests.cs index 1652143..6853348 100644 --- a/OpenNest.Tests/Sequencing/PlateSequencingTests.cs +++ b/OpenNest.Tests/Sequencing/PlateSequencingTests.cs @@ -245,20 +245,42 @@ public class PlateSequencingTests } [Fact] - public void Apply_NonCrossingTailCutOff_KeepsItsNormalSequencePlace() + public void Apply_SheetSeparatingCutOff_GoesAfterEveryPartAndCrossingCutOff() { - var part = TestHelpers.MakePartAt(10, 10, 10); - var plate = TestHelpers.MakePlate(60, 120, part); - var tail = new CutOff(new Vector(40, 0), CutOffAxis.Vertical); - plate.CutOffs.Add(tail); - plate.RegenerateCutOffs(new CutOffSettings()); - var parameters = new SequenceParameters { Method = SequenceMethod.LeftSide }; - var expected = Baseline(plate, parameters); - Assert.Same(part, expected[0]); + var hazards = 0; + foreach (var method in Enum.GetValues()) + { + var left = TestHelpers.MakePartAt(10, 10, 10); + var right = TestHelpers.MakePartAt(80, 10, 10); + var plate = TestHelpers.MakePlate(60, 120, left, right); + var separator = new CutOff(new Vector(40, 0), CutOffAxis.Vertical); + var crossing = new CutOff(new Vector(85, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(separator); + plate.CutOffs.Add(crossing); + plate.RegenerateCutOffs(new CutOffSettings()); + var parameters = new SequenceParameters { Method = method }; + var separatorPart = plate.Parts.Single(p => ReferenceEquals(p.BaseDrawing, separator.Drawing)); + List baseline; + try + { + baseline = Baseline(plate, parameters).ToList(); + } + catch (NotSupportedException) + { + continue; + } + if (baseline.IndexOf(separatorPart) < baseline.IndexOf(right) + || baseline.IndexOf(separatorPart) < baseline.IndexOf(left)) + hazards++; - PlateSequencing.Apply(plate, parameters); + PlateSequencing.Apply(plate, parameters); - Assert.Equal(expected, plate.Parts); + Assert.Same(separatorPart, plate.Parts[^1]); + AssertPrecedes(plate, crossing, right); + Assert.Equal(4, plate.Parts.Count); + } + // Without the rule, at least one route cuts the separator before a part. + Assert.True(hazards > 0); } [Fact] diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md index f12e0dc..48d7214 100644 --- a/docs/automatic-scrap-cutoffs.md +++ b/docs/automatic-scrap-cutoffs.md @@ -45,7 +45,7 @@ Existing full-width vertical cut-offs inside the used span become fixed strip bo For **Plate > Sequence Parts > Manual Sequence**, choose the one-based sequence number and hover a part on the sheet; only the part under the pointer is outlined. Click it to place it at that exact position, then the number advances up to the last part. Clicking empty sheet space changes nothing. Use **Done**, the window close button, right-click on the sheet, or Escape to leave the tool. Manual reordering changes neither geometry nor quantities. -After adding cutoffs, apply **Part Sequencing** to the current plate or all plates. Each cutoff is moved earlier as needed so it is cut before every part its nominal line passes through. Ordinary parts retain their relative order from the chosen sequencing route; a tail separator that crosses no parts keeps its normal route position rather than being forced to the front. Cutoff geometry, clearance, part programs, placements, and quantities are unchanged. +After adding cutoffs, apply **Part Sequencing** to the current plate or all plates. Each cutoff is moved earlier as needed so it is cut before every part its nominal line passes through. Ordinary parts retain their relative order from the chosen sequencing route. A cutoff that splits the whole sheet — one continuous cut from sheet edge to sheet edge, such as a tail separator — goes after every part and every other cutoff, so the head never travels back across the separated edge. Cutoffs that cross no part but leave gaps keep their normal route position. Cutoff geometry, clearance, part programs, placements, and quantities are unchanged. The dependency check uses the nominal horizontal/vertical line and its start/end limits, not the trimmed cutting segments, which intentionally skip the parts. It conservatively checks placed part bounds (including edge contacts), so a cutoff through a concave recess can also move ahead of that part. Definitions are matched by drawing identity, not their displayed names. A cutoff part with no matching definition is conservatively ordered before all ordinary parts. diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index f3e0626..8295c5e 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -95,6 +95,15 @@ the final replay enforce them: plate-scoped request; they are always fixed programs, need no lead-in, are not material for lead validation and, being open cuts, never become rapid obstacles. Rapids into and out of them are still checked. +- A cutoff that splits the whole sheet is cut after every part and every other + cutoff, so the head never travels back across the separated edge. It qualifies + when its placed program is one continuous straight cut on its own line spanning + the sheet from edge to edge (`CutOff.SeparatesSheet`). A line trimmed around parts, + limited short of an edge, on the sheet edge, stale after a move or in an + unrecognised program form does not qualify and keeps the crossing rule above. + Being continuous, a separating line passes through no part. A kept order that cuts + it earlier is a constraint conflict naming the separating cutoff (user decision, + 2026-10-13). - A part whose perimeter lies strictly inside a cutout of another part precedes that host. Material bounds (never rapids or scribe marks) only select candidate pairs; containment is proven on native clean material. Touching or crossing boundaries, or material that cannot be @@ -427,9 +436,9 @@ panel is wired in the editor. Non-editor callers still use the shared analyzer. `Cut-off Settings...` opens the Automatic Scrap Cut-offs dialog in settings mode for the same scope: it previews on the active plate as usual, but `Use These Settings` only returns the values (it adds nothing), ticks the box and replans. The panel - remembers the last settings per unit system until the application closes. A cut-off - still goes before every part its nominal line passes through; a line that passes - through no part keeps a free position in the order (user decision, 2026-10-13). + remembers the last settings per unit system until the application closes. Cut-off + order follows the dependency rules above: crossing cut-offs before the parts they + pass through, sheet-separating cut-offs last. - Lead-ins are optional for planning. With `None` (or a style that emits no lead motion), the emitter places the pierce directly on the selected contour point. Unlocked contour starts can still move to improve travel; locked programs keep