feat(desktop): add minimum strip and cut-length summary to automatic cutoffs

This commit is contained in:
aj committed 2026-10-13 19:34:02 -04:00
1 parent 5857e7fc69
commit f63fee7392
4 files changed
+152 -17

No files matched your search

@@ -20,12 +20,63 @@ public class AutomaticCutOffFormTests
using var form = new AutomaticCutOffForm(view, units);
Assert.Equal(spacing, Control<TextBox>(form, "spacingBox").Text);
Assert.Contains(suffix, Control<Label>(form, "spacingLabel").Text);
Assert.Contains(suffix, Control<Label>(form, "minimumStripLabel").Text);
Assert.Equal(double.Parse(spacing) / 2, double.Parse(Control<TextBox>(form, "minimumStripBox").Text));
Assert.Contains(suffix, Control<Label>(form, "minimumTailLabel").Text);
Assert.Equal(minimumTail, double.Parse(Control<TextBox>(form, "minimumTailBox").Text));
Assert.Contains($"36 {suffix} width", Control<Label>(form, "sheetLabel").Text);
});
}
[Fact]
public void MinimumStripFollowsSpacingUntilEditedAndPreviewUsesIt()
{
RunSta(() =>
{
using var view = CreateView();
var edge = new CNC.Program();
edge.Codes.Add(new RapidMove(0, 0));
edge.Codes.Add(new LinearMove(10, 0));
edge.Codes.Add(new LinearMove(10, 6));
edge.Codes.Add(new LinearMove(0, 6));
edge.Codes.Add(new LinearMove(0, 0));
view.Plate.Parts.Add(new Part(new Drawing("edge", edge), new Vector(24.75, 29)));
using var form = new AutomaticCutOffForm(view, Units.Inches);
var spacing = Control<TextBox>(form, "spacingBox");
var minimum = Control<TextBox>(form, "minimumStripBox");
spacing.Text = "30";
Assert.Equal(15, double.Parse(minimum.Text));
minimum.Text = "20";
spacing.Text = "40";
Assert.Equal("20", minimum.Text);
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
var expected = AutomaticCutOffPlanner.Create(view.Plate,
new AutomaticCutOffOptions { Spacing = 40, MinimumStripLength = 20, MinimumTailLength = 12 },
view.CutOffSettings);
Assert.NotEqual(40, expected.Definitions.First(c => c.Axis == CutOffAxis.Vertical).Position.X);
Assert.Equal(expected.Definitions.Select(c => c.Position.X),
PreviewParts(view).Select(p => ((RapidMove)p.BasePart.Program.Codes[0]).EndPoint.X));
Assert.Contains("Strip cuts:", Control<Label>(form, "stripCutsLabel").Text);
Assert.Contains("less than the fixed grid", Control<Label>(form, "stripCutsLabel").Text);
});
}
[Fact]
public void InvalidMinimumStripRefusesApplyWithoutChangingCutoffs()
{
RunSta(() =>
{
using var view = CreateView();
using var form = new AutomaticCutOffForm(view, Units.Inches);
Control<TextBox>(form, "minimumStripBox").Text = "40";
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.NotEqual(DialogResult.OK, form.DialogResult);
Assert.Empty(view.Plate.CutOffs);
Assert.False(Control<Button>(form, "applyButton").Enabled);
Assert.Contains("Minimum strip", Control<TextBox>(form, "diagnosticsBox").Text);
});
}
[Fact]
public void PreviewIsDetachedAndDisposeClearsIt()
{
@@ -107,7 +158,7 @@ public class AutomaticCutOffFormTests
view.Plate.Parts[0].Offset(5, 0);
view.CutOffSettings.PartClearance = 2;
var expected = AutomaticCutOffPlanner.Create(view.Plate,
new AutomaticCutOffOptions { Spacing = 35 }, view.CutOffSettings);
new AutomaticCutOffOptions { Spacing = 35, MinimumStripLength = 17.5 }, view.CutOffSettings);
var removals = 0;
view.Plate.PartRemoved += (_, e) =>
{
@@ -134,6 +185,7 @@ public class AutomaticCutOffFormTests
{
using var view = CreateView();
using var form = new AutomaticCutOffForm(view, Units.Inches);
Control<TextBox>(form, "minimumStripBox").Text = "35";
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
var limited = new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 };
view.Plate.CutOffs.Add(limited);
@@ -255,6 +307,7 @@ public class AutomaticCutOffFormTests
nest.Plates.Add(view.Plate);
nest.Plates.Add(otherView.Plate);
using var form = new AutomaticCutOffForm(view, nest);
Control<TextBox>(form, "minimumStripBox").Text = "35";
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
otherView.Plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 });
+40 -7
View File
@@ -25,6 +25,8 @@ partial class AutomaticCutOffForm
spacingPanel = new TableLayoutPanel();
spacingLabel = new Label();
spacingBox = new TextBox();
minimumStripLabel = new Label();
minimumStripBox = new TextBox();
minimumTailLabel = new Label();
minimumTailBox = new TextBox();
statusLabel = new Label();
@@ -37,6 +39,7 @@ partial class AutomaticCutOffForm
usedLabel = new Label();
separatorLabel = new Label();
tailLabel = new Label();
stripCutsLabel = new Label();
warningLabel = new Label();
toolTip = new ToolTip(components);
plateGrid = new ListView();
@@ -103,12 +106,15 @@ partial class AutomaticCutOffForm
spacingPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
spacingPanel.Controls.Add(spacingLabel, 0, 0);
spacingPanel.Controls.Add(spacingBox, 1, 0);
spacingPanel.Controls.Add(minimumTailLabel, 0, 1);
spacingPanel.Controls.Add(minimumTailBox, 1, 1);
spacingPanel.Controls.Add(minimumStripLabel, 0, 1);
spacingPanel.Controls.Add(minimumStripBox, 1, 1);
spacingPanel.Controls.Add(minimumTailLabel, 0, 2);
spacingPanel.Controls.Add(minimumTailBox, 1, 2);
spacingPanel.Dock = DockStyle.Fill;
spacingPanel.Margin = new Padding(0, 0, 0, 6);
spacingPanel.Name = "spacingPanel";
spacingPanel.RowCount = 2;
spacingPanel.RowCount = 3;
spacingPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
spacingPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
spacingPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
spacingPanel.TabIndex = 1;
@@ -126,21 +132,36 @@ partial class AutomaticCutOffForm
spacingBox.Dock = DockStyle.Fill;
spacingBox.Name = "spacingBox";
spacingBox.TabIndex = 1;
spacingBox.TextChanged += SettingsBox_TextChanged;
spacingBox.TextChanged += SpacingBox_TextChanged;
//
// minimumStripLabel
//
minimumStripLabel.Anchor = AnchorStyles.Left;
minimumStripLabel.AutoSize = true;
minimumStripLabel.Name = "minimumStripLabel";
minimumStripLabel.TabIndex = 2;
minimumStripLabel.Text = "Minimum strip:";
//
// minimumStripBox
//
minimumStripBox.Dock = DockStyle.Fill;
minimumStripBox.Name = "minimumStripBox";
minimumStripBox.TabIndex = 3;
minimumStripBox.TextChanged += MinimumStripBox_TextChanged;
//
// minimumTailLabel
//
minimumTailLabel.Anchor = AnchorStyles.Left;
minimumTailLabel.AutoSize = true;
minimumTailLabel.Name = "minimumTailLabel";
minimumTailLabel.TabIndex = 2;
minimumTailLabel.TabIndex = 4;
minimumTailLabel.Text = "Minimum tail to keep:";
//
// minimumTailBox
//
minimumTailBox.Dock = DockStyle.Fill;
minimumTailBox.Name = "minimumTailBox";
minimumTailBox.TabIndex = 3;
minimumTailBox.TabIndex = 5;
minimumTailBox.TextChanged += SettingsBox_TextChanged;
//
// statusLabel
@@ -169,10 +190,13 @@ partial class AutomaticCutOffForm
metricsPanel.Controls.Add(usedLabel, 1, 1);
metricsPanel.Controls.Add(separatorLabel, 2, 1);
metricsPanel.Controls.Add(tailLabel, 3, 1);
metricsPanel.Controls.Add(stripCutsLabel, 0, 2);
metricsPanel.SetColumnSpan(stripCutsLabel, 4);
metricsPanel.Dock = DockStyle.Fill;
metricsPanel.Margin = new Padding(0, 0, 0, 6);
metricsPanel.Name = "metricsPanel";
metricsPanel.RowCount = 2;
metricsPanel.RowCount = 3;
metricsPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
metricsPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
metricsPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
metricsPanel.TabIndex = 3;
@@ -231,6 +255,12 @@ partial class AutomaticCutOffForm
tailLabel.Margin = new Padding(3, 0, 3, 0);
tailLabel.Name = "tailLabel";
tailLabel.Text = "—";
stripCutsLabel.AutoSize = true;
stripCutsLabel.Dock = DockStyle.Fill;
stripCutsLabel.Font = valueFont;
stripCutsLabel.Margin = new Padding(3, 7, 3, 0);
stripCutsLabel.Name = "stripCutsLabel";
stripCutsLabel.Text = "—";
//
// warningLabel
//
@@ -375,6 +405,8 @@ partial class AutomaticCutOffForm
private TableLayoutPanel spacingPanel;
private Label spacingLabel;
private TextBox spacingBox;
private Label minimumStripLabel;
private TextBox minimumStripBox;
private Label minimumTailLabel;
private TextBox minimumTailBox;
private Label statusLabel;
@@ -387,6 +419,7 @@ partial class AutomaticCutOffForm
private Label usedLabel;
private Label separatorLabel;
private Label tailLabel;
private Label stripCutsLabel;
private Label warningLabel;
private ToolTip toolTip;
private ListView plateGrid;
+43 -1
View File
@@ -20,6 +20,8 @@ public partial class AutomaticCutOffForm : Form
private readonly Nest nest;
private readonly Func<bool> isOperationBusy;
private readonly string unit;
private bool minimumStripEdited;
private bool settingMinimumStrip;
public AutomaticCutOffForm(PlateView plateView, Units units, Func<bool> isOperationBusy = null)
: this(plateView, units, null, isOperationBusy) { }
@@ -37,6 +39,7 @@ public partial class AutomaticCutOffForm : Form
InitializeComponent();
spacingLabel.Text = $"Nominal spacing along sheet length ({unit}):";
spacingBox.Text = units == Units.Millimeters ? "889" : "35";
minimumStripLabel.Text = $"Minimum strip ({unit}):";
minimumTailLabel.Text = $"Minimum tail to keep ({unit}):";
minimumTailBox.Text = (units == Units.Millimeters ? 304.8 : 12).ToString(CultureInfo.CurrentCulture);
sheetLabel.Text = $"Sheet: {Measure(plate.Size.Length)} length × {Measure(plate.Size.Width)} width";
@@ -90,10 +93,17 @@ public partial class AutomaticCutOffForm : Form
if (!double.TryParse(spacingBox.Text, NumberStyles.Float, CultureInfo.CurrentCulture, out var spacing))
throw new ArgumentException($"Enter a numeric spacing in {unit}.");
if (!double.TryParse(minimumStripBox.Text, NumberStyles.Float, CultureInfo.CurrentCulture, out var minimumStrip))
throw new ArgumentException($"Enter a numeric minimum strip in {unit}.");
if (!double.TryParse(minimumTailBox.Text, NumberStyles.Float, CultureInfo.CurrentCulture, out var minimumTail))
throw new ArgumentException($"Enter a numeric minimum tail length in {unit}.");
var options = new AutomaticCutOffOptions { Spacing = spacing, MinimumTailLength = minimumTail };
var options = new AutomaticCutOffOptions
{
Spacing = spacing,
MinimumStripLength = minimumStrip,
MinimumTailLength = minimumTail,
};
var settings = plateView.CutOffSettings;
var plans = apply
? AutomaticCutOffBatch.Apply(plates, options, settings)
@@ -121,6 +131,11 @@ public partial class AutomaticCutOffForm : Form
usedLabel.Text = Measure(plan.UsedSpan);
separatorLabel.Text = plan.HasSeparatedTail ? SignedPosition(plan.TailSeparatorX.Value) : "none";
tailLabel.Text = plan.HasSeparatedTail ? Measure(plan.TailLength) : "none";
var saving = plan.FixedGridCutLength - plan.StripCutLength;
stripCutsLabel.Text = $"Strip cuts: {Measure(plan.StripCutLength)} ("
+ (saving > 0 ? $"{Measure(saving)} less than the fixed grid" :
saving < 0 ? $"{Measure(-saving)} more than the fixed grid" : "same as the fixed grid")
+ ")";
var blocked = plans.Any(p => p.HasBlockingDiagnostics);
var definitionCount = plans.Sum(p => p.Definitions.Count);
@@ -196,6 +211,7 @@ public partial class AutomaticCutOffForm : Form
usedLabel.Text = "—";
separatorLabel.Text = "—";
tailLabel.Text = "—";
stripCutsLabel.Text = "—";
plateGrid.Items.Clear();
}
@@ -208,6 +224,32 @@ public partial class AutomaticCutOffForm : Form
diagnosticsBox.Text = error.Message;
}
private void SpacingBox_TextChanged(object sender, EventArgs e)
{
if (!minimumStripEdited &&
double.TryParse(spacingBox.Text, NumberStyles.Float, CultureInfo.CurrentCulture, out var spacing) &&
double.IsFinite(spacing))
{
settingMinimumStrip = true;
try
{
minimumStripBox.Text = (spacing / 2).ToString(CultureInfo.CurrentCulture);
}
finally
{
settingMinimumStrip = false;
}
}
SettingsBox_TextChanged(sender, e);
}
private void MinimumStripBox_TextChanged(object sender, EventArgs e)
{
if (!settingMinimumStrip)
minimumStripEdited = true;
SettingsBox_TextChanged(sender, e);
}
private void SettingsBox_TextChanged(object sender, EventArgs e)
{
ClearPreview();
+15 -8
View File
@@ -4,12 +4,13 @@ Finish nesting, then choose **Nest > Automatic Scrap Cutoffs (All Plates)** to p
## Preview and apply
1. Finish or cancel any active nesting/fill operation first, and wait for it to finish stopping; closing its progress window alone does not finish the operation. Enter strip spacing along the sheet's length. The initial value is **35 in**, or **889 mm** for a metric nest. This is the distance between nominal cut lines, not the length of a cut.
2. Set **Minimum tail to keep**, initially **12 in** or **304.8 mm**. This measures the reusable tail's length along X, after separator clearance, not its area. A shorter tail does not get a new final separator; a tail at or above the minimum can be retained. Zero allows any positive tail.
3. Opening the modal dialog previews the initial proposal. After changing either setting, choose **Preview** to refresh it. Cuts run across the active sheet's actual width, advancing along X from the origin-side edge in the plate's quadrant. A bold green or red status line states what **Apply** will do; the occupied span, used span, separator X, and retained tail appear as measurements, and the details list reports only issues and notices. The sheet cannot be edited while the dialog is open.
4. Choose **Apply** (or **Apply to All Plates**) to recalculate against the current parts/settings and add ordinary cutoff definitions at the end of each plate's cutting sequence. Blocked or empty proposals add nothing. Existing parts and manual cutoff definitions are not moved or reordered. **Cancel**, Escape, and closing the dialog discard the detached preview.
1. Finish or cancel any active nesting/fill operation first, and wait for it to finish stopping; closing its progress window alone does not finish the operation. Enter the maximum strip spacing along the sheet's length. The initial value is **35 in**, or **889 mm** for a metric nest. This is the maximum distance between new cut lines, not the length of a cut.
2. Set **Minimum strip**, initially half the spacing (**17.5 in** or **444.5 mm**). The first strip from the origin-side sheet edge and each strip between successive new lines stay between this minimum and the spacing; the last strip before the separator or sheet end can be shorter. This field follows spacing changes until you edit it. Setting it equal to spacing restores the fixed grid.
3. Set **Minimum tail to keep**, initially **12 in** or **304.8 mm**. This measures the reusable tail's length along X, after separator clearance, not its area. A shorter tail does not get a new final separator; a tail at or above the minimum can be retained. Zero allows any positive tail.
4. Opening the modal dialog previews the initial proposal. After changing a setting, choose **Preview** to refresh it. Cuts run across the active sheet's actual width, advancing along X from the origin-side edge in the plate's quadrant. A bold green or red status line states what **Apply** will do; the occupied span, used span, separator X, retained tail, and strip cut length versus the fixed grid appear as measurements. The details list reports only issues and notices. The sheet cannot be edited while the dialog is open.
5. Choose **Apply** (or **Apply to All Plates**) to recalculate against the current parts/settings and add ordinary cutoff definitions at the end of each plate's cutting sequence. Blocked or empty proposals add nothing. Existing parts and manual cutoff definitions are not moved or reordered. **Cancel**, Escape, and closing the dialog discard the detached preview.
For the Nest command, the on-sheet overlay and upper measurements show the active plate; a per-plate table lists each plate's proposed cut-off count, occupied and used spans, retained tail, and readiness (Ready, Blocked, or No change), while the details list reports only issues and notices, each prefixed with its plate number. Each plate uses its own dimensions, quadrant and part spacing with the dialog's common spacing/minimum-tail settings and current cutoff clearance/direction settings. No plate navigation or per-plate confirmation is required. Empty or unchanged plates are left untouched. A blocking conflict or invalid plate prevents changes to **all** plates, with the plate number reported; resolve it before retrying. If application fails after changes begin, all touched plates' original cutoff programs and sequence are restored. An explicit incomplete-recovery warning requires manual review before saving or cutting.
For the Nest command, the on-sheet overlay and upper measurements show the active plate; a per-plate table lists each plate's proposed cut-off count, occupied and used spans, retained tail, and readiness (Ready, Blocked, or No change), while the details list reports only issues and notices, each prefixed with its plate number. Each plate uses its own dimensions, quadrant and part spacing with the dialog's common spacing/minimum-strip/minimum-tail settings and current cutoff clearance/direction settings. No plate navigation or per-plate confirmation is required. Empty or unchanged plates are left untouched. A blocking conflict or invalid plate prevents changes to **all** plates, with the plate number reported; resolve it before retrying. If application fails after changes begin, all touched plates' original cutoff programs and sequence are restored. An explicit incomplete-recovery warning requires manual review before saving or cutting.
An empty sheet is left untouched. Invalid input or out-of-sheet part geometry prevents automatic planning. Excessively small spacing is rejected rather than allocating an unbounded number of cuts. Small bounded discrepancies between converted material and cached bounds—including amplified roundoff in fitted near-tangent arcs—are accepted without changing the part or its program. The same conservative numerical padding applies to cutoff pruning and exclusions. Their broad-phase envelope uses the farther edge of the cached box and converted perimeter; a cutoff within the narrow uncertainty band at a cached edge gets a full-envelope exclusion instead of relying on a near-tangent contour intersection. Elsewhere the contour is retained for precise trimming. Larger arc-center discrepancies still prevent automatic planning rather than risking a cut through material; repair the geometry before retrying.
@@ -23,13 +24,19 @@ Cutoff trimming measures clearance from the part contour, excluding tagged lead-
If no safe separator fits inside the sheet, there is no retained-tail claim and the nominal cuts may cover the full sheet length. A proposed tail is not reported as separated unless its generated program spans the full sheet width. An active clamp blocks any proposed full-width line whose X coordinate falls in the clamp's whole footprint plus bottom-edge clearance, including a nominal grid line or an equivalent existing line. A tolerance-close existing line is tested at its own X as well as the proposed X, because that existing line is what would be certified. A same-line manual cut with different limits still blocks the whole plan, even on a clamp. Blocked lines are skipped with a clamp diagnostic; a blocked tail also reports `NoSafeTailSeparator` rather than claiming separation. Removed clamps on a sheet are absent from its active zones and do not block cuts. Stored zones are relative to the sheet's lower-left corner; the planner maps them into the plate's quadrant frame (the same frame as part and cut-off coordinates) before comparing, so the rule holds in all four quadrants. Obstructed or minimum-length-filtered lines produce diagnostics; they are not proof of a completed partition.
For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal skeleton lines are at 35 and 70 inches, followed by a separator beyond 80 inches at the required margin. There is no line at 105 inches through the retained tail.
For a 120-by-81-inch sheet whose parts extend through 80 inches, the fixed grid would put skeleton lines at 35 and 70 inches, followed by a separator beyond 80 inches at the required margin. With a 17.5-inch minimum strip, the planner can move those two lines toward the origin to shorten their trimmed cut segments. The separator does not move, and there is no line at 105 inches through the retained tail.
## Shifting strip lines
The preview searches line positions together to minimize the total emitted strip-line cutting length after part clearance and minimum-segment filtering. Equal lengths favor fewer pierces, then less total movement from the fixed grid. The number of new lines in each span stays at the fixed-grid count; it never adds cuts to chase a shorter path. For example, a part edge at 34.75 inches can make a 35-inch line run beside much of the part, while a small shift toward the origin lets that line cross the part and skip its material. The preview reports the measured strip-cut length and the corresponding fixed-grid length; a `ShiftedCut` notice identifies moved lines.
Existing full-width vertical cut-offs inside the used span become fixed strip boundaries; the planner fills only gaps longer than the spacing, never moving or duplicating the manual cut. A partial manual cut is not a boundary and its position is avoided. Active clamps exclude candidate positions including bottom-edge clearance. If a gap cannot place all its lines outside clamps, it falls back to nominal lines and reports any blocked cuts under the existing guard. An unchanged rerun adds nothing. The desktop defaults to shifting; API callers that omit `MinimumStripLength` retain the fixed-grid behavior.
## Limits and operator review
**View > Draw Rapids** follows the complete cutting sequence: an incoming rapid ends at a cutoff's first pierce, gaps between its trimmed segments remain rapids, and the next part's rapid starts at the cutoff's final cutting endpoint—not its last pierce. This display does not add a return move or alter the cutoff program.
- Spacing is nominal. Clearance gaps and suppressed short segments can leave bridges between scrap regions. This command does **not** certify that every connected scrap piece is disconnected or fits a hopper.
- Maximum strip spacing is not a disconnected-scrap guarantee. Clearance gaps and suppressed short segments can leave bridges between scrap regions. This command does **not** certify that every connected scrap piece is disconnected or fits a hopper.
- The active sheet's physical width is used, not a hard-coded 81 inches. Check the actual width against the hopper; a wider sheet is not automatically hopper-compatible.
- Internal-hole scrap is not processed. This command reuses the outside-skeleton behavior of manual cutoffs; it does not force cuts through parts or remove clearance to guarantee separation.
- Review the preview and posted NC using the normal machine-review process before production cutting.
@@ -48,7 +55,7 @@ The all-plates command uses `PlateSequencing.ApplyAll`, which snapshots the targ
## Rerunning, editing, and saving
The command is one-shot; spacing and minimum-tail inputs start at their defaults each time the dialog opens. Generated lines are ordinary cutoffs and can subsequently be dragged, deleted, or sequenced with the existing tools. Moving parts regenerates their trimmed segments but does not reposition the cutoff grid or recalculate the retained tail automatically.
The command is one-shot; spacing, minimum-strip and minimum-tail inputs start at their defaults each time the dialog opens. Generated lines are ordinary cutoffs and can subsequently be dragged, deleted, or sequenced with the existing tools. Moving parts regenerates their trimmed segments but does not reposition the saved cut-off lines or recalculate the retained tail automatically.
Rerunning unchanged suppresses equivalent existing full-span lines. A limited cutoff at a proposed line is a conflict requiring manual review, not permission to replace it or add overlapping NC moves. Existing definitions are never silently deleted or expanded.