fix(cutting): plan with staged scrap cut-offs within budget

Staged cut-offs were appended after every part, so a kept order (the
"Keep the current part order" option, or the fallback after a free
search runs out of budget) always conflicted with any cut-off crossing
a part. PlateSequencing.PlaceCutOffs moves each new cut-off to just
before the first part it crosses, separators last, without moving
anything else; the panel applies it after staging.

The free search also learned "cut the cut-off first" only after a full
stall, re-planning the many-holed parts before it. A cut-off is a fixed
program that never gets harder to cut earlier, so a crossing approach
into a non-separating cut-off now teaches that rule at once. On the
reported 35-part plate this drops the plan from 23,930 expansions (over
the 20,000 budget) to 15,605.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 22:14:28 -04:00
1 parent ee8be3d79e
commit 13ab8e02f8
7 files changed
+195 -4

No files matched your search

@@ -70,7 +70,7 @@ internal static class JointCuttingPlanSearch
foreach (var (preferredOnly, contourRecovery) in preferEndpoints
? new[] { (true, false), (false, true), (false, false) } : [(false, false)])
{
var pass = walk.Follow(sequence, stall, resume, preferredOnly, contourRecovery);
var pass = walk.Follow(sequence, stall, resume, preferredOnly, contourRecovery, prerequisites);
if (pass.Order != null)
return walk.Ready(pass.Order);
if (Learn(pass, prerequisites))
@@ -144,6 +144,11 @@ internal static class JointCuttingPlanSearch
return learned;
}
// True when a crossing of the blocked part's approach would teach a new order rule.
private static bool CanLearn(Attempt attempt, HashSet<int>[] prerequisites) =>
attempt.Blocked is int blocked && attempt.Crossed.Any(crossed => crossed != blocked
&& !prerequisites[crossed].Contains(blocked) && !Precedes(crossed, blocked, prerequisites));
// True when 'first' must already come before 'second' through the prerequisite chain.
private static bool Precedes(int first, int second, HashSet<int>[] prerequisites)
{
@@ -226,8 +231,11 @@ internal static class JointCuttingPlanSearch
/// a null sequence tries every dependency-ready part, nearest first.
/// With a stall limit the attempt ends once that many expansions pass without getting
/// further along the order; it never backtracks behind its starting node.
/// With <paramref name="prerequisites"/>, an approach into a cutoff that crosses parts
/// already cut ends the attempt at once when that teaches "cut it before them".
/// </summary>
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false)
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false,
HashSet<int>[] prerequisites = null)
{
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
var attempt = new Attempt(sequence);
@@ -246,6 +254,13 @@ internal static class JointCuttingPlanSearch
attempt.Order = node.Order;
return attempt;
}
// A cutoff is a fixed program cut the same way wherever it goes, so replanning
// the parts before it can only change where its approach starts. Cutting it
// earlier never makes its own cut harder; learn that rather than stall here.
if (prerequisites != null && sequence != null && attempt.Blocked is int blocked
&& snapshot.Placements[blocked] is { IsCutOff: true, SeparatesSheet: false }
&& CanLearn(attempt, prerequisites))
return attempt;
if (attempt.Advance(node))
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
@@ -36,6 +36,58 @@ namespace OpenNest.Engine.Sequencing
plate.Parts.Add(part);
}
/// <summary>
/// Moves the given cutoffs' parts to where the cutoff rules need them while every other
/// part and cutoff keeps its position: just before the first part each nominal line
/// crosses, or last when it splits the whole sheet. One that crosses nothing stays put,
/// unless it would follow a sheet-separating cutoff.
/// </summary>
public static void PlaceCutOffs(Plate plate, IEnumerable<CutOff> definitions)
{
var bounds = plate.BoundingBox(includeParts: false);
var moving = definitions
.Select(definition => (Definition: definition, Part: plate.Parts.FirstOrDefault(part =>
ReferenceEquals(part.BaseDrawing, definition.Drawing))))
.Where(item => item.Part != null).ToList();
if (moving.Count == 0)
return;
var existing = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
foreach (var cutOff in plate.CutOffs)
existing[cutOff.Drawing] = cutOff;
var order = plate.Parts.ToList();
var separating = new List<Part>();
foreach (var (definition, part) in moving)
{
if (CutOff.SeparatesSheet(part, definition, bounds))
{
order.Remove(part);
separating.Add(part);
continue;
}
var target = order.FindIndex(other => !other.BaseDrawing.IsCutOff
&& CuttingDependencyGraph.CutOffCrosses(definition.Axis, definition.Position,
definition.StartLimit, definition.EndLimit, other.BoundingBox, bounds));
if (target < 0)
target = order.FindIndex(other => other.BaseDrawing.IsCutOff && !moving.Any(m => ReferenceEquals(m.Part, other))
&& existing.TryGetValue(other.BaseDrawing, out var cut) && CutOff.SeparatesSheet(other, cut, bounds));
if (target < 0 || target > order.IndexOf(part))
continue;
order.Remove(part);
order.Insert(target, part);
}
order.AddRange(separating);
// Touch only the moved cutoff parts; real parts never leave the plate.
foreach (var (_, part) in moving)
plate.Parts.Remove(part);
for (var index = 0; index < order.Count; index++)
{
if (moving.Any(item => ReferenceEquals(item.Part, order[index])))
plate.Parts.Insert(index, order[index]);
}
}
private static IEnumerable<Part> CutOrder(IPartSequencer sequencer, List<Part> parts, Plate plate)
{
if (parts.Count == 0)
@@ -76,6 +76,40 @@ public class CuttingDependencyTests
&& f.Message.Contains("before the sheet is separated"));
}
// The tour cuts B, then the locked rectangle A (leaving right), then the cutoff pierced at
// (10, 0) below-left of A: that approach crosses A. Replanning B's entries can never fix it,
// so the search learns "cutoff first" at once instead of retrying each of B's entries.
[Fact]
public void Plan_ApproachIntoACutOffCrossingCutParts_LearnsToCutItFirstWithoutRetrying()
{
var parameters = ExplicitContourTests.Parameters();
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Size = new Size(100, 100);
var b = Emitted(parameters, new Vector(20, 60));
var a = Rectangle(20, 40, 10, 10);
a.LeadInsLocked = true;
var cutOff = new CutOff(new Vector(10, 0), CutOffAxis.Vertical) { StartLimit = 0, EndLimit = 40 };
var program = new Program();
program.Codes.Add(new RapidMove(10, 0));
program.Codes.Add(new LinearMove(10, 40) { Layer = LayerType.Cut });
cutOff.Drawing.Program = program;
plate.CutOffs.Add(cutOff);
var cut = new Part(cutOff.Drawing);
plate.Parts.Add(b);
plate.Parts.Add(a);
plate.Parts.Add(cut);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(21, 70),
confirmedParameters: parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
var order = result.ProposedOrder.Select(o => o.SourcePart).ToList();
Assert.True(order.IndexOf(cut) < order.IndexOf(a), string.Join(",", result.ProposedOrder.Select(o => o.SourceOrdinal)));
// Learning at once took 22 expansions; retrying every entry of B first took 60.
Assert.True(result.Expansions < 40, Describe(result));
}
// 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()
@@ -401,6 +401,37 @@ public class PlateSequencingTests
Assert.Same(square, plate.Parts.First(p => !p.BaseDrawing.IsCutOff));
}
[Fact]
public void PlaceCutOffs_MovesOnlyTheGivenCutOffsWhereTheRulesNeedThem()
{
var a = TestHelpers.MakePartAt(10, 10, 10);
var b = TestHelpers.MakePartAt(40, 10, 10);
var c = TestHelpers.MakePartAt(70, 10, 10);
var plate = TestHelpers.MakePlate(60, 120, a, b, c);
var settings = new CutOffSettings();
var existingSeparator = new CutOff(new Vector(110, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(existingSeparator);
plate.RegenerateCutOffs(settings);
var crossing = new CutOff(new Vector(45, 0), CutOffAxis.Vertical);
var limited = new CutOff(new Vector(60, 0), CutOffAxis.Vertical) { EndLimit = 30 };
var separator = new CutOff(new Vector(100, 0), CutOffAxis.Vertical);
// As staging adds them: appended after everything, the existing separator included.
plate.CutOffs.AddRange([crossing, limited, separator]);
plate.RegenerateCutOffs(settings);
Part Cut(CutOff cutOff) => plate.Parts.Single(p => ReferenceEquals(p.BaseDrawing, cutOff.Drawing));
Assert.Equal(new[] { a, b, c, Cut(existingSeparator), Cut(crossing), Cut(limited), Cut(separator) },
plate.Parts);
var removed = 0;
plate.PartRemoved += (_, e) => Assert.True(e.Item.BaseDrawing.IsCutOff, "Only cut-off parts move.");
plate.PartRemoved += (_, _) => removed++;
PlateSequencing.PlaceCutOffs(plate, [crossing, limited, separator]);
Assert.Equal(new[] { a, Cut(crossing), b, c, Cut(limited), Cut(existingSeparator), Cut(separator) },
plate.Parts);
Assert.Equal(3, removed);
}
private static Part[] Baseline(Plate plate, SequenceParameters parameters) =>
PartSequencerFactory.Create(parameters).Sequence(plate.Parts.ToList(), plate)
.Select(p => p.Part).Reverse().ToArray();
@@ -631,6 +631,47 @@ public class CuttingInspectionPanelTests
Assert.All(plate.Parts.Where(p => !p.BaseDrawing.IsCutOff), part => Assert.True(part.HasManualLeadIns));
});
[Fact]
public void KeptOrderPlansWithStagedCutOffsPlacedBeforeThePartsTheyCross() => RunSta(() =>
{
// A 50 in wide part: every 35 in strip line crosses it, so a strip appended after it
// would conflict with the kept order.
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Size = new OpenNest.Geometry.Size(100, 100);
plate.CuttingParameters = Parameters();
var program = new OpenNest.CNC.Program();
program.MoveTo(0, 0);
program.LineTo(0, 10);
program.LineTo(50, 10);
program.LineTo(50, 0);
program.LineTo(0, 0);
var wide = new Part(new Drawing("wide", program), new Vector(1, 1));
var small = Square("small", 60);
plate.Parts.Add(wide);
plate.Parts.Add(small);
using var editor = new EditNestForm(nest);
editor.Show();
editor.PlateManager.LoadAt(0);
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Field<CheckBox>(panel, "keepOrderCheckBox").Checked = true;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Field<CheckBox>(panel, "autoCutOffsCheckBox").Checked = true;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
var strip = plate.Parts.First(p => p.BaseDrawing.IsCutOff);
Assert.True(plate.Parts.IndexOf(strip) < plate.Parts.IndexOf(wide));
Assert.True(plate.Parts.IndexOf(wide) < plate.Parts.IndexOf(small));
var planned = Assert.Single(panel.Proposal.Plates);
Assert.True(planned.IsReady, string.Join("\n", panel.Proposal.Describe("in")));
Assert.True(Field<Button>(panel, "applyButton").Enabled);
panel.RequestClose();
Assert.Equal(new[] { wide, small }, plate.Parts);
});
[Fact]
public void ExistingLeadInsShowTheRedBannerAndClearRecalculatesFromScratch() => RunSta(() =>
{
+12
View File
@@ -11,6 +11,7 @@ using OpenNest.CNC.CuttingStrategy;
using OpenNest.Controls;
using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Engine.Sequencing;
namespace OpenNest.Forms;
@@ -1131,6 +1132,17 @@ internal sealed class CuttingInspectionPanel : UserControl
+ "Untick scrap cut-offs to plan without them, or clear the existing cut-offs.");
return false;
}
try
{
// Appended last, a crossing cut-off breaks any kept order; move it ahead of what it crosses.
for (var index = 0; index < plates.Length; index++)
PlateSequencing.PlaceCutOffs(plates[index], staging.Plans[index].Definitions);
}
catch
{
staging.Revert();
throw;
}
stagedCutOffs = staging.CanRevert ? staging : null;
view.Invalidate();
return true;
+8 -2
View File
@@ -176,7 +176,9 @@ along an order that teaches such a rule ends the round; the directed-recovery an
full passes run only when the lighter pass taught nothing. An attempt
stops backtracking after a stall of 8 x entries x contours expansions without
getting further, so it learns instead of retrying every entry combination of the
parts before it. A rule that would contradict an order already required is skipped.
parts before it. A cutoff is the exception: it is a fixed program and cutting it earlier never
makes its own cut harder, so an approach into a non-separating cutoff that crosses parts already
cut teaches "cut the cutoff before them" at once, without that stall. A rule that would contradict an order already required is skipped.
"Cut before" rules are a heuristic (a part blocked straight after another may be
reachable via a third), so once nothing new can be learned the remaining budget
first tries sequence-free recovery, visiting dependency-ready parts lazily by
@@ -531,7 +533,11 @@ panel is wired in the editor. Non-editor callers still use the shared analyzer.
- `Add scrap cut-offs` runs the automatic cut-off planner (defaults 35 in /
889 mm strips, half-strip minimum, 12 in / 304.8 mm tail) on every plate in scope
*before* capture, so the cutting plan sequences the new lines like any
other cut-off. These are the only live changes made before Apply: Apply keeps them,
other cut-off. Each added cut-off is moved in the part list to just before the first part
its nominal line crosses (a sheet-separating one last, one that crosses nothing ahead of any
existing separator), so a kept order, including the fallback after a free search runs out of
budget, satisfies the cut-off rules; other parts and cut-offs keep their positions.
These are the only live changes made before Apply: Apply keeps them,
while unticking the box, Close or switching to Validate removes them and
restores the earlier cut-offs and sequence. The proposal preview draws planned cut-offs
with the same orange-red dashed line as the editor. A blocked cut-off plan adds nothing and