From 13ab8e02f81f991b73d032b82412fba66613b716 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 18 Oct 2026 22:14:28 -0400 Subject: [PATCH] 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 --- .../CuttingPlanning/JointCuttingPlanSearch.cs | 19 ++++++- OpenNest.Engine/Sequencing/PlateSequencing.cs | 52 +++++++++++++++++++ .../CuttingPlanning/CuttingDependencyTests.cs | 34 ++++++++++++ .../Sequencing/PlateSequencingTests.cs | 31 +++++++++++ .../Forms/CuttingInspectionPanelTests.cs | 41 +++++++++++++++ OpenNest/Forms/CuttingInspectionPanel.cs | 12 +++++ docs/cutting-planner.md | 10 +++- 7 files changed, 195 insertions(+), 4 deletions(-) diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 9fa4ee60..f8d7d41b 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -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[] 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[] 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 , an approach into a cutoff that crosses parts + /// already cut ends the attempt at once when that teaches "cut it before them". /// - 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[] 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) diff --git a/OpenNest.Engine/Sequencing/PlateSequencing.cs b/OpenNest.Engine/Sequencing/PlateSequencing.cs index e3b4cbca..2e1d2bdb 100644 --- a/OpenNest.Engine/Sequencing/PlateSequencing.cs +++ b/OpenNest.Engine/Sequencing/PlateSequencing.cs @@ -36,6 +36,58 @@ namespace OpenNest.Engine.Sequencing plate.Parts.Add(part); } + /// + /// 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. + /// + public static void PlaceCutOffs(Plate plate, IEnumerable 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(ReferenceEqualityComparer.Instance); + foreach (var cutOff in plate.CutOffs) + existing[cutOff.Drawing] = cutOff; + var order = plate.Parts.ToList(); + var separating = new List(); + 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 CutOrder(IPartSequencer sequencer, List parts, Plate plate) { if (parts.Count == 0) diff --git a/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs index 7d4e8459..75d2b387 100644 --- a/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CuttingDependencyTests.cs @@ -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() diff --git a/OpenNest.Tests/Sequencing/PlateSequencingTests.cs b/OpenNest.Tests/Sequencing/PlateSequencingTests.cs index c4f3167d..47bf757d 100644 --- a/OpenNest.Tests/Sequencing/PlateSequencingTests.cs +++ b/OpenNest.Tests/Sequencing/PlateSequencingTests.cs @@ -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(); diff --git a/OpenNest.WinForms.Tests/Forms/CuttingInspectionPanelTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingInspectionPanelTests.cs index cda7c814..26cc5904 100644 --- a/OpenNest.WinForms.Tests/Forms/CuttingInspectionPanelTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CuttingInspectionPanelTests.cs @@ -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(panel, "keepOrderCheckBox").Checked = true; + PumpUntil(() => panel.Proposal != null && !panel.IsPlanning); + + Field(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