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