feat(desktop): show clamps to pull and their time

Slice 3b task 3 (D9 + display). OpenNest.Core: TimingInfo gains
ClampRemovalMinutes (included in +, -, * so every aggregation stays
consistent); Timing.GetTimingInfo(Plate) adds plate.ClampRemovalMinutes
once per physical sheet, before the quantity multiply so it scales
with everything else cut on that sheet; CalculateTime adds it to the
estimate as a flat number of minutes regardless of the cut parameters'
unit. A plate with nothing removed is unaffected (zero).

OpenNest.Data: ClampSettings and ClampRailEditor carry RemovalMinutes
through the same three boundaries as clamp size - the rail/constructor,
the settings file (missing key falls back to
ClampRail.DefaultRemovalMinutes; present but negative/non-numeric/null
is ignored and reported Partial, same as width/depth), and
ToMachineDefaults - except it is a time, not a length, so
ConvertedTo never scales it between inches and millimeters.

Desktop: ClampsForm gets a 'Minutes to remove and replace one clamp'
field wired to the editor; PlateRenderer.DrawRemovedClamps draws each
clamp Plate.RemovedClamps lists (resolved from the full rail, since a
removed clamp is absent from Plate.ClampZones) as a dashed grey
outline with a small 'remove' marker, distinct from an active clamp's
solid hatch and still display-only - no hit test, selection or
sequencing; PlateView calls it right after the active-zone pass.
MainForm's Auto Nest completion flow lists every committed sheet with
a removal as 'Sheet N: remove clamp-X (+5 min)', using
Plate.RemovedClamps/ClampRemovalMinutes from Slice 3b tasks 1-2.

Tests: 10 new Data (ClampSettingsTests, ClampRailEditorTests) covering
round-trip/default/invalid-value handling and the never-scaled-by-units
rule; 4 new Core (TimingTests) covering the per-sheet add, the quantity
scaling, CalculateTime, and nest-level aggregation across plates.
Desktop changes (dialog field, renderer, Auto Nest summary) are
compile-verified only (-p:EnableWindowsTargeting=true), matching the
project's existing WinForms testing bar - no automated UI test exists
for the sibling DrawClampZones either.

Gates: Release OpenNest.Tests 3898 passed/31 skipped, OpenNest.IO.Tests
105 passed, OpenNest.Engine.Tests 878 passed/2 skipped; desktop and
OpenNest.WinForms.Tests cross-builds clean; scoped dotnet format
--verify-no-changes clean.

This completes Slice 3b (D8/D9) on top of Slice 3's rail-following
plates. Not published; master and other feature/clamp-zones-* branches
untouched. Windows runtime/visual acceptance not claimed - the plan's
click-through checklist (after slices 3-5) still applies.
This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent 83c77269a1
commit 87849e5326
11 files changed
+280 -6

No files matched your search

+8 -1
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using OpenNest.Api;
using OpenNest.CNC;
@@ -43,6 +43,10 @@ namespace OpenNest
pos = endpt;
}
// D9: once per physical sheet, before the quantity multiply below scales it with
// everything else cut on that sheet.
info.ClampRemovalMinutes = plate.ClampRemovalMinutes;
info *= plate.Quantity;
return info;
@@ -91,6 +95,9 @@ namespace OpenNest
time += TimeSpan.FromTicks(info.PierceCount * cutParams.PierceTime.Ticks);
// D9: a flat per-removed-clamp time, already in minutes regardless of cutParams.Units.
time += TimeSpan.FromMinutes(info.ClampRemovalMinutes);
return time;
}
}
+11
View File
@@ -10,6 +10,13 @@ namespace OpenNest
public double CutDistance;
/// <summary>
/// Minutes added by last-resort clamp removal (D9): once per physical sheet, for
/// every clamp <see cref="Plate.RemovedClamps"/> lists. Zero for a plate with no
/// removal, so existing timing is unaffected.
/// </summary>
public double ClampRemovalMinutes;
public static TimingInfo operator +(TimingInfo info1, TimingInfo info2)
{
return new TimingInfo
@@ -18,6 +25,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount + info2.IntersectionCount,
TravelDistance = info1.TravelDistance + info2.TravelDistance,
PierceCount = info1.PierceCount + info2.PierceCount,
ClampRemovalMinutes = info1.ClampRemovalMinutes + info2.ClampRemovalMinutes,
};
}
@@ -29,6 +37,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount - info2.IntersectionCount,
TravelDistance = info1.TravelDistance - info2.TravelDistance,
PierceCount = info1.PierceCount - info2.PierceCount,
ClampRemovalMinutes = info1.ClampRemovalMinutes - info2.ClampRemovalMinutes,
};
}
@@ -40,6 +49,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount * info2.IntersectionCount,
TravelDistance = info1.TravelDistance * info2.TravelDistance,
PierceCount = info1.PierceCount * info2.PierceCount,
ClampRemovalMinutes = info1.ClampRemovalMinutes * info2.ClampRemovalMinutes,
};
}
@@ -51,6 +61,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount * factor,
TravelDistance = info1.TravelDistance * factor,
PierceCount = info1.PierceCount * factor,
ClampRemovalMinutes = info1.ClampRemovalMinutes * factor,
};
}
}
+15 -2
View File
@@ -18,6 +18,7 @@ public sealed class ClampRailEditor
var initial = ClampSettings.FromRail(rail, units, applyToNewNests);
Width = initial.Width;
Depth = initial.Depth;
RemovalMinutes = initial.RemovalMinutes;
_centers.AddRange(initial.Centers);
}
@@ -30,6 +31,10 @@ public sealed class ClampRailEditor
/// <summary>Clamp intrusion into the sheet (Y).</summary>
public double Depth { get; set; }
/// <summary>Minutes to remove one clamp and put it back after cutting (D9); not a
/// length, so it is never converted between units.</summary>
public double RemovalMinutes { get; set; }
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
public bool ApplyToNewNests { get; set; }
@@ -76,6 +81,7 @@ public sealed class ClampRailEditor
var converted = settings.ConvertedTo(Units);
Width = converted.Width;
Depth = converted.Depth;
RemovalMinutes = converted.RemovalMinutes;
ApplyToNewNests = settings.Enabled;
_centers.Clear();
_centers.AddRange(converted.Centers.Distinct().OrderBy(c => c));
@@ -83,10 +89,11 @@ public sealed class ClampRailEditor
/// <summary>
/// The edited rail, or null when no clamp is listed. Throws
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth.
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
/// negative/non-finite removal time.
/// </summary>
public ClampRail? BuildRail() =>
_centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers);
_centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers, RemovalMinutes);
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
public void ApplyTo(Nest nest)
@@ -107,12 +114,18 @@ public sealed class ClampRailEditor
nameof(Width),
"Clamp width and depth must be positive."
);
if (!double.IsFinite(RemovalMinutes) || RemovalMinutes < 0)
throw new ArgumentOutOfRangeException(
nameof(RemovalMinutes),
"Clamp removal time must be finite and zero or more."
);
return new ClampSettings
{
Enabled = ApplyToNewNests && _centers.Count > 0,
Units = Units,
Width = Width,
Depth = Depth,
RemovalMinutes = RemovalMinutes,
Centers = _centers.ToArray(),
};
}
+21 -3
View File
@@ -57,6 +57,12 @@ public sealed class ClampSettings
public double Depth { get; set; } = DefaultDepth;
/// <summary>
/// Minutes to remove one clamp and put it back after cutting (D9), stored and edited in
/// the machine clamp settings alongside the rail's physical size.
/// </summary>
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// <summary>Clamp centers along X, measured from the plate origin.</summary>
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
@@ -82,13 +88,14 @@ public sealed class ClampSettings
Units = units,
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
Centers = rail?.Centers.ToArray() ?? Array.Empty<double>(),
};
/// <summary>
/// The rail a new nest in <paramref name="units"/> receives, or null when clamps are
/// disabled or no center is configured. Values are converted between inches and
/// millimeters as needed.
/// millimeters as needed; <see cref="RemovalMinutes"/> is a time, so it is never scaled.
/// </summary>
public ClampRail? RailFor(Units units)
{
@@ -96,12 +103,13 @@ public sealed class ClampSettings
return null;
var converted = ConvertedTo(units);
return new ClampRail(converted.Width, converted.Depth, converted.Centers);
return new ClampRail(converted.Width, converted.Depth, converted.Centers, converted.RemovalMinutes);
}
/// <summary>
/// A copy with width, depth and centers expressed in <paramref name="units"/>
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> is kept.
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
/// </summary>
public ClampSettings ConvertedTo(Units units)
{
@@ -112,6 +120,7 @@ public sealed class ClampSettings
Units = units,
Width = Width * scale,
Depth = Depth * scale,
RemovalMinutes = RemovalMinutes,
Centers = Centers.Select(c => c * scale).ToArray(),
};
}
@@ -203,6 +212,14 @@ public sealed class ClampSettings
ignored = true;
}
if (values.TryGetValue("removalMinutes", out var removalMinutes))
{
if (TryLength(removalMinutes, out var value) && value >= 0)
settings.RemovalMinutes = value;
else
ignored = true;
}
if (values.TryGetValue("centers", out var centers))
{
if (centers.ValueKind == JsonValueKind.Array)
@@ -240,6 +257,7 @@ public sealed class ClampSettings
units = Units.ToString().ToLowerInvariant(),
width = Width,
depth = Depth,
removalMinutes = RemovalMinutes,
centers = Centers,
};
var json = JsonSerializer.Serialize(dto, JsonOptions);
@@ -168,4 +168,47 @@ public class ClampRailEditorTests
Assert.Equal(1.5, inches.Width); // the source is not modified
Assert.Equal(1.5, inches.ConvertedTo(Units.Inches).Width);
}
// D9: the editor carries removal minutes through the same three boundaries as size.
[Fact]
public void RemovalMinutesStartsFromTheRailOrMachineDefault()
{
var rail = new ClampRail(1.5, 0.625, MachineCenters, removalMinutes: 7);
Assert.Equal(7, new ClampRailEditor(rail, Units.Inches, false).RemovalMinutes);
Assert.Equal(ClampRail.DefaultRemovalMinutes,
new ClampRailEditor(null, Units.Inches, false).RemovalMinutes);
}
[Fact]
public void RemovalMinutesIsNeverConvertedBetweenUnitsOnLoadOrBuild()
{
var editor = new ClampRailEditor(null, Units.Millimeters, applyToNewNests: true);
editor.RemovalMinutes = 8;
editor.TryAddCenter(150, out _);
Assert.Equal(8, editor.ToMachineDefaults().RemovalMinutes);
Assert.Equal(8, editor.BuildRail()!.RemovalMinutes);
var defaults = new ClampSettings
{
Enabled = true,
Units = Units.Inches,
RemovalMinutes = 9,
Centers = new[] { 6.0 },
};
editor.LoadMachineDefaults(defaults);
Assert.Equal(9, editor.RemovalMinutes); // a time, so unaffected by the unit change
}
[Fact]
public void InvalidRemovalMinutesIsRefusedBeforeSaving()
{
var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: true);
editor.TryAddCenter(6, out _);
editor.RemovalMinutes = -1;
Assert.Throws<ArgumentOutOfRangeException>(() => editor.ToMachineDefaults());
Assert.Throws<ArgumentOutOfRangeException>(() => editor.BuildRail());
}
}
+65
View File
@@ -322,6 +322,71 @@ public class ClampSettingsTests : IDisposable
AssertFallback(none);
}
// D9: minutes to remove one clamp, stored alongside the rail's physical size.
[Fact]
public void RemovalMinutesDefaultsToThePlannerValue()
{
Assert.Equal(ClampRail.DefaultRemovalMinutes, new ClampSettings().RemovalMinutes);
Assert.Equal(ClampRail.DefaultRemovalMinutes, ClampSettings.Fallback.RemovalMinutes);
}
[Fact]
public void RemovalMinutesRoundTripsAndIsNeverScaledByUnits()
{
var settings = Machine();
settings.RemovalMinutes = 8;
settings.Save(_path);
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.Equal(8, loaded.RemovalMinutes);
using var json = JsonDocument.Parse(File.ReadAllText(_path));
Assert.Equal(8, json.RootElement.GetProperty("removalMinutes").GetDouble());
// Width/depth/centers scale between units; removal time (not a length) never does.
var metric = loaded.ConvertedTo(Units.Millimeters);
Assert.Equal(8, metric.RemovalMinutes);
Assert.Equal(8, loaded.RailFor(Units.Millimeters)!.RemovalMinutes);
}
[Fact]
public void MissingRemovalMinutesDefaultsOnLoad()
{
File.WriteAllText(_path, """{ "enabled": true, "centers": [6] }""");
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.Equal(ClampRail.DefaultRemovalMinutes, loaded.RemovalMinutes);
}
[Theory]
[InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": -1 }""")]
[InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": "five" }""")]
[InlineData("""{ "enabled": true, "centers": [6], "removalMinutes": null }""")]
public void InvalidRemovalMinutesFallsBackAndIsReported(string json)
{
File.WriteAllText(_path, json);
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Partial, status);
Assert.Equal(ClampRail.DefaultRemovalMinutes, loaded.RemovalMinutes);
}
[Fact]
public void ZeroRemovalMinutesIsValid()
{
var settings = Machine();
settings.RemovalMinutes = 0;
settings.Save(_path);
var loaded = ClampSettings.Load(_path, out var status);
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.Equal(0, loaded.RemovalMinutes);
}
private static ClampSettingsStatus LoadStatus(string path)
{
ClampSettings.Load(path, out var status);
+62
View File
@@ -0,0 +1,62 @@
using OpenNest.Api;
using OpenNest.Geometry;
namespace OpenNest.Tests;
/// <summary>
/// D9: a sheet's cut-time estimate gains the rail's removal minutes once per removed clamp,
/// scaled by the plate's cut quantity like every other timing field; a plate with nothing
/// removed is unaffected.
/// </summary>
public class TimingTests
{
private static Plate PlateWithOneRemovedClamp(double removalMinutes, int quantity = 1)
{
var plate = new Plate(3, 23) { Quantity = quantity, EdgeSpacing = new Spacing(0, 0, 0, 0) };
var rail = new ClampRail(1.5, 0.625, new[] { 6.0, 21.0 }, removalMinutes);
plate.SetClampRail(rail, new[] { "clamp-1" });
return plate;
}
[Fact]
public void PlateWithNoRemovedClampsAddsNoTime()
{
var plate = new Plate(48, 96) { Quantity = 3 };
var info = Timing.GetTimingInfo(plate);
Assert.Equal(0, info.ClampRemovalMinutes);
}
[Fact]
public void RemovedClampMinutesAreAddedOncePerPhysicalSheet()
{
var plate = PlateWithOneRemovedClamp(removalMinutes: 5, quantity: 3);
var info = Timing.GetTimingInfo(plate);
Assert.Equal(15, info.ClampRemovalMinutes); // 5 minutes x 3 physical sheets
}
[Fact]
public void CalculateTimeAddsTheRemovalMinutesToTheEstimate()
{
var info = new TimingInfo { ClampRemovalMinutes = 7 };
var time = Timing.CalculateTime(info, CutParameters.Default);
Assert.Equal(TimeSpan.FromMinutes(7), time);
}
[Fact]
public void NestTotalsSumRemovalMinutesAcrossPlates()
{
var nest = new Nest();
nest.Plates.Add(PlateWithOneRemovedClamp(removalMinutes: 5));
nest.Plates.Add(new Plate(48, 96)); // ordinary plate, no clamps
var info = Timing.GetTimingInfo(nest);
Assert.Equal(5, info.ClampRemovalMinutes);
}
}
+37
View File
@@ -205,6 +205,43 @@ namespace OpenNest.Controls
}
}
// D8: a clamp Auto Nest pulled as a last resort. Distinct from an active clamp - a
// dashed grey outline and a small "remove" marker, still display + exclusion only
// (no hit test, selection or sequencing).
private static readonly Color RemovedClampOutlineColor = Color.FromArgb(200, 110, 110, 110);
public void DrawRemovedClamps(Graphics g)
{
var plate = view.DisplayPlate;
if (plate?.ClampRail == null || plate.RemovedClamps.Count == 0)
return;
var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
using var pen = new Pen(RemovedClampOutlineColor, 1.5f) { DashStyle = DashStyle.Dash };
using var font = new Font(FontFamily.GenericSansSerif, 7f);
using var textBrush = new SolidBrush(RemovedClampOutlineColor);
foreach (var zone in plate.ClampRail.ResolveFor(plate.Size, plate.Quadrant))
{
if (!plate.RemovedClamps.Contains(zone.Id))
continue;
var box = zone.Bounds.Translate(left, bottom);
var rect = new RectangleF
{
Location = view.PointWorldToGraph(box.Location),
Width = view.LengthWorldToGui(box.Length),
Height = view.LengthWorldToGui(box.Width),
};
rect.Y -= rect.Height;
g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
g.DrawString("remove", font, textBrush, rect.X, rect.Y - font.GetHeight(g));
}
}
public void DrawCutOffs(Graphics g)
{
var plate = view.DisplayPlate;
+1
View File
@@ -712,6 +712,7 @@ namespace OpenNest.Controls
renderer.DrawPlate(e.Graphics);
renderer.DrawParts(e.Graphics);
renderer.DrawClampZones(e.Graphics);
renderer.DrawRemovedClamps(e.Graphics);
if (!HasProposalDisplay)
{
renderer.DrawCutOffs(e.Graphics);
+5
View File
@@ -20,6 +20,7 @@ public sealed class ClampsForm : Form
private readonly string settingsPath;
private readonly NumericUpDown widthBox;
private readonly NumericUpDown depthBox;
private readonly NumericUpDown removalMinutesBox;
private readonly NumericUpDown centerBox;
private readonly ListBox centersList;
private readonly Button addButton;
@@ -73,8 +74,10 @@ public sealed class ClampsForm : Form
var size = new TableLayoutPanel { AutoSize = true, ColumnCount = 3, Dock = DockStyle.Top };
widthBox = NewLengthBox(Editor.Width, minimum: 0.0001m);
depthBox = NewLengthBox(Editor.Depth, minimum: 0.0001m);
removalMinutesBox = NewLengthBox(Editor.RemovalMinutes, minimum: 0m);
AddRow(size, "Clamp width (along the edge):", widthBox, unit);
AddRow(size, "Clamp depth (into the plate):", depthBox, unit);
AddRow(size, "Minutes to remove and replace one clamp:", removalMinutesBox, "min");
layout.Controls.Add(size);
var centersGroup = new GroupBox
@@ -206,6 +209,7 @@ public sealed class ClampsForm : Form
{
Editor.Width = (double)widthBox.Value;
Editor.Depth = (double)depthBox.Value;
Editor.RemovalMinutes = (double)removalMinutesBox.Value;
Editor.ApplyToNewNests = applyToNewNestsBox.Checked;
}
@@ -244,6 +248,7 @@ public sealed class ClampsForm : Form
Editor.LoadMachineDefaults(machineDefaults);
widthBox.Value = ToBoxValue(widthBox, Editor.Width);
depthBox.Value = ToBoxValue(depthBox, Editor.Depth);
removalMinutesBox.Value = ToBoxValue(removalMinutesBox, Editor.RemovalMinutes);
applyToNewNestsBox.Checked = Editor.ApplyToNewNests;
RefreshCenters();
}
+12
View File
@@ -1702,6 +1702,18 @@ namespace OpenNest.Forms
target.OverlapDisplay = OverlapDisplayMode.Both;
await target.CheckOverlapsAsync();
}
// D8/D9: name every clamp Auto Nest pulled as a last resort and its added time.
var removalNotices = applied
.Where(plate => plate.RemovedClamps.Count > 0)
.Select(plate => $"Sheet {target.Nest.Plates.IndexOf(plate) + 1}: remove "
+ $"{string.Join(", ", plate.RemovedClamps.OrderBy(id => id, StringComparer.Ordinal))} "
+ $"(+{plate.ClampRemovalMinutes:0.#} min)")
.ToList();
if (removalNotices.Count > 0)
MessageBox.Show(this,
"Auto Nest removed clamps as a last resort so a part that would not "
+ "otherwise fit could be nested:\n\n" + string.Join("\n", removalNotices),
"Auto Nest", MessageBoxButtons.OK, MessageBoxIcon.Information);
if (costs?.Basis == "supplied-cost")
MessageBox.Show(this, $"Gross sheet cost: {costs.GrossTotal:G}\nEstimated salvage credit: {costs.SalvageCredit:G}\nNet score: {costs.NetScore:G}",
"Auto Nest Cost", MessageBoxButtons.OK, MessageBoxIcon.Information);