feat(desktop): add automatic cut-offs, existing-work banner and clear button to the cutting planner

This commit is contained in:
aj committed 2026-10-13 20:39:43 -04:00
1 parent 81cccfc610
commit 9f2ece37ed
3 files changed
+303 -4

No files matched your search

@@ -436,6 +436,112 @@ public class CuttingInspectionPanelTests
panel.RequestClose();
});
[Fact]
public void AutomaticCutOffsAreStagedForThePlanAndRemovedOnCancelOrUntick() => RunSta(() =>
{
using var editor = Editor();
editor.Show();
editor.PlateManager.LoadAt(0);
var plate = editor.PlateView.Plate;
var original = plate.Parts.ToArray();
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.Empty(plate.CutOffs);
Field<CheckBox>(panel, "autoCutOffsCheckBox").Checked = true;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.NotNull(panel.StagedCutOffs);
Assert.NotEmpty(plate.CutOffs);
Assert.Equal(plate.CutOffs.Count, plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
Assert.Equal(plate.CutOffs.Count, panel.Proposal.Plates[0].Result.ProposedOrder.Count(p => p.IsCutOff));
Assert.False(Field<FlowLayoutPanel>(panel, "existingWorkBanner").Visible); // Staged lines are not "existing".
Field<CheckBox>(panel, "autoCutOffsCheckBox").Checked = false;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.Null(panel.StagedCutOffs);
Assert.Empty(plate.CutOffs);
Assert.Equal(original, plate.Parts);
Field<CheckBox>(panel, "autoCutOffsCheckBox").Checked = true;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.NotEmpty(plate.CutOffs);
panel.RequestClose();
Assert.Null(editor.CuttingInspection);
Assert.Empty(plate.CutOffs);
Assert.Equal(original, plate.Parts);
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
});
[Fact]
public void ApplyKeepsTheStagedAutomaticCutOffsInThePlannedOrder() => RunSta(() =>
{
using var editor = Editor();
editor.Show();
editor.PlateManager.LoadAt(0);
var plate = editor.PlateView.Plate;
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Field<CheckBox>(panel, "autoCutOffsCheckBox").Checked = true;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
var staged = panel.StagedCutOffs!.Definitions.ToArray();
var planned = panel.Proposal.Plates[0].Result.ProposedOrder.Select(p => p.SourcePart).ToArray();
Field<Button>(panel, "applyButton").PerformClick();
Assert.Equal(OpenNest.CNC.CuttingPlanning.CuttingCommitStatus.Applied, panel.CommitResult!.Status);
Assert.Null(editor.CuttingInspection);
Assert.Equal(staged, plate.CutOffs);
Assert.Equal(planned, plate.Parts);
Assert.All(plate.Parts.Where(p => !p.BaseDrawing.IsCutOff), part => Assert.True(part.HasManualLeadIns));
});
[Fact]
public void ExistingLeadInsShowTheRedBannerAndClearRecalculatesFromScratch() => RunSta(() =>
{
using var editor = Editor();
editor.Show();
editor.PlateManager.LoadAt(0);
var plate = editor.PlateView.Plate;
editor.OpenCuttingInspection();
var first = editor.CuttingInspection!;
PumpUntil(() => first.Proposal != null && !first.IsPlanning);
Assert.False(Field<FlowLayoutPanel>(first, "existingWorkBanner").Visible);
Field<Button>(first, "applyButton").PerformClick();
Assert.All(plate.Parts, part => Assert.True(part.HasManualLeadIns));
plate.CutOffs.Add(new CutOff(new Vector(60, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(editor.PlateView.CutOffSettings);
var real = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray();
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
var banner = Field<FlowLayoutPanel>(panel, "existingWorkBanner");
Assert.True(banner.Visible);
Assert.Contains("ALREADY APPLIED: lead-ins on 2 parts and 1 cut-off on plate 1.",
Field<Label>(panel, "existingWorkLabel").Text);
string? asked = null;
panel.ConfirmClearExisting = message => { asked = message; return false; };
Field<Button>(panel, "clearExistingButton").PerformClick();
Assert.Contains("cannot be undone", asked);
Assert.Single(plate.CutOffs);
Assert.All(real, part => Assert.True(part.HasManualLeadIns));
panel.ConfirmClearExisting = _ => true;
Field<Button>(panel, "clearExistingButton").PerformClick();
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.Empty(plate.CutOffs);
Assert.Equal(real, plate.Parts);
Assert.All(real, part => Assert.False(part.HasManualLeadIns));
Assert.False(banner.Visible);
Assert.True(panel.Proposal.CanApply);
panel.RequestClose();
});
private sealed class MenuHost : MainForm
{
protected override void OnLoad(EventArgs e) { }
+184 -4
View File
@@ -58,6 +58,34 @@ internal sealed class CuttingInspectionPanel : UserControl
private readonly Button previousButton = new() { Text = "Previous", AutoSize = true };
private readonly Button nextButton = new() { Text = "Next", AutoSize = true };
private readonly CheckBox keepOrderCheckBox = new() { Text = "Keep current part order", AutoSize = true };
private readonly CheckBox autoCutOffsCheckBox = new() { AutoSize = true };
private readonly FlowLayoutPanel existingWorkBanner = new()
{
AutoSize = true,
FlowDirection = FlowDirection.TopDown,
WrapContents = false,
BackColor = Color.FromArgb(192, 0, 0),
Padding = new Padding(6),
Margin = new Padding(3, 3, 3, 6),
Visible = false
};
private readonly Label existingWorkLabel = new()
{
AutoSize = true,
MaximumSize = new Size(428, 0),
ForeColor = Color.White,
BackColor = Color.Transparent,
AccessibleName = "Existing lead-ins and cut-offs warning"
};
private readonly Button clearExistingButton = new()
{
Text = "Clear Lead-ins && Cut-offs…",
AutoSize = true,
BackColor = SystemColors.Control,
UseVisualStyleBackColor = true
};
private readonly AutomaticCutOffOptions cutOffOptions;
private AutomaticCutOffStaging stagedCutOffs;
private readonly CheckBox acceptWarningsCheckBox = new() { Text = "Accept UNVERIFIED plan warnings (post consent happens separately)", AutoSize = true, Visible = false };
private readonly Label settingsLabel = new() { AutoSize = true, MaximumSize = new Size(440, 0) };
private readonly Label previewLabel = new() { AutoSize = true, MaximumSize = new Size(440, 0) };
@@ -111,7 +139,15 @@ internal sealed class CuttingInspectionPanel : UserControl
{
if (!configuringMode) SwitchMode((CuttingInspectionMode)modeSelector.SelectedIndex);
};
header.Controls.AddRange([modeSelector, settingsLabel, keepOrderCheckBox, buttons, acceptWarningsCheckBox, navigation, previewLabel, validationScopeLabel]);
var units = editor.Nest.Units;
var unit = UnitsHelper.GetShortString(units);
cutOffOptions = DefaultCutOffOptions(units);
autoCutOffsCheckBox.Text = $"Add automatic scrap cut-offs ({cutOffOptions.Spacing:0.###} {unit} strips, "
+ $"keep tails of {cutOffOptions.MinimumTailLength:0.###} {unit} or more)";
existingWorkLabel.Font = new Font(Font, FontStyle.Bold);
existingWorkBanner.Controls.AddRange([existingWorkLabel, clearExistingButton]);
header.Controls.AddRange([modeSelector, existingWorkBanner, settingsLabel, keepOrderCheckBox, autoCutOffsCheckBox, buttons,
acceptWarningsCheckBox, navigation, previewLabel, validationScopeLabel]);
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3 };
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 30));
@@ -133,13 +169,16 @@ internal sealed class CuttingInspectionPanel : UserControl
cancelButton.Click += (_, _) => RequestClose();
settingsButton.Click += (_, _) => EditSettings();
keepOrderCheckBox.CheckedChanged += (_, _) => StartPlanning();
autoCutOffsCheckBox.CheckedChanged += (_, _) => StartPlanning();
clearExistingButton.Click += (_, _) => ClearExistingWork();
acceptWarningsCheckBox.CheckedChanged += (_, _) => UpdateApply();
applyButton.Click += (_, _) => Apply();
previousButton.Click += (_, _) => ShowPlate(selectedPlate - 1);
nextButton.Click += (_, _) => ShowPlate(selectedPlate + 1);
warningGrid.SelectionChanged += (_, _) => NavigateFinding();
ShowSettings();
resultLabel.Text = "Read-only proposal inspection. Nothing live changes before Apply.";
resultLabel.Text = "Read-only proposal inspection. Nothing live changes before Apply, except automatic cut-offs "
+ "you choose to add; those are removed again unless you Apply.";
}
internal bool IsPlanning => inspection.IsRunning || validationCancellation != null;
@@ -150,6 +189,24 @@ internal sealed class CuttingInspectionPanel : UserControl
internal Action<Exception> OnPostApplyError { get; set; }
internal CuttingPlanProposal Proposal => inspection.Proposal;
/// <summary>Asks before discarding applied lead-ins and cut-offs; null shows a Yes/No box (tests replace it).</summary>
internal Func<string, bool> ConfirmClearExisting { get; set; }
/// <summary>Automatic cut-offs added to the live plates for the current plan, until Apply keeps them.</summary>
internal AutomaticCutOffStaging StagedCutOffs => stagedCutOffs;
/// <summary>The automatic cut-off dialog's defaults: 35 in strips, half-strip minimum, 12 in tail.</summary>
internal static AutomaticCutOffOptions DefaultCutOffOptions(Units units)
{
var spacing = units == Units.Millimeters ? 889 : 35;
return new AutomaticCutOffOptions
{
Spacing = spacing,
MinimumStripLength = spacing / 2.0,
MinimumTailLength = units == Units.Millimeters ? 304.8 : 12,
};
}
internal void Open()
{
scope = new ValidationInspectionScope(editor, this, editor.SidePanelHeader, RequestClose);
@@ -173,6 +230,8 @@ internal sealed class CuttingInspectionPanel : UserControl
configuringMode = false;
var planning = Mode == CuttingInspectionMode.Plan;
settingsButton.Visible = settingsLabel.Visible = keepOrderCheckBox.Visible = applyButton.Visible = planning;
autoCutOffsCheckBox.Visible = planning;
RefreshExistingWork();
previousButton.Visible = nextButton.Visible = planning;
validationScopeLabel.Visible = !planning;
planButton.Text = planning ? "Replan" : "Recheck all plates";
@@ -217,6 +276,12 @@ internal sealed class CuttingInspectionPanel : UserControl
{
if (closed || closeRequested || IsPlanning) return;
ClearResult();
try { RevertStagedCutOffs(); } // Validate what is really on the plates, not an unapplied plan.
catch (Exception ex)
{
resultLabel.Text = $"Unable to remove the unapplied automatic cut-offs: {ex.Message}";
return;
}
ConfigureMode();
SetPlanning(true);
resultLabel.Text = "Checking all live plates: overlaps, missing lead-ins and rapid crossings…";
@@ -323,8 +388,16 @@ internal sealed class CuttingInspectionPanel : UserControl
if (Mode != CuttingInspectionMode.Plan || closed || IsPlanning || closeRequested || inspection.CloseRequested)
return;
ClearResult();
if (!SyncStagedCutOffs())
{
RefreshExistingWork();
return;
}
RefreshExistingWork();
SetPlanning(true);
resultLabel.Text = "Planning… Read-only; the live plate is unchanged.";
resultLabel.Text = stagedCutOffs != null
? "Planning… Automatic cut-offs were added for this plan; they are removed again unless you Apply."
: "Planning… Read-only; the live plate is unchanged.";
try
{
// Capture on the owner thread; no worker recapture of the live plate or settings.
@@ -427,7 +500,8 @@ internal sealed class CuttingInspectionPanel : UserControl
private void SetPlanning(bool running)
{
settingsButton.Enabled = keepOrderCheckBox.Enabled = planButton.Enabled = !running;
settingsButton.Enabled = keepOrderCheckBox.Enabled = autoCutOffsCheckBox.Enabled = planButton.Enabled = !running;
clearExistingButton.Enabled = !running;
previousButton.Enabled = !running && selectedPlate > 0;
nextButton.Enabled = !running && selectedPlate < plates.Length - 1;
acceptWarningsCheckBox.Enabled = !running;
@@ -541,6 +615,9 @@ internal sealed class CuttingInspectionPanel : UserControl
CommitResult = Proposal.Apply(acceptWarningsCheckBox.Checked);
if (CommitResult.Status == CuttingCommitStatus.Applied)
{
// The plan was made with these cut-offs in place; applying it keeps them.
stagedCutOffs?.Keep();
stagedCutOffs = null;
Exception postApplyError = null;
try
{
@@ -593,6 +670,101 @@ internal sealed class CuttingInspectionPanel : UserControl
return Regex.Replace(name, "(?<=[a-z])(?=[A-Z])", " ").ToLowerInvariant();
}
/// <summary>
/// Adds or removes this panel's automatic cut-offs to match the check box, on the owner
/// thread before capture, so the plan sequences them. False when planning cannot start.
/// </summary>
private bool SyncStagedCutOffs()
{
try
{
if (!autoCutOffsCheckBox.Checked)
{
RevertStagedCutOffs();
return true;
}
if (stagedCutOffs != null)
return true;
var staging = AutomaticCutOffBatch.Stage(plates, cutOffOptions, view.CutOffSettings);
if (staging.IsBlocked)
{
var reasons = staging.Plans.SelectMany((plan, index) => plan.Diagnostics.Where(d => d.IsBlocking)
.Select(d => $"Plate {plateNumbers[index]}: {d.Message}"));
resultLabel.Text = "Automatic cut-offs were not added, so nothing was planned:" + Environment.NewLine
+ string.Join(Environment.NewLine, reasons) + Environment.NewLine
+ "Untick \"Add automatic scrap cut-offs\" to plan without them, or clear or fix the existing cut-offs.";
return false;
}
stagedCutOffs = staging.CanRevert ? staging : null;
view.Invalidate();
return true;
}
catch (Exception ex)
{
resultLabel.Text = $"Automatic cut-offs could not be added: {ex.Message} Nothing was planned.";
return false;
}
}
private void RevertStagedCutOffs()
{
var staged = stagedCutOffs;
stagedCutOffs = null;
if (staged == null)
return;
staged.Revert();
if (!view.IsDisposed)
view.Invalidate();
}
private void RefreshExistingWork()
{
var summary = AppliedCuttingWork.Summarize(plates, plateNumbers, stagedCutOffs?.Definitions);
existingWorkLabel.Text = summary.Describe();
existingWorkBanner.Visible = Mode == CuttingInspectionMode.Plan && summary.HasExistingWork;
}
private void ClearExistingWork()
{
if (closed || closeRequested || IsPlanning || Mode != CuttingInspectionMode.Plan)
return;
var summary = AppliedCuttingWork.Summarize(plates, plateNumbers, stagedCutOffs?.Definitions);
if (!summary.HasExistingWork)
return;
var message = $"Remove the applied lead-ins ({summary.LeadInParts} parts, {summary.LockedLeadInParts} locked) and "
+ $"{summary.CutOffs} cut-offs from plate(s) {string.Join(", ", summary.PlateNumbers)}?"
+ Environment.NewLine + Environment.NewLine
+ "Locked and hand-placed lead-ins are removed too, and this cannot be undone. The plan is then recalculated.";
var confirmed = ConfirmClearExisting?.Invoke(message)
?? MessageBox.Show(editor, message, "Clear Lead-ins and Cut-offs", MessageBoxButtons.YesNo,
MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) == DialogResult.Yes;
if (!confirmed || closed || closeRequested || IsPlanning)
return;
ClearResult();
try
{
RevertStagedCutOffs();
AppliedCuttingWork.Clear(plates, plateNumbers);
}
catch (Exception ex)
{
RefreshExistingWork();
resultLabel.Text = $"Clearing stopped part-way: {ex.Message} Review the plates before planning or posting.";
return;
}
finally
{
foreach (var part in view.Parts)
{
part.IsDirty = true;
part.Update();
}
view.Invalidate();
editor.UpdatePlateList();
}
StartPlanning();
}
internal void RequestClose()
{
if (closed)
@@ -620,6 +792,14 @@ internal sealed class CuttingInspectionPanel : UserControl
var ownedScope = scope;
scope = null;
try
{
RevertStagedCutOffs(); // Closing without Apply leaves the plates as they were.
}
catch (Exception ex)
{
restoreError = ex;
}
try
{
RestoreLiveView();
if (!view.IsDisposed && ReferenceEquals(view.Plate, plate))
+13
View File
@@ -411,6 +411,19 @@ panel is wired in the editor. Non-editor callers still use the shared analyzer.
whole-part order; either change replans. The settings are confirmed parameters: every
unlocked part's lead-ins are regenerated. Locked parts keep their exact programs;
the strict route checks them, while any best-effort warnings require explicit review below.
- When a plate in scope already carries applied lead-ins or cut-offs, a red banner
above the settings says how many (and how many lead-ins are locked), and that the
plan keeps locked lead-ins and existing cut-offs unchanged. `Clear Lead-ins &
Cut-offs...` asks first, then removes every applied lead-in (locked and hand-placed
ones too), every cut-off and the plates' recorded cutting settings, keeping part
poses, order and quantities, and replans from scratch. There is no undo.
- `Add automatic scrap cut-offs` runs the automatic cut-off planner with its dialog
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,
while unticking the box, Cancel/Close or switching to Validate removes them and
restores the earlier cut-offs and sequence. A blocked cut-off plan adds nothing and
plans nothing; the panel names the plate and reason.
- 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