mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-16 09:51:36 -04:00
feat(desktop): add automatic cut-offs, existing-work banner and clear button to the cutting planner
This commit is contained in:
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) { }
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user