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.
This commit is contained in:
aj committed 2026-11-03 17:38:07 -05:00
1 parent 2939fe0802
commit 3b5cf92af2
4 files changed
+139 -36

No files matched your search

@@ -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<LedgerStockResult>(new LedgerStockResult.Ok(
Array.Empty<LedgerStockSpec>(),
[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<NestStockCodeUse>())));
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;
@@ -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<CheckBox>(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<string> Calls { get; } = new();
public List<Nest> Nests { get; } = new();
public List<ReleaseLedgerStock> Ledgers { get; } = new();
public SetReleaseResult? Ended { get; private set; }
public int StopAt { get; init; }
public void Begin() => Calls.Add("Begin");
public Task<SetReleaseRenest> RenestAsync(Nest nest, CancellationToken token)
public Task<SetReleaseRenest> 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();
+45 -13
View File
@@ -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
/// <summary>
/// Nests a group's built nest when Nest after release is ticked: Auto Nest headless with
/// the selected engine (<see cref="NestRenest"/>); replaced by tests.
/// the selected engine (<see cref="NestRenest"/>), on the run's MaterialLedger stock first
/// (null when not offered); replaced by tests.
/// </summary>
internal Func<Nest, CancellationToken, Task<SetReleaseRenest>> Renest { get; set; } = RenestAsync;
internal Func<Nest, ReleaseLedgerStock, CancellationToken, Task<SetReleaseRenest>> Renest
{ get; set; } = RenestAsync;
/// <summary>
/// The MaterialLedger stock one Make Nests run nests on; replaced by tests.
/// </summary>
internal Func<ReleaseLedgerStock> CreateLedgerStock { get; set; } =
() => new ReleaseLedgerStock(
(material, grade, thicknessIn, token) => MaterialLedgerStore.CreateClient()
.GetStockAsync(material, grade, thicknessIn, token),
token => LedgerCodesOnOtherNests.ReadAsync(Guid.Empty, token));
/// <summary>Shows Edit Drawing and returns the saved record, or null; replaced by tests.</summary>
internal Func<DrawingLibraryEditSession, DrawingLibraryRecord> 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<SetReleaseProgress>(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<Nest, CancellationToken, Task<SetReleaseRenest>> LiveRenest(IReleaseNestingHost live) =>
private Func<Nest, CancellationToken, Task<SetReleaseRenest>> 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<SetReleaseRenest> RenestAsync(Nest nest, CancellationToken token)
private static Task<SetReleaseRenest> 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);
}
/// <summary>
/// The note a released nest shows: Auto Nest's, then what MaterialLedger stock it used.
/// </summary>
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
/// <summary>
/// Shows <paramref name="nest"/>, 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 <see cref="OperationCanceledException"/> when the operator stops it, which
/// stops the release.
/// drawing's required quantity on <paramref name="ledger"/>'s stock first, then the plate
/// defaults (null: the plate defaults only), keeping only valid layouts (else no sheets
/// and a note). Throws <see cref="OperationCanceledException"/> when the operator stops
/// it, which stops the release.
/// </summary>
Task<SetReleaseRenest> RenestAsync(Nest nest, CancellationToken token);
Task<SetReleaseRenest> RenestAsync(Nest nest, ReleaseLedgerStock ledger,
CancellationToken token);
/// <summary>
/// Make Nests has finished (<paramref name="result"/> is null if it threw) and has
+31 -16
View File
@@ -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<SetReleaseRenest> IReleaseNestingHost.RenestAsync(Nest nest, CancellationToken token)
async Task<SetReleaseRenest> 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<LedgerStockRow>())
{
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)