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);