mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 22:01:06 -04:00
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:
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(() =>
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user