feat(ui): choose Plan Cutting's plate scope in the panel

The panel gains a Current plate only / All plates selector, shown when the
two scopes differ. Changing it takes the unapplied scrap cut-offs off,
cancels any running plan and replans. The toolbar button opens with the
scope last chosen there (default all plates); the Plate and Nest menu items
keep their fixed scopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-27 23:12:18 -04:00
1 parent 87e0a70878
commit fcb61f3b3e
7 files changed
+228 -14

No files matched your search

@@ -187,6 +187,88 @@ public class CuttingInspectionPanelTests
Assert.Same(original, editor.PlateView.Plate);
});
[Fact]
public void ScopeSelectorReplansForAllPlatesAndBackAndRemembersTheChoice() => RunSta(() =>
{
using var editor = TwoPlateEditor();
editor.Show();
editor.PlateManager.LoadAt(0);
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
var remembered = new List<bool>();
panel.RememberScope = remembered.Add;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
var scope = Field<ComboBox>(panel, "scopeSelector");
Assert.True(scope.Visible);
Assert.Equal(0, scope.SelectedIndex);
Assert.Equal("All plates (2)", scope.Items[1]);
Assert.Single(panel.Proposal.Plates);
scope.SelectedIndex = 1;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.True(panel.AllPlates);
Assert.Equal(2, panel.Proposal.Plates.Count);
Assert.True(Field<Button>(panel, "nextButton").Visible);
Assert.Contains("2 plates", Field<Label>(panel, "statusLabel").Text);
scope.SelectedIndex = 0;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.False(panel.AllPlates);
Assert.Single(panel.Proposal.Plates);
Assert.False(Field<Button>(panel, "nextButton").Visible);
Assert.Equal(new[] { true, false }, remembered);
panel.RequestClose();
});
[Fact]
public void ScopeChangedMidPlanReplansOnceTheRunningPlanCancels() => RunSta(() =>
{
using var editor = TwoPlateEditor();
editor.Show();
editor.PlateManager.LoadAt(0);
using var entered = new ManualResetEventSlim();
using var release = new ManualResetEventSlim();
var runs = 0;
editor.CuttingBeforePlan = token =>
{
if (Interlocked.Increment(ref runs) > 1) return;
entered.Set();
release.Wait(TimeSpan.FromSeconds(30));
Assert.True(token.IsCancellationRequested);
};
try
{
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
panel.RememberScope = _ => { };
Assert.True(entered.Wait(TimeSpan.FromSeconds(30)));
Field<ComboBox>(panel, "scopeSelector").SelectedIndex = 1;
Assert.False(panel.AllPlates); // The running plan still reads the old scope.
release.Set();
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.True(panel.AllPlates);
Assert.Equal(2, panel.Proposal.Plates.Count);
panel.RequestClose();
}
finally
{
release.Set();
}
});
[Fact]
public void ScopeSelectorIsHiddenWhenOnlyOnePlateHasParts() => RunSta(() =>
{
using var editor = Editor();
editor.Show();
editor.PlateManager.LoadAt(0);
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.False(Field<ComboBox>(panel, "scopeSelector").Visible);
panel.RequestClose();
});
[Fact]
public void BlockedOverlapRowShadesTheOverlapOnTheLivePlate() => RunSta(() =>
{
@@ -950,6 +1032,16 @@ public class CuttingInspectionPanelTests
return new EditNestForm(nest);
}
private static EditNestForm TwoPlateEditor()
{
var editor = Editor();
var second = editor.Nest.Plates[^1]; // Fill the managed empty sentinel; a new one is appended.
second.Size = new OpenNest.Geometry.Size(100, 100);
second.Parts.Add(Square("left", 1));
second.Parts.Add(Square("right", 20));
return editor;
}
private static Part Square(string name, double x)
{
var program = new OpenNest.CNC.Program();
+111 -13
View File
@@ -25,8 +25,8 @@ internal sealed class CuttingInspectionPanel : UserControl
private readonly PlateView view;
private readonly Plate plate;
private CuttingParameters parameters;
private readonly Plate[] plates;
private readonly int[] plateNumbers;
private Plate[] plates;
private int[] plateNumbers;
private int selectedPlate;
private Plate preview;
private bool populatingWarnings;
@@ -36,6 +36,7 @@ internal sealed class CuttingInspectionPanel : UserControl
private readonly OverlapDisplayMode overlapDisplay;
private CuttingProposalInspection inspection = new();
private readonly ComboBox modeSelector = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220 };
private readonly ComboBox scopeSelector = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220 };
private CancellationTokenSource validationCancellation;
private bool closeRequested;
private CuttingInspectionMode? pendingMode;
@@ -43,6 +44,8 @@ internal sealed class CuttingInspectionPanel : UserControl
private bool pendingReplan;
private bool pendingClear;
private bool pendingCutOffSettings;
// A scope chosen while a plan ran; applied once that run's cancellation lands.
private bool? pendingScope;
// The running plan's headline, re-shown each second with its elapsed time; null when not ticking.
private string planningStatus;
private readonly System.Diagnostics.Stopwatch planningClock = new();
@@ -94,7 +97,7 @@ internal sealed class CuttingInspectionPanel : UserControl
};
private readonly Button cutOffSettingsButton = new() { Text = "Cut-off Settings…", AutoSize = true };
private readonly Units units;
private readonly bool allPlates;
private bool allPlates;
private AutomaticCutOffOptions cutOffOptions;
// Last cut-off settings chosen here, per unit system, for the rest of this session.
private static readonly System.Collections.Generic.Dictionary<Units, AutomaticCutOffOptions> lastCutOffOptions = new();
@@ -155,12 +158,7 @@ internal sealed class CuttingInspectionPanel : UserControl
this.editor = editor;
view = editor.PlateView;
plate = view.Plate;
var all = editor.Nest.Plates.ToList();
plates = allPlates ? all.Where(p => p.Parts.Count > 0).ToArray() : [plate];
if (plates.Length == 0)
plates = [plate];
plateNumbers = plates.Select(p => all.IndexOf(p) + 1).ToArray();
selectedPlate = System.Math.Max(0, Array.IndexOf(plates, plate));
SetPlates(allPlates);
this.parameters = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters));
context = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
viewport = view.CaptureViewport();
@@ -182,7 +180,11 @@ internal sealed class CuttingInspectionPanel : UserControl
{
if (!configuringMode) SwitchMode((CuttingInspectionMode)modeSelector.SelectedIndex);
};
this.allPlates = allPlates;
toolTip.SetToolTip(scopeSelector, "Plan the plate on screen, or every plate with parts in one plan.");
scopeSelector.SelectedIndexChanged += (_, _) =>
{
if (!configuringMode) ChangeScope(scopeSelector.SelectedIndex == 1);
};
units = editor.Nest.Units;
cutOffOptions = lastCutOffOptions.TryGetValue(units, out var last) ? Copy(last) : DefaultCutOffOptions(units);
ShowCutOffOptions();
@@ -197,7 +199,7 @@ internal sealed class CuttingInspectionPanel : UserControl
existingWorkLabel.Font = new Font(Font, FontStyle.Bold);
existingWorkBanner.Controls.AddRange([existingWorkLabel, clearExistingButton]);
existingWorkBanner.SetTone(Tone.Bad);
header.Controls.AddRange([modeSelector, existingWorkBanner, settingsRow, keepOrderCheckBox, cutOffRow,
header.Controls.AddRange([modeSelector, scopeSelector, existingWorkBanner, settingsRow, keepOrderCheckBox, cutOffRow,
validationScopeLabel, buttons, acceptWarningsCheckBox, navigation, previewLabel, detailLabel]);
statusBanner.Controls.Add(statusLabel);
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3 };
@@ -415,10 +417,11 @@ internal sealed class CuttingInspectionPanel : UserControl
private void ConfigureMode()
{
var planning = Mode == CuttingInspectionMode.Plan;
configuringMode = true;
modeSelector.SelectedIndex = (int)Mode;
ShowScope(planning);
configuringMode = false;
var planning = Mode == CuttingInspectionMode.Plan;
settingsButton.Visible = settingsLabel.Visible = keepOrderCheckBox.Visible = applyButton.Visible = planning;
autoCutOffsCheckBox.Visible = cutOffSettingsButton.Visible = planning;
RefreshExistingWork();
@@ -429,6 +432,89 @@ internal sealed class CuttingInspectionPanel : UserControl
previewLabel.Text = "No current preview.";
}
/// <summary>True when this panel plans every plate with parts rather than only the plate on screen.</summary>
internal bool AllPlates => allPlates;
/// <summary>Saves the scope chosen in the panel for the next toolbar open; tests replace it.</summary>
internal Action<bool> RememberScope { get; set; } = all =>
{
Properties.Settings.Default.CuttingPlanAllPlates = all;
Properties.Settings.Default.Save();
};
/// <summary>Scopes the panel to the plate on screen or to every plate with parts (that plate when none has).</summary>
private void SetPlates(bool all)
{
allPlates = all;
var nestPlates = editor.Nest.Plates.ToList();
var current = nestPlates.Contains(view.Plate) ? view.Plate : plate;
plates = ScopePlates(nestPlates, current, all);
plateNumbers = plates.Select(p => nestPlates.IndexOf(p) + 1).ToArray();
selectedPlate = System.Math.Max(0, Array.IndexOf(plates, current));
}
private static Plate[] ScopePlates(System.Collections.Generic.List<Plate> nestPlates, Plate current, bool all)
{
var scoped = all ? nestPlates.Where(p => p.Parts.Count > 0).ToArray() : [current];
return scoped.Length == 0 ? [current] : scoped;
}
// Shown only when the two scopes would plan different plates.
private void ShowScope(bool planning)
{
var nestPlates = editor.Nest.Plates.ToList();
var current = nestPlates.Contains(view.Plate) ? view.Plate : plate;
var everyPlate = ScopePlates(nestPlates, current, true);
scopeSelector.Items.Clear();
scopeSelector.Items.AddRange(["Current plate only", $"All plates ({everyPlate.Length})"]);
scopeSelector.SelectedIndex = allPlates ? 1 : 0;
scopeSelector.Visible = planning && (allPlates || everyPlate.Length > 1 || !ReferenceEquals(everyPlate[0], current));
}
/// <summary>Replans for the chosen scope; a running plan is cancelled first so the plates never change under it.</summary>
internal void ChangeScope(bool all)
{
if (closed || closeRequested || pendingMode != null || Mode != CuttingInspectionMode.Plan
|| all == (pendingScope ?? allPlates))
return;
RememberScope?.Invoke(all);
if (IsPlanning)
{
pendingScope = all;
if (inspection.CloseRequested)
return; // Already finishing; the scope is applied when that lands.
pendingReplan = true;
ClearResult();
SetPlanning(true);
SetStatus(Tone.Neutral, "Restarting…");
inspection.RequestClose();
return;
}
if (ApplyScope(all))
StartPlanning();
}
// Takes the unapplied cut-offs off the old scope's plates before rescoping; false when they couldn't come off.
private bool ApplyScope(bool all)
{
ClearResult();
try
{
RevertStagedCutOffs();
}
catch (Exception ex)
{
SetStatus(Tone.Bad, "Couldn't remove the unapplied cut-offs.", ex.Message);
configuringMode = true;
ShowScope(true); // The plates stay in the old scope, so the selector does too.
configuringMode = false;
return false;
}
SetPlates(all);
ConfigureMode();
return true;
}
internal void SwitchMode(CuttingInspectionMode mode)
{
if (closed || closeRequested || mode == Mode && pendingMode == null) return;
@@ -704,6 +790,7 @@ internal sealed class CuttingInspectionPanel : UserControl
if (closeRequested || pendingMode != null)
{
pendingReplan = pendingClear = pendingCutOffSettings = false;
pendingScope = null;
FinishTransition();
return;
}
@@ -717,6 +804,17 @@ internal sealed class CuttingInspectionPanel : UserControl
pendingClear = false; // The cancelled run no longer reads the plates.
if (!ClearAppliedWork())
{
pendingScope = null;
SetPlanning(false);
return;
}
}
if (pendingScope is { } scoped)
{
pendingScope = null; // The clear above, if any, covered the scope it was confirmed for.
if (!ApplyScope(scoped))
{
pendingCutOffSettings = false;
SetPlanning(false);
return;
}
@@ -875,7 +973,7 @@ internal sealed class CuttingInspectionPanel : UserControl
// unless closing or switching; validation still locks them while it runs.
var replannable = !running || Mode == CuttingInspectionMode.Plan && !closeRequested && pendingMode == null;
settingsButton.Enabled = keepOrderCheckBox.Enabled = planButton.Enabled = replannable;
cutOffSettingsButton.Enabled = clearExistingButton.Enabled = replannable;
cutOffSettingsButton.Enabled = clearExistingButton.Enabled = scopeSelector.Enabled = replannable;
previousButton.Enabled = !running && selectedPlate > 0;
nextButton.Enabled = !running && selectedPlate < plates.Length - 1;
acceptWarningsCheckBox.Enabled = !running;
+4
View File
@@ -111,6 +111,10 @@ namespace OpenNest.Forms
private void NestPlanCutting_Click(object sender, EventArgs e) => ShowCuttingPlan(allPlates: true);
// The toolbar opens with the scope last chosen in the panel; the menus keep their own.
private void ToolbarPlanCutting_Click(object sender, EventArgs e) =>
ShowCuttingPlan(Properties.Settings.Default.CuttingPlanAllPlates);
// Native tests capture this notice without opening a modal dialog in session zero.
internal System.Action<string, string> CuttingPlanNotice { get; set; }
+1 -1
View File
@@ -116,7 +116,7 @@ namespace OpenNest.Forms
statusBar = new MainStatusBar(statusLabel1, locationStatusLabel, selectionStatusLabel,
machineStatusLabel, materialStatusLabel, plateUtilStatusLabel, ledgerStatusLabel);
toolStrip1.Items.Add(CreateActionButton("btnCutPlanner", "Plan Cutting",
Resources.cut_planner, PlatePlanCutting_Click));
Resources.cut_planner, ToolbarPlanCutting_Click));
toolStrip1.Items.Add(CreateActionButton("btnSimulateCutting", "Simulate Cutting",
Resources.cut_simulate, SimulateCutting_Click));
toolStrip1.Items.Add(CreateActionButton("btnSequenceByPath", "Sequence by Path",
+13
View File
@@ -372,6 +372,19 @@ namespace OpenNest.Properties {
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("True")]
// The Plan Cutting toolbar button plans every plate; false plans only the plate on screen.
public bool CuttingPlanAllPlates {
get {
return ((bool)(this["CuttingPlanAllPlates"]));
}
set {
this["CuttingPlanAllPlates"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("False")]
+3
View File
@@ -89,6 +89,9 @@
<Setting Name="CuttingPlanEffort" Type="System.Double" Scope="User">
<Value Profile="(Default)">1</Value>
</Setting>
<Setting Name="CuttingPlanAllPlates" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">True</Value>
</Setting>
<Setting Name="CutOffLockHeadHeight" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
+4
View File
@@ -615,6 +615,10 @@ on an `Applied` result, not as a rollback.
`Plate > Plan Cutting...` opens a cutting inspection panel beside the main plate canvas;
`Nest > Plan Cutting (All Plates)...` opens the same panel for every occupied plate.
The toolbar's Plan Cutting button opens it with the scope last chosen in the panel
(`Settings.CuttingPlanAllPlates`, default all plates). When the two scopes differ, the
panel's scope selector switches between **Current plate only** and **All plates**; a
change takes the unapplied scrap cut-offs off first, cancels any running plan and replans.
It captures scope on the editor thread, plans on a worker, and shows only a fresh,
supported detached proposal in that canvas, including proposed part numbers and
rapids. The live `Plate`, part order, programs and quantities remain unchanged