diff --git a/OpenNest.Core/Timing.cs b/OpenNest.Core/Timing.cs
index 0d6e6289..338bde9c 100644
--- a/OpenNest.Core/Timing.cs
+++ b/OpenNest.Core/Timing.cs
@@ -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;
}
}
diff --git a/OpenNest.Core/TimingInfo.cs b/OpenNest.Core/TimingInfo.cs
index b2bdf817..8a3e97ca 100644
--- a/OpenNest.Core/TimingInfo.cs
+++ b/OpenNest.Core/TimingInfo.cs
@@ -10,6 +10,13 @@ namespace OpenNest
public double CutDistance;
+ ///
+ /// Minutes added by last-resort clamp removal (D9): once per physical sheet, for
+ /// every clamp lists. Zero for a plate with no
+ /// removal, so existing timing is unaffected.
+ ///
+ 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,
};
}
}
diff --git a/OpenNest.Data/ClampRailEditor.cs b/OpenNest.Data/ClampRailEditor.cs
index 45ba887a..5f210f85 100644
--- a/OpenNest.Data/ClampRailEditor.cs
+++ b/OpenNest.Data/ClampRailEditor.cs
@@ -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
/// Clamp intrusion into the sheet (Y).
public double Depth { get; set; }
+ /// Minutes to remove one clamp and put it back after cutting (D9); not a
+ /// length, so it is never converted between units.
+ public double RemovalMinutes { get; set; }
+
/// Whether saved machine defaults give new nests these clamps.
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
///
/// The edited rail, or null when no clamp is listed. Throws
- /// for a non-positive width or depth.
+ /// for a non-positive width or depth, or a
+ /// negative/non-finite removal time.
///
public ClampRail? BuildRail() =>
- _centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers);
+ _centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers, RemovalMinutes);
/// The dialog's OK: makes the edited rail the nest's, re-resolving every plate.
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(),
};
}
diff --git a/OpenNest.Data/ClampSettings.cs b/OpenNest.Data/ClampSettings.cs
index 4c6d04f1..eb71bd05 100644
--- a/OpenNest.Data/ClampSettings.cs
+++ b/OpenNest.Data/ClampSettings.cs
@@ -57,6 +57,12 @@ public sealed class ClampSettings
public double Depth { get; set; } = DefaultDepth;
+ ///
+ /// 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.
+ ///
+ public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
+
/// Clamp centers along X, measured from the plate origin.
public IReadOnlyList Centers { get; set; } = Array.Empty();
@@ -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(),
};
///
/// The rail a new nest in receives, or null when clamps are
/// disabled or no center is configured. Values are converted between inches and
- /// millimeters as needed.
+ /// millimeters as needed; is a time, so it is never scaled.
///
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);
}
///
/// A copy with width, depth and centers expressed in
- /// (inches and millimeters convert at 25.4); is kept.
+ /// (inches and millimeters convert at 25.4); and
+ /// (a time, not a length) are kept unscaled.
///
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);
diff --git a/OpenNest.Tests/Data/ClampRailEditorTests.cs b/OpenNest.Tests/Data/ClampRailEditorTests.cs
index 52df228a..d10e3ff0 100644
--- a/OpenNest.Tests/Data/ClampRailEditorTests.cs
+++ b/OpenNest.Tests/Data/ClampRailEditorTests.cs
@@ -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(() => editor.ToMachineDefaults());
+ Assert.Throws(() => editor.BuildRail());
+ }
}
diff --git a/OpenNest.Tests/Data/ClampSettingsTests.cs b/OpenNest.Tests/Data/ClampSettingsTests.cs
index 94f7234b..5de24488 100644
--- a/OpenNest.Tests/Data/ClampSettingsTests.cs
+++ b/OpenNest.Tests/Data/ClampSettingsTests.cs
@@ -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);
diff --git a/OpenNest.Tests/TimingTests.cs b/OpenNest.Tests/TimingTests.cs
new file mode 100644
index 00000000..f9f65285
--- /dev/null
+++ b/OpenNest.Tests/TimingTests.cs
@@ -0,0 +1,62 @@
+using OpenNest.Api;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+///
+/// 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.
+///
+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);
+ }
+}
diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs
index 351ce59e..0029766c 100644
--- a/OpenNest/Controls/PlateRenderer.cs
+++ b/OpenNest/Controls/PlateRenderer.cs
@@ -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;
diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs
index 4a942d2c..ab83db29 100644
--- a/OpenNest/Controls/PlateView.cs
+++ b/OpenNest/Controls/PlateView.cs
@@ -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);
diff --git a/OpenNest/Forms/ClampsForm.cs b/OpenNest/Forms/ClampsForm.cs
index ad3dbf8d..316355c2 100644
--- a/OpenNest/Forms/ClampsForm.cs
+++ b/OpenNest/Forms/ClampsForm.cs
@@ -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();
}
diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs
index 5111cb41..c1245bff 100644
--- a/OpenNest/Forms/MainForm.cs
+++ b/OpenNest/Forms/MainForm.cs
@@ -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);