From 3b5cf92af2afff679934089cdafa9322292c85dc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 3 Nov 2026 17:38:07 -0500 Subject: [PATCH] feat(desktop): Make Nests nests each group on its MaterialLedger stock With Nest after release ticked, one ReleaseLedgerStock is shared by the whole run and passed to the renest step, headless or live in the main window. The live tab runs one Auto Nest per stage (ledger stock, then the Nest Defaults sheet), records the ledger locations for the plate tooltips, and the group's note says how many sheets came from the ledger or why it was not used. --- .../Forms/MainFormReleaseNestingTests.cs | 54 +++++++++++++++-- .../Forms/SetReleaseFormsTests.cs | 16 ++++- OpenNest/Controls/ReleasePanel.cs | 58 ++++++++++++++----- OpenNest/Forms/MainForm.Release.cs | 47 ++++++++++----- 4 files changed, 139 insertions(+), 36 deletions(-) diff --git a/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs b/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs index 095b79f9..560f3f06 100644 --- a/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs +++ b/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs @@ -1,6 +1,7 @@ using System.Windows.Forms; using OpenNest.Controls; using OpenNest.Data; +using OpenNest.Data.MaterialLedger; using OpenNest.Engine; using OpenNest.Engine.Jobs; using OpenNest.Forms; @@ -28,7 +29,7 @@ public class MainFormReleaseNestingTests var unsaved = NestOf("N26-0002", 2); host.Begin(); - var first = Run(host.RenestAsync(saved, CancellationToken.None)); + var first = Run(host.RenestAsync(saved, null, CancellationToken.None)); var tab = Assert.Single(Tabs(main)); Assert.Same(saved, tab.Nest); Assert.True(first.Nested, first.Note); @@ -39,7 +40,7 @@ public class MainFormReleaseNestingTests Assert.Equal(1, tab.History.UndoCount); Assert.Equal("Auto Nest", tab.History.UndoLabel); Assert.False(main.TryUndo(tab)); - Run(host.RenestAsync(unsaved, CancellationToken.None)); + Run(host.RenestAsync(unsaved, null, CancellationToken.None)); Assert.Equal(2, Tabs(main).Count); // Make Nests opens what it saved: the tab already showing it is bound instead. @@ -68,7 +69,7 @@ public class MainFormReleaseNestingTests var nest = NestOf("N26-0003", 4); host.Begin(); - var renest = host.RenestAsync(nest, CancellationToken.None); + var renest = host.RenestAsync(nest, null, CancellationToken.None); PumpUntil(() => renest.IsCompleted); Assert.True(renest.IsCanceled || renest.Exception?.InnerException is OperationCanceledException); @@ -78,11 +79,54 @@ public class MainFormReleaseNestingTests Assert.Empty(Tabs(main)); }, TestTimeout, "The live release stop test did not complete."); - private static Nest NestOf(string name, int quantity) + [Fact] + public void LedgerStockIsNestedFirst_InTheTab_ThenTheRestOnTheNestDefaultsSheet() => + StaTestThread.Run(() => + { + using var main = new Host(); + main.SolveReleasedNest = (_, request, cts) => Task.Run(() => NestPipeline.Run( + NestingEngineRegistry.Create("Rectangles"), "Rectangles", request, null, cts.Token)); + main.Show(); + IReleaseNestingHost host = main; + // One free 10x10 ledger sheet holds one 8x8 part; the second copy tops up on 48x96. + var nest = NestOf("N26-0004", 2, partSize: 8); + nest.Material = new Material("Synthetic Steel", "SYN-1"); + nest.Thickness = 0.25; + var ledger = new ReleaseLedgerStock( + (_, _, _, _) => Task.FromResult(new LedgerStockResult.Ok( + Array.Empty(), + [new LedgerStockItem("S-1", 1, LedgerItemKinds.FullSheet, 10, 10, 1, + LedgerItemShapes.Rectangular, "B3", "Bay 3", null, null, DateTime.UnixEpoch)])), + _ => Task.FromResult(LedgerCodesInUse.Known(Array.Empty()))); + + host.Begin(); + var renest = Run(host.RenestAsync(nest, ledger, CancellationToken.None)); + + var tab = Assert.Single(Tabs(main)); + Assert.True(renest.Nested, renest.Note); + Assert.EndsWith(" 1 sheet from MaterialLedger stock.", renest.Note); + var coded = Assert.Single(nest.Plates, p => p.StockItemCodes.Count > 0); + Assert.Equal((new Size(10, 10), "S-1"), (coded.Size, Assert.Single(coded.StockItemCodes))); + Assert.Contains(nest.Plates, p => p.Parts.Count > 0 && p.Size.Equals(new Size(48, 96)) + && p.StockItemCodes.Count == 0); + Assert.Equal(2, nest.Plates.Sum(p => p.Parts.Count * p.Quantity)); + Assert.Equal("B3", tab.LedgerLocations["S-1"]); + // Each stage is its own Auto Nest step in the tab. + Assert.Equal(2, tab.History.UndoCount); + host.End(null!); + Application.DoEvents(); + }, TestTimeout, "The live release ledger test did not complete."); + + private static Nest NestOf(string name, int quantity, double partSize = 0) { var nest = new Nest(name) { Units = Units.Inches }; nest.PlateDefaults.Size = new Size(48, 96); - var drawing = new RectangleShape { Name = name + "-A", Length = 4, Width = 2 }.GetDrawing(); + var drawing = new RectangleShape + { + Name = name + "-A", + Length = partSize > 0 ? partSize : 4, + Width = partSize > 0 ? partSize : 2, + }.GetDrawing(); drawing.Quantity.Required = quantity; nest.Drawings.Add(drawing); return nest; diff --git a/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs b/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs index 8b7df061..387b0318 100644 --- a/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs +++ b/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Security.Cryptography; using System.Windows.Forms; using OpenNest.Data; +using OpenNest.Data.MaterialLedger; using OpenNest.Forms; using OpenNest.IO; using OpenNest.Shapes; @@ -421,7 +422,11 @@ public class SetReleaseFormsTests var (host, panel) = ShowPanel(sets, library, nests); using var _ = host; - panel.Renest = (_, _) => { renested++; return Task.FromResult(new SetReleaseRenest(true, "")); }; + panel.Renest = (_, _, _) => + { + renested++; + return Task.FromResult(new SetReleaseRenest(true, "")); + }; var nestAfter = Field(panel, "nestAfterReleaseCheckBox"); nestAfter.Checked = true; panel.AddSet(set); @@ -510,6 +515,10 @@ public class SetReleaseFormsTests Run(panel.ReleaseAsync()); Assert.Equal(new[] { "Begin", "Renest Synthetic Aluminum", "Renest Synthetic Steel", "End" }, host.Calls); + // Both nests share the run's ledger stock, so neither takes a sheet the other took. + Assert.Equal(2, host.Ledgers.Count); + Assert.NotNull(host.Ledgers[0]); + Assert.Same(host.Ledgers[0], host.Ledgers[1]); Assert.Equal(FormWindowState.Minimized, form.WindowState); Assert.Same(panel.Result, host.Ended); // The host's nests are the ones saved and opened (bound) afterwards. @@ -1136,14 +1145,17 @@ public class SetReleaseFormsTests { public List Calls { get; } = new(); public List Nests { get; } = new(); + public List Ledgers { get; } = new(); public SetReleaseResult? Ended { get; private set; } public int StopAt { get; init; } public void Begin() => Calls.Add("Begin"); - public Task RenestAsync(Nest nest, CancellationToken token) + public Task RenestAsync(Nest nest, ReleaseLedgerStock ledger, + CancellationToken token) { Nests.Add(nest); + Ledgers.Add(ledger); Calls.Add($"Renest {nest.Material.Name}"); if (Nests.Count == StopAt) throw new OperationCanceledException(); diff --git a/OpenNest/Controls/ReleasePanel.cs b/OpenNest/Controls/ReleasePanel.cs index 54966cd9..233e5ea8 100644 --- a/OpenNest/Controls/ReleasePanel.cs +++ b/OpenNest/Controls/ReleasePanel.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using OpenNest.Data; +using OpenNest.Data.MaterialLedger; using OpenNest.Engine.BestFit; using OpenNest.Engine.Jobs; using OpenNest.Forms; @@ -70,9 +71,20 @@ namespace OpenNest.Controls /// /// Nests a group's built nest when Nest after release is ticked: Auto Nest headless with - /// the selected engine (); replaced by tests. + /// the selected engine (), on the run's MaterialLedger stock first + /// (null when not offered); replaced by tests. /// - internal Func> Renest { get; set; } = RenestAsync; + internal Func> Renest + { get; set; } = RenestAsync; + + /// + /// The MaterialLedger stock one Make Nests run nests on; replaced by tests. + /// + internal Func CreateLedgerStock { get; set; } = + () => new ReleaseLedgerStock( + (material, grade, thicknessIn, token) => MaterialLedgerStore.CreateClient() + .GetStockAsync(material, grade, thicknessIn, token), + token => LedgerCodesOnOtherNests.ReadAsync(Guid.Empty, token)); /// Shows Edit Drawing and returns the saved record, or null; replaced by tests. internal Func EditDrawing { get; set; } @@ -733,11 +745,15 @@ namespace OpenNest.Controls var merges = ChosenMerges(); var nestAfter = nestAfterReleaseCheckBox.Checked; var live = nestAfter ? NestingHost : null; + // One run shares its ledger stock, so two of its nests never take the same sheet. + var ledger = nestAfter ? CreateLedgerStock() : null; running = true; cancellation = new CancellationTokenSource(); UpdateCommands(); session = new SetReleaseSession(sets, library, nests, serverUrl, ReadPackage, WriteNest, CreateNest, ReadNest, - !nestAfter ? null : live == null ? Renest : LiveRenest(live)); + !nestAfter ? null + : live == null ? (nest, token) => Renest(nest, ledger, token) + : LiveRenest(live, ledger)); var progress = new Progress(ShowProgress); if (live != null) { @@ -767,12 +783,13 @@ namespace OpenNest.Controls } // The host's Auto Nest stops the whole release, as Stop here does. - private Func> LiveRenest(IReleaseNestingHost live) => + private Func> LiveRenest( + IReleaseNestingHost live, ReleaseLedgerStock ledger) => async (nest, token) => { try { - return await live.RenestAsync(nest, token); + return await live.RenestAsync(nest, ledger, token); } catch (OperationCanceledException) when (!token.IsCancellationRequested) { @@ -1138,16 +1155,29 @@ namespace OpenNest.Controls }.Where(s => !string.IsNullOrWhiteSpace(s))); // The nest is not shown anywhere yet, so Auto Nest runs off the UI thread on it alone. - private static Task RenestAsync(Nest nest, CancellationToken token) + private static Task RenestAsync(Nest nest, ReleaseLedgerStock ledger, + CancellationToken token) { var engineName = EngineSelection.EngineName; - return Task.Run(() => + return Task.Run(async () => { - var renested = NestRenest.Run(nest, engineName, AutoNestForm.DefaultMinRemnantSize, token: token); - return new SetReleaseRenest(renested.Nested, renested.Note); + var offer = ledger == null ? null : await ledger.OfferAsync(nest, token); + var renested = NestRenest.Run(nest, NestingEngineRegistry.Create(engineName), + engineName, offer?.Options, AutoNestForm.DefaultMinRemnantSize, token: token); + var ledgerNote = offer == null + ? null + : ledger.Assign(nest, offer, renested.Committed); + return new SetReleaseRenest(renested.Nested, + ReleaseNote(renested.Note, ledgerNote)); }, token); } + /// + /// The note a released nest shows: Auto Nest's, then what MaterialLedger stock it used. + /// + internal static string ReleaseNote(string renestNote, string ledgerNote) => + string.Join(" ", new[] { renestNote, ledgerNote }.Where(n => !string.IsNullOrEmpty(n))); + private static LibraryPackage ReadPackage(byte[] bytes) { var contents = DrawingPackage.Read(bytes); @@ -1179,11 +1209,13 @@ namespace OpenNest.Controls /// /// Shows , built but not saved, and nests it from scratch: every - /// drawing's required quantity, keeping only a valid layout (else no sheets and a note). - /// Throws when the operator stops it, which - /// stops the release. + /// drawing's required quantity on 's stock first, then the plate + /// defaults (null: the plate defaults only), keeping only valid layouts (else no sheets + /// and a note). Throws when the operator stops + /// it, which stops the release. /// - Task RenestAsync(Nest nest, CancellationToken token); + Task RenestAsync(Nest nest, ReleaseLedgerStock ledger, + CancellationToken token); /// /// Make Nests has finished ( is null if it threw) and has diff --git a/OpenNest/Forms/MainForm.Release.cs b/OpenNest/Forms/MainForm.Release.cs index 2b170d32..1ac580f5 100644 --- a/OpenNest/Forms/MainForm.Release.cs +++ b/OpenNest/Forms/MainForm.Release.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.Windows.Forms; using OpenNest.Controls; using OpenNest.Data; +using OpenNest.Data.MaterialLedger; using OpenNest.Engine.Jobs; using OpenNest.Forms.Commands; @@ -39,7 +40,8 @@ namespace OpenNest.Forms BringForward(); } - async Task IReleaseNestingHost.RenestAsync(Nest nest, CancellationToken token) + async Task IReleaseNestingHost.RenestAsync(Nest nest, + ReleaseLedgerStock ledger, CancellationToken token) { ArgumentNullException.ThrowIfNull(nest); token.ThrowIfCancellationRequested(); @@ -52,36 +54,49 @@ namespace OpenNest.Forms if (tab.PlateManager.CanClearAll) tab.ClearNest(); nest.UpdateDrawingQuantities(); - var request = NestRenest.CreateRequest(nest, EngineSelection.EngineName, AutoNestForm.DefaultMinRemnantSize); - if (request == null) - return new SetReleaseRenest(false, NestRenest.NothingToNestNote); + var offer = ledger == null ? null : await ledger.OfferAsync(nest, token); using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); // As for Auto Nest, switching tabs while it runs stops it. nestingCts = cts; - NestPipelineResult result; + NestRenestResult renested; try { - result = await (SolveReleasedNest ?? ((t, r, c) => AutoNestCommand.SolveAsync(this, t, r, c)))(tab, request, cts); + // Ledger stock first, then the plate defaults: each stage is its own Auto Nest run. + var solve = SolveReleasedNest + ?? ((t, r, c) => AutoNestCommand.SolveAsync(this, t, r, c)); + renested = await NestRenest.RunStagedAsync(nest, EngineSelection.EngineName, + offer?.Options, AutoNestForm.DefaultMinRemnantSize, + request => solve(tab, request, cts), + r => + { + using var change = tab.BeginChange("Auto Nest"); + change.MarkChanged(); + var applied = NestPipelineCommit.ApplyToEmptyPlates(r, tab.PlateManager, + token: cts.Token); + change.Complete(); + return applied; + }, cts.Token); } finally { nestingCts = null; } - cts.Token.ThrowIfCancellationRequested(); - var kept = NestRenest.Keep(result, r => + var ledgerNote = offer == null ? null : ledger.Assign(nest, offer, renested.Committed); + foreach (var row in offer?.Rows ?? Array.Empty()) { - using var change = tab.BeginChange("Auto Nest"); - change.MarkChanged(); - var applied = NestPipelineCommit.ApplyToEmptyPlates(r, tab.PlateManager, token: cts.Token); - change.Complete(); - return applied; - }); - if (kept.Nested) + foreach (var (code, location) in row.ItemLocations) + tab.LedgerLocations[code] = location; + } + if (renested.Nested) + { + tab.UpdatePlateList(); tab.PlateManager.LoadFirst(); + } tab.PlateView.ZoomToFit(); - return new SetReleaseRenest(kept.Nested, kept.Note); + return new SetReleaseRenest(renested.Nested, + ReleasePanel.ReleaseNote(renested.Note, ledgerNote)); } void IReleaseNestingHost.End(SetReleaseResult result)