From f600c5e58bec224c22992b4489859d6c63d126d2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 24 Oct 2026 19:09:19 -0400 Subject: [PATCH] feat(desktop): nest released nests live in the main window With Nest after release ticked, Make Nests now minimizes the Drawing Sets window and nests each group's built nest in its own main-window tab, before it is saved, with Auto Nest's progress window and live plan strip, one nest at a time in plan order. Stop there (or switching tabs) stops the whole release, as the panel's Stop does; the layout is kept by the same rules as the headless renest, without the Keep review. After upload each tab is bound to its saved record instead of a second tab opening; a tab whose nest was not saved is closed. The main window stays in front, and the Drawing Sets window flashes its taskbar button when a group failed or was refused or the release was not recorded. NestRenest splits into CreateRequest and Keep so the solve runs off the UI thread and the commit on it; AutoNestCommand's solve-with-progress step is shared rather than copied. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Engine/Jobs/NestRenest.cs | 43 +++++- OpenNest.Tests/Engine/NestRenestTests.cs | 24 ++++ .../Forms/MainFormReleaseNestingTests.cs | 113 +++++++++++++++ .../Forms/SetReleaseFormsTests.cs | 130 ++++++++++++++++++ OpenNest/Controls/ReleasePanel.cs | 90 ++++++++++-- OpenNest/Forms/Commands/AutoNestCommand.cs | 90 ++++++------ OpenNest/Forms/DrawingSetsForm.cs | 19 ++- OpenNest/Forms/MainForm.File.cs | 13 +- OpenNest/Forms/MainForm.Release.cs | 114 +++++++++++++++ OpenNest/Win32.cs | 30 +++- docs/nest-storage.md | 20 ++- 11 files changed, 622 insertions(+), 64 deletions(-) create mode 100644 OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs create mode 100644 OpenNest/Forms/MainForm.Release.cs diff --git a/OpenNest.Engine/Jobs/NestRenest.cs b/OpenNest.Engine/Jobs/NestRenest.cs index ced54018..616a1635 100644 --- a/OpenNest.Engine/Jobs/NestRenest.cs +++ b/OpenNest.Engine/Jobs/NestRenest.cs @@ -25,12 +25,17 @@ public sealed record NestRenestResult( /// . A layout is committed only when it is valid and keepable; /// otherwise the nest is left without plates for the operator to run Auto Nest. Copies the /// layout leaves unplaced stay as remaining quantity. The caller owns the nest throughout. +/// A caller showing the nest uses and to solve +/// off its thread and commit on it. /// public static class NestRenest { /// The note left when no layout is kept. public const string NotKeptNote = "Auto Nest could not keep a layout: open it and run Auto Nest."; + /// The note left when no drawing has a quantity to nest. + public const string NothingToNestNote = "Nothing has a quantity to nest."; + /// Most physical sheets one renest may use, as the Auto Nest dialog allows. public const int MaxPlates = 100; @@ -51,6 +56,24 @@ public static class NestRenest nest.Plates.Clear(); nest.UpdateDrawingQuantities(); + var request = CreateRequest(nest, engineName, minimumSalvageDimension); + if (request == null) + return new NestRenestResult(false, 0, 0, Array.Empty(), NothingToNestNote); + + var result = NestPipeline.Run(engine, engineName, request, progress, token); + // Engines that ignore cancellation still have their proposal discarded. + token.ThrowIfCancellationRequested(); + return Keep(result, kept => NestPipelineCommit.ApplyToPlates(kept, nest.CreatePlate, token: token)); + } + + /// + /// The Auto Nest job for every drawing's required quantity of , on + /// sheets from its plate defaults and plate-size options; null when nothing has a quantity. + /// The nest is not changed, so a caller showing it can solve this off its thread. + /// + public static NestPipelineRequest CreateRequest(Nest nest, string engineName, double minimumSalvageDimension = 0) + { + ArgumentNullException.ThrowIfNull(nest); var items = nest.Drawings .Where(d => !d.IsCutOff && d.Quantity.Required > 0) .Select(d => new NestItem @@ -64,21 +87,29 @@ public static class NestRenest }) .ToList(); if (items.Count == 0) - return new NestRenestResult(false, 0, 0, Array.Empty(), "Nothing has a quantity to nest."); + return null; var options = nest.PlateOptions; - var request = new NestPipelineRequest(engineName, items, + return new NestPipelineRequest(engineName, items, NestStockBuilder.FromTemplate(nest.PlateDefaults.CreateNew(), options), new NestJobOptions(maxPlates: MaxPlates, salvageRate: options.Count > 0 ? nest.SalvageRate : 0, minimumSalvageDimension: options.Count > 0 ? minimumSalvageDimension : 0)); - var result = NestPipeline.Run(engine, engineName, request, progress, token); - // Engines that ignore cancellation still have their proposal discarded. - token.ThrowIfCancellationRequested(); + } + + /// + /// Keeps only when it is valid, keepable and places something: + /// then attaches its sheets to the nest (on the nest's own + /// thread) and returns them. Otherwise nothing is committed and the note says so. + /// + public static NestRenestResult Keep(NestPipelineResult result, Func> commit) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(commit); if (!result.IsValid || !result.CanKeep || result.Plates.All(p => p.Parts.Count == 0)) return new NestRenestResult(false, 0, 0, result.Unplaced, NotKeptNote); - var applied = NestPipelineCommit.ApplyToPlates(result, nest.CreatePlate, token: token); + var applied = commit(result); var sheets = applied.Sum(p => p.Quantity); var area = applied.Sum(p => p.Area() * p.Quantity); var utilization = area > 0 ? applied.Sum(p => p.Utilization() * p.Area() * p.Quantity) / area : 0; diff --git a/OpenNest.Tests/Engine/NestRenestTests.cs b/OpenNest.Tests/Engine/NestRenestTests.cs index 982d421f..936b5325 100644 --- a/OpenNest.Tests/Engine/NestRenestTests.cs +++ b/OpenNest.Tests/Engine/NestRenestTests.cs @@ -84,6 +84,30 @@ public class NestRenestTests Assert.Empty(nest.Plates); } + [Fact] + public void CreateRequest_LeavesTheNestAlone_AndKeepCommitsTheSolvedLayoutThroughTheCaller() + { + var nest = NestOf(("SYN-A", 4, 2, 6), ("SYN-B", 3, 3, 0)); + Assert.Null(NestRenest.CreateRequest(NestOf(("SYN-Z", 4, 2, 0)), "Rectangles")); + + var request = NestRenest.CreateRequest(nest, "Rectangles"); + + Assert.NotNull(request); + Assert.Empty(nest.Plates); + Assert.Equal(new[] { ("SYN-A", 6) }, request.Items.Select(i => (i.Drawing.Name, i.Quantity))); + var solved = NestPipeline.Run(request); + var committed = 0; + var kept = NestRenest.Keep(solved, r => + { + committed++; + return NestPipelineCommit.ApplyToPlates(r, nest.CreatePlate); + }); + Assert.True(kept.Nested, kept.Note); + Assert.Equal(1, committed); + Assert.Equal(6, nest.Plates.Sum(p => p.Parts.Count * p.Quantity)); + Assert.StartsWith("Nested on 1 sheet", kept.Note); + } + /// Puts every copy at the same spot, so the layout never validates. private sealed class OverlappingEngine : INestingEngine { diff --git a/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs b/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs new file mode 100644 index 00000000..c5467634 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs @@ -0,0 +1,113 @@ +using System.Windows.Forms; +using OpenNest.Controls; +using OpenNest.Data; +using OpenNest.Engine; +using OpenNest.Engine.Jobs; +using OpenNest.Forms; +using OpenNest.Geometry; +using OpenNest.Shapes; + +namespace OpenNest.WinForms.Tests.Forms; + +// Make Nests nesting live in the main window. Reads Application.OpenForms through MainForm, +// so it must not overlap fill-lifetime tests. +[Collection("Fill operation lifetime")] +public class MainFormReleaseNestingTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromMinutes(1); + + [Fact] + public void EachNestNestsInItsOwnTab_ASavedOneIsBoundNotReopened_AndAnUnsavedOneCloses() => 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; + var saved = NestOf("N26-0001", 6); + var unsaved = NestOf("N26-0002", 2); + + host.Begin(); + var first = Run(host.RenestAsync(saved, CancellationToken.None)); + var tab = Assert.Single(Tabs(main)); + Assert.Same(saved, tab.Nest); + Assert.True(first.Nested, first.Note); + Assert.StartsWith("Nested on 1 sheet", first.Note); + Assert.Equal(6, saved.Plates.Sum(p => p.Parts.Count * p.Quantity)); + Assert.Equal(Guid.Empty, tab.Document.RemoteId); + Run(host.RenestAsync(unsaved, CancellationToken.None)); + Assert.Equal(2, Tabs(main).Count); + + // Make Nests opens what it saved: the tab already showing it is bound instead. + var record = new NestRecord { Id = Guid.NewGuid(), Revision = 1, Name = saved.Name }; + main.OpenServerNest(saved, record); + Assert.Equal(2, Tabs(main).Count); + Assert.Equal((record.Id, 1), (tab.Document.RemoteId, tab.Document.RemoteRevision)); + + host.End(null!); + Application.DoEvents(); + Assert.Same(tab, Assert.Single(Tabs(main))); + }, TestTimeout, "The live release nesting test did not complete."); + + [Fact] + public void StoppingTheAutoNestStopsTheRelease_AndLeavesNoLayout() => StaTestThread.Run(() => + { + using var main = new Host(); + main.SolveReleasedNest = (_, _, cts) => + { + // The progress window's Stop. + cts.Cancel(); + return Task.FromException(new OperationCanceledException(cts.Token)); + }; + main.Show(); + IReleaseNestingHost host = main; + var nest = NestOf("N26-0003", 4); + + host.Begin(); + var renest = host.RenestAsync(nest, CancellationToken.None); + PumpUntil(() => renest.IsCompleted); + + Assert.True(renest.IsCanceled || renest.Exception?.InnerException is OperationCanceledException); + Assert.DoesNotContain(nest.Plates, p => p.Parts.Count > 0); + host.End(null!); + Application.DoEvents(); + Assert.Empty(Tabs(main)); + }, TestTimeout, "The live release stop test did not complete."); + + private static Nest NestOf(string name, int quantity) + { + 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(); + drawing.Quantity.Required = quantity; + nest.Drawings.Add(drawing); + return nest; + } + + private static List Tabs(MainForm main) => + main.MdiChildren.OfType().Where(f => !f.IsDisposed).ToList(); + + private static T Run(Task task) + { + PumpUntil(() => task.IsCompleted); + return task.GetAwaiter().GetResult(); + } + + private static void PumpUntil(Func condition) + { + var until = DateTime.UtcNow.AddSeconds(30); + while (!condition() && DateTime.UtcNow < until) + { + Application.DoEvents(); + Thread.Sleep(10); + } + Assert.True(condition(), "Timed out."); + } + + private sealed class Host : MainForm + { + // No startup migrations/automatic new document, and no settings save at close. + protected override void OnLoad(EventArgs e) { } + protected override void OnClosing(System.ComponentModel.CancelEventArgs e) { } + } +} diff --git a/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs b/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs index 500d87e6..0ac004f8 100644 --- a/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs +++ b/OpenNest.WinForms.Tests/Forms/SetReleaseFormsTests.cs @@ -6,6 +6,7 @@ using OpenNest.Forms; using OpenNest.IO; using OpenNest.Shapes; using OpenNest.Tests.Data; +using IReleaseNestingHost = OpenNest.Controls.IReleaseNestingHost; using ReleasePanel = OpenNest.Controls.ReleasePanel; namespace OpenNest.WinForms.Tests.Forms; @@ -479,6 +480,109 @@ public class SetReleaseFormsTests "The remembered nest-after test did not complete."); } + [Fact] + public void NestAfterRelease_WithANestingHost_NestsEachNestThereInPlanOrder_AndTheWindowStepsAside() + { + StaTestThread.Run( + () => + { + var library = new PackageLibrary(); + var a = library.Add("SYN-A", "Synthetic Steel"); + var c = library.Add("SYN-C", "Synthetic Aluminum"); + var sets = new InMemoryDrawingSets(); + var set = CreateSet(sets, (a, 1), (c, 2)); + var nests = new MemoryNests(); + var host = new FakeNestingHost(); + var opened = new List(); + + using var form = new DrawingSetsForm(sets, library, Server); + form.EnableRelease(nests, (nest, _) => opened.Add(nest), null, host); + var panel = form.ReleasePanel; + panel.CreateNest = (group, _) => Task.FromResult(new Nest("N") { Units = group.Units }); + var finished = 0; + panel.MakeNestsFinished += (_, _) => finished++; + form.Show(); + Field(panel, "nestAfterReleaseCheckBox").Checked = true; + panel.AddSet(set); + + Run(panel.ReleaseAsync()); + + Assert.Equal(new[] { "Begin", "Renest Synthetic Aluminum", "Renest Synthetic Steel", "End" }, host.Calls); + Assert.Equal(FormWindowState.Minimized, form.WindowState); + Assert.Same(panel.Result, host.Ended); + // The host's nests are the ones saved and opened (bound) afterwards. + Assert.Equal(host.Nests, opened); + Assert.Equal(2, nests.Uploaded.Count); + Assert.All(Field(panel, "groupGrid").Rows.Cast(), + r => Assert.EndsWith("Nested live.", Note(r))); + Assert.False(panel.NeedsAttention); + Assert.Equal(1, finished); + }, + TestTimeout, + "The live nesting test did not complete."); + } + + [Fact] + public void NestingHostStop_StopsTheRelease_SavesNothing_AndEndsTheHost() + { + StaTestThread.Run( + () => + { + var library = new PackageLibrary(); + var a = library.Add("SYN-A", "Synthetic Steel"); + var c = library.Add("SYN-C", "Synthetic Aluminum"); + var sets = new InMemoryDrawingSets(); + var set = CreateSet(sets, (a, 1), (c, 2)); + var nests = new MemoryNests(); + var host = new FakeNestingHost { StopAt = 2 }; + + var (window, panel) = ShowPanel(sets, library, nests); + using var _ = window; + panel.NestingHost = host; + panel.AddSet(set); + + Run(panel.ReleaseAsync()); + + Assert.Equal(new[] { "Begin", "Renest Synthetic Aluminum", "Renest Synthetic Steel", "End" }, host.Calls); + Assert.True(panel.Result!.Canceled); + Assert.Empty(nests.Uploaded); + Assert.Empty(sets.Releases); + Assert.Same(panel.Result, host.Ended); + Assert.Empty(panel.Result.Created); + Assert.True(panel.NeedsAttention); + Assert.StartsWith("Canceled: 0 nests saved", Field