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