From 2939fe080244aca11eecf2509161dc0e27762173 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 3 Nov 2026 17:38:07 -0500 Subject: [PATCH] feat(release): nest released groups on MaterialLedger stock first NestRenest gains a staged run: a nest is nested on finite first stock alone, then whatever is left on its plate defaults and plate-size options. Each stage keeps only a valid layout, and the result lists the stock id each committed plate was solved on. ReleaseLedgerStock is the ledger stock of one Make Nests run: each nest is offered the free sheets and drops Auto Nest's ledger load would tick for its material, grade and thickness (substitutes included), minus codes on other open server nests (read once per run) and codes earlier nests of the same run took, which are not on the server until the release uploads them. Committed ledger plates get their codes as Auto Nest gives them. An unusable ledger nests on the plate defaults and the note says why the ledger was not used. --- .../MaterialLedger/ReleaseLedgerStock.cs | 159 ++++++++++++++++ OpenNest.Engine/Jobs/NestRenest.cs | 130 +++++++++++-- .../Data/ReleaseLedgerStockTests.cs | 173 ++++++++++++++++++ 3 files changed, 445 insertions(+), 17 deletions(-) create mode 100644 OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs create mode 100644 OpenNest.Tests/Data/ReleaseLedgerStockTests.cs diff --git a/OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs b/OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs new file mode 100644 index 00000000..4eff8077 --- /dev/null +++ b/OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs @@ -0,0 +1,159 @@ +using System.Globalization; +using OpenNest.Geometry; + +namespace OpenNest.Data.MaterialLedger; + +/// +/// The MaterialLedger stock one released nest is offered: the rows Auto Nest's ledger load +/// would tick (substitutes included). An empty offer nests on the plate defaults alone, and +/// says why the ledger was not used. +/// +public sealed record ReleaseLedgerOffer(IReadOnlyList Rows, string? NotUsed) +{ + public static ReleaseLedgerOffer None(string reason) => + new(Array.Empty(), reason); + + /// Why codes on other nests were not left out of the rows, or null. + public string? OtherNestsUnchecked { get; init; } + + /// + /// The rows as engine stock: finite, under their ledger:{n} ids, no costs. + /// + public IReadOnlyList Options => Rows + .Select(row => new PlateOption + { + Width = row.Size.Width, + Length = row.Size.Length, + Quantity = row.Quantity, + Id = row.Id, + }) + .ToList(); +} + +/// +/// MaterialLedger stock for one Make Nests run. Each nest is offered the free sheets and +/// drops of its material, grade and thickness, minus codes on other open server nests (read +/// once per run) and codes nests earlier in the same run took: those are not on the server +/// until the release uploads them. Nothing here throws for an unusable ledger; the offer +/// says so. +/// +public sealed class ReleaseLedgerStock +{ + /// Reads the free stock for a material, grade and thickness in inches. + public delegate Task StockReader(string material, string grade, + double thicknessIn, CancellationToken cancellationToken); + + private readonly StockReader _readStock; + private readonly Func> _readCodesInUse; + private readonly HashSet _taken = new(StringComparer.Ordinal); + private LedgerCodesInUse? _codesInUse; + + public ReleaseLedgerStock(StockReader readStock, + Func> readCodesInUse) + { + _readStock = readStock ?? throw new ArgumentNullException(nameof(readStock)); + _readCodesInUse = readCodesInUse + ?? throw new ArgumentNullException(nameof(readCodesInUse)); + } + + /// The codes nests of this run have taken so far. + public IReadOnlyCollection Taken => _taken; + + /// + /// The stock to offer , whose drawings carry their release + /// quantities. + /// + public async Task OfferAsync(Nest nest, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(nest); + var material = nest.Material?.Name?.Trim() ?? ""; + if (material.Length == 0 || !(nest.Thickness > 0)) + return ReleaseLedgerOffer.None("the nest has no material or thickness"); + + var grade = nest.Material?.Grade?.Trim() ?? ""; + var thicknessIn = LedgerStockMapper.ThicknessInches(nest.Thickness, nest.Units); + var result = await _readStock(material, grade, thicknessIn, cancellationToken); + switch (result) + { + case LedgerStockResult.Ok ok: + var mapping = LedgerStockMapper.Map(ok.Items, nest.Units, PartEnvelopes(nest), + ok.Specs); + _codesInUse ??= await _readCodesInUse(cancellationToken); + if (_codesInUse.IsKnown) + mapping = LedgerCodeFilter.Exclude(mapping, _codesInUse.Codes); + var rows = LedgerCodeFilter.Exclude(mapping.Rows, _taken, out _); + if (rows.Count == 0) + return ReleaseLedgerOffer.None( + $"no free stock of {Describe(nest, material, grade)}"); + return new ReleaseLedgerOffer(rows, null) + { + OtherNestsUnchecked = _codesInUse.Unavailable, + }; + case LedgerStockResult.Unmapped: + return ReleaseLedgerOffer.None($"no mapping for {Describe(nest, material, grade)}"); + case LedgerStockResult.Unauthorized: + return ReleaseLedgerOffer.None("it rejected the API key (Tools > Options)"); + case LedgerStockResult.Unreachable unreachable: + return ReleaseLedgerOffer.None($"unreachable: {unreachable.Reason}"); + default: + return ReleaseLedgerOffer.None("not set up on this PC (Tools > Options)"); + } + } + + /// + /// Gives the plates the release committed on 's rows their item + /// codes, as Auto Nest does, keeps those codes from later nests of this run, and returns + /// the note for the release: how many sheets came from the ledger, or why it was not used. + /// + /// + /// Each committed plate with the engine stock id it was solved on. + /// + public string Assign(Nest nest, ReleaseLedgerOffer offer, + IReadOnlyList<(Plate Plate, string StockId)> committed) + { + ArgumentNullException.ThrowIfNull(nest); + ArgumentNullException.ThrowIfNull(offer); + ArgumentNullException.ThrowIfNull(committed); + if (offer.NotUsed != null) + return $"MaterialLedger not used: {offer.NotUsed}."; + + LedgerStockAssignment.Assign(nest.Plates, committed, offer.Rows); + var sheets = 0; + foreach (var (plate, stockId) in committed) + { + _taken.UnionWith(plate.StockItemCodes); + if (LedgerStockMapper.IsLedgerId(stockId)) + sheets += plate.Quantity; + } + + var note = sheets == 0 + ? "No MaterialLedger stock used." + : string.Format(CultureInfo.CurrentCulture, "{0} sheet{1} from MaterialLedger stock.", + sheets, sheets == 1 ? "" : "s"); + return offer.OtherNestsUnchecked == null + ? note + : $"{note} Codes on other nests were not checked: {offer.OtherNestsUnchecked}."; + } + + private static List PartEnvelopes(Nest nest) + { + var envelopes = new List(); + var drawings = nest.Drawings + .Where(d => !d.IsCutOff && d.Quantity.Required > 0 && d.Program != null); + foreach (var drawing in drawings) + { + var box = drawing.Program.BoundingBox(); + if (double.IsFinite(box.Width) && double.IsFinite(box.Length) + && box.Width > 0 && box.Length > 0) + envelopes.Add(new Size(box.Width, box.Length)); + } + return envelopes; + } + + private static string Describe(Nest nest, string material, string grade) + { + var unit = nest.Units == Units.Millimeters ? "mm" : "in"; + return FormattableString.Invariant( + $"{material}{(grade.Length == 0 ? "" : " " + grade)} {nest.Thickness:G} {unit}"); + } +} diff --git a/OpenNest.Engine/Jobs/NestRenest.cs b/OpenNest.Engine/Jobs/NestRenest.cs index 616a1635..aaf72214 100644 --- a/OpenNest.Engine/Jobs/NestRenest.cs +++ b/OpenNest.Engine/Jobs/NestRenest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; +using System.Threading.Tasks; namespace OpenNest.Engine.Jobs; @@ -16,7 +17,14 @@ public sealed record NestRenestResult( double Utilization, IReadOnlyList Unplaced, string Note -); +) +{ + /// + /// Each committed plate with the engine stock id it was solved on, in commit order. + /// + public IReadOnlyList<(Plate Plate, string StockId)> Committed { get; init; } = + Array.Empty<(Plate Plate, string StockId)>(); +} /// /// Auto Nest without the dialog, for a nest no one is looking at yet (a released drawing @@ -25,8 +33,8 @@ 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. +/// A caller showing the nest uses (or +/// and ) to solve off its thread and commit on it. /// public static class NestRenest { @@ -48,7 +56,17 @@ public static class NestRenest /// Smallest offcut credited when the nest offers plate-size options, in the nest's units. /// public static NestRenestResult Run(Nest nest, INestingEngine engine, string engineName, - double minimumSalvageDimension = 0, IProgress progress = null, CancellationToken token = default) + double minimumSalvageDimension = 0, IProgress progress = null, + CancellationToken token = default) => + Run(nest, engine, engineName, null, minimumSalvageDimension, progress, token); + + /// + /// Sheets offered before the plate defaults (see ); null or + /// empty nests on the plate defaults alone. + /// + public static NestRenestResult Run(Nest nest, INestingEngine engine, string engineName, + IReadOnlyList firstStock, double minimumSalvageDimension = 0, + IProgress progress = null, CancellationToken token = default) { ArgumentNullException.ThrowIfNull(nest); ArgumentNullException.ThrowIfNull(engine); @@ -56,14 +74,66 @@ 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); + // Solved synchronously: the returned task has already completed. + return RunStagedAsync(nest, engineName, firstStock, minimumSalvageDimension, + request => Task.FromResult( + NestPipeline.Run(engine, engineName, request, progress, token)), + kept => NestPipelineCommit.ApplyToPlates(kept, nest.CreatePlate, token: token), + token) + .GetAwaiter().GetResult(); + } - var result = NestPipeline.Run(engine, engineName, request, progress, token); - // Engines that ignore cancellation still have their proposal discarded. + /// + /// Nests every drawing's remaining quantity of , which has no parts + /// placed yet, in up to two kept layouts: first on alone + /// (finite sheets such as MaterialLedger stock, so they are used before anything else), + /// then whatever is left on the plate defaults and plate-size options. A stage whose layout + /// is not valid and keepable commits nothing, so its copies fall to the next stage or stay + /// remaining. runs a request; attaches a + /// kept result's sheets to the nest on the nest's own thread and returns them. + /// + public static async Task RunStagedAsync(Nest nest, string engineName, + IReadOnlyList firstStock, double minimumSalvageDimension, + Func> solve, + Func> commit, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(nest); + ArgumentNullException.ThrowIfNull(solve); + ArgumentNullException.ThrowIfNull(commit); token.ThrowIfCancellationRequested(); - return Keep(result, kept => NestPipelineCommit.ApplyToPlates(kept, nest.CreatePlate, token: token)); + + var applied = new List(); + var committed = new List<(Plate Plate, string StockId)>(); + IReadOnlyList unplaced = Array.Empty(); + var anyRequest = false; + var stages = firstStock is { Count: > 0 } + ? new[] { firstStock, null } + : new IReadOnlyList[] { null }; + foreach (var stock in stages) + { + var request = CreateRequest(nest, engineName, minimumSalvageDimension, stock, + remainingOnly: true); + if (request == null) + break; + anyRequest = true; + var result = await solve(request); + // Engines that ignore cancellation still have their proposal discarded. + token.ThrowIfCancellationRequested(); + unplaced = result.Unplaced; + if (!IsKeepable(result)) + continue; + // Attaching parts counts them as nested, so the next stage asks only for the rest. + var plates = commit(result); + committed.AddRange(StockIds(result, plates)); + applied.AddRange(plates); + } + + if (!anyRequest) + return new NestRenestResult(false, 0, 0, Array.Empty(), + NothingToNestNote); + if (applied.Count == 0) + return new NestRenestResult(false, 0, 0, unplaced, NotKeptNote); + return Summarize(applied, unplaced) with { Committed = committed }; } /// @@ -71,15 +141,23 @@ public static class NestRenest /// 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) + public static NestPipelineRequest CreateRequest(Nest nest, string engineName, + double minimumSalvageDimension = 0) => + CreateRequest(nest, engineName, minimumSalvageDimension, null, remainingOnly: false); + + // stock: offered instead of the nest's plate-size options; remainingOnly: copies already + // placed are not asked for again. + private static NestPipelineRequest CreateRequest(Nest nest, string engineName, + double minimumSalvageDimension, IReadOnlyList stock, bool remainingOnly) { ArgumentNullException.ThrowIfNull(nest); + int Wanted(Drawing d) => remainingOnly ? d.Quantity.Remaining : d.Quantity.Required; var items = nest.Drawings - .Where(d => !d.IsCutOff && d.Quantity.Required > 0) + .Where(d => !d.IsCutOff && Wanted(d) > 0) .Select(d => new NestItem { Drawing = d, - Quantity = d.Quantity.Required, + Quantity = Wanted(d), Priority = d.Priority, RotationStart = d.Constraints.StartAngle, RotationEnd = d.Constraints.EndAngle, @@ -89,7 +167,7 @@ public static class NestRenest if (items.Count == 0) return null; - var options = nest.PlateOptions; + var options = stock ?? nest.PlateOptions; return new NestPipelineRequest(engineName, items, NestStockBuilder.FromTemplate(nest.PlateDefaults.CreateNew(), options), new NestJobOptions(maxPlates: MaxPlates, @@ -106,14 +184,32 @@ public static class NestRenest { ArgumentNullException.ThrowIfNull(result); ArgumentNullException.ThrowIfNull(commit); - if (!result.IsValid || !result.CanKeep || result.Plates.All(p => p.Parts.Count == 0)) + if (!IsKeepable(result)) return new NestRenestResult(false, 0, 0, result.Unplaced, NotKeptNote); var applied = commit(result); + return Summarize(applied, result.Unplaced) with { Committed = StockIds(result, applied) }; + } + + private static List<(Plate Plate, string StockId)> StockIds(NestPipelineResult result, + IReadOnlyList applied) => + NestPipelineCommit.PairWithStock(result, applied) + .Select(p => (p.Plate, p.Stock.Id)) + .ToList(); + + private static bool IsKeepable(NestPipelineResult result) => + result.IsValid && result.CanKeep && result.Plates.Any(p => p.Parts.Count > 0); + + private static NestRenestResult Summarize(IReadOnlyList applied, + IReadOnlyList unplaced) + { 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; - return new NestRenestResult(true, sheets, utilization, result.Unplaced, Note(sheets, utilization, result.Unplaced)); + var utilization = area > 0 + ? applied.Sum(p => p.Utilization() * p.Area() * p.Quantity) / area + : 0; + return new NestRenestResult(true, sheets, utilization, unplaced, + Note(sheets, utilization, unplaced)); } private static string Note(int sheets, double utilization, IReadOnlyList unplaced) diff --git a/OpenNest.Tests/Data/ReleaseLedgerStockTests.cs b/OpenNest.Tests/Data/ReleaseLedgerStockTests.cs new file mode 100644 index 00000000..dd981f92 --- /dev/null +++ b/OpenNest.Tests/Data/ReleaseLedgerStockTests.cs @@ -0,0 +1,173 @@ +using OpenNest.Data; +using OpenNest.Data.MaterialLedger; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.Shapes; + +namespace OpenNest.Tests.Data; + +public class ReleaseLedgerStockTests +{ + private const string Material = "Synthetic Steel"; + + private static Nest NestOf(double partSize, int quantity, string grade = "SYN-1") + { + var nest = new Nest("N26-0001") + { + Material = new Material(Material, grade), + Thickness = 0.25, + }; + nest.PlateDefaults.Size = new Size(48, 96); + var drawing = new RectangleShape { Name = "SYN-A", Length = partSize, Width = partSize } + .GetDrawing(); + drawing.Quantity.Required = quantity; + nest.Drawings.Add(drawing); + return nest; + } + + private static LedgerStockItem Sheet(string code, decimal width, decimal length) => + new(code, 1, LedgerItemKinds.FullSheet, width, length, 1, LedgerItemShapes.Rectangular, + "R1", "Rack 1", null, null, DateTime.UnixEpoch); + + private static LedgerStockResult Stock(params LedgerStockItem[] items) => + new LedgerStockResult.Ok(Array.Empty(), items); + + private sealed class FakeLedger + { + public LedgerStockResult Stock { get; init; } = Stock(); + public LedgerCodesInUse InUse { get; init; } = + LedgerCodesInUse.Known(Array.Empty()); + public List<(string Material, string Grade, double Thickness)> StockReads { get; } = new(); + public int InUseReads { get; private set; } + + public ReleaseLedgerStock Create() => new( + (material, grade, thickness, _) => + { + StockReads.Add((material, grade, thickness)); + return Task.FromResult(Stock); + }, + _ => + { + InUseReads++; + return Task.FromResult(InUse); + }); + } + + private static async Task<(NestRenestResult Renest, string Note)> Release( + ReleaseLedgerStock ledger, Nest nest) + { + var offer = await ledger.OfferAsync(nest, CancellationToken.None); + var renest = NestRenest.Run(nest, NestingEngineRegistry.Create("Rectangles"), "Rectangles", + offer.Options); + return (renest, ledger.Assign(nest, offer, renest.Committed)); + } + + [Fact] + public async Task LedgerStockIsUsedFirst_EvenWhenTheDefaultSheetWouldBeSmaller() + { + // One 60x120 ledger sheet holds both parts; a single pass scored by area would take + // the smaller 48x96 Nest Defaults sheet instead. + var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) }; + var nest = NestOf(20, 2); + + var (renest, note) = await Release(ledger.Create(), nest); + + Assert.True(renest.Nested, renest.Note); + var plate = Assert.Single(nest.Plates); + Assert.Equal(new Size(60, 120), plate.Size); + Assert.Equal(["S-1"], plate.StockItemCodes); + Assert.Equal(0, nest.Drawings.First().Quantity.Remaining); + Assert.Equal("1 sheet from MaterialLedger stock.", note); + Assert.Equal((Material, "SYN-1", 0.25), Assert.Single(ledger.StockReads)); + } + + [Fact] + public async Task CopiesTheLedgerCannotHold_TopUpOnTheNestDefaultsSheet_WithoutACode() + { + // Two free 40x40 sheets each hold one 30x30 part; the third copy needs a default sheet. + var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 40, 40), Sheet("S-2", 40, 40)) }; + var nest = NestOf(30, 3); + + var (renest, note) = await Release(ledger.Create(), nest); + + Assert.True(renest.Nested, renest.Note); + Assert.Empty(renest.Unplaced); + Assert.Equal(0, nest.Drawings.First().Quantity.Remaining); + var coded = nest.Plates.Where(p => p.StockItemCodes.Count > 0).ToList(); + Assert.Equal(["S-1", "S-2"], coded.SelectMany(p => p.StockItemCodes).Order()); + Assert.All(coded, p => Assert.Equal(new Size(40, 40), p.Size)); + var topUp = Assert.Single(nest.Plates.Where(p => p.StockItemCodes.Count == 0)); + Assert.Equal(new Size(48, 96), topUp.Size); + Assert.Equal(3, renest.Sheets); + Assert.Equal("2 sheets from MaterialLedger stock.", note); + } + + [Fact] + public async Task ALaterNestOfTheSameRun_DoesNotGetCodesAnEarlierNestTook() + { + // The release uploads every nest only after all are nested, so the server cannot + // report the first nest's codes yet; a substitute row can list the same sheet twice. + var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) }; + var run = ledger.Create(); + await Release(run, NestOf(20, 2)); + + var second = NestOf(20, 2, grade: "SYN-2"); + var (renest, note) = await Release(run, second); + + Assert.True(renest.Nested, renest.Note); + var plate = Assert.Single(second.Plates); + Assert.Empty(plate.StockItemCodes); + Assert.Equal(new Size(48, 96), plate.Size); + Assert.Equal("MaterialLedger not used: no free stock of Synthetic Steel SYN-2 0.25 in.", + note); + Assert.Equal(["S-1"], run.Taken); + Assert.Equal(1, ledger.InUseReads); + } + + [Fact] + public async Task CodesOnOtherServerNests_AreLeftOut() + { + var ledger = new FakeLedger + { + Stock = Stock(Sheet("S-1", 60, 120), Sheet("S-2", 60, 120)), + InUse = LedgerCodesInUse.Known( + [new NestStockCodeUse { ItemCode = "S-1", NestName = "N26-0099" }]), + }; + + var offer = await ledger.Create().OfferAsync(NestOf(20, 2), CancellationToken.None); + + Assert.Null(offer.NotUsed); + Assert.Equal(["S-2"], Assert.Single(offer.Rows).ItemCodes); + } + + [Fact] + public async Task AnUnreachableLedger_NestsOnTheNestDefaultsSheet_AndSaysSo() + { + var ledger = new FakeLedger { Stock = new LedgerStockResult.Unreachable("timed out") }; + var nest = NestOf(20, 2); + + var (renest, note) = await Release(ledger.Create(), nest); + + Assert.True(renest.Nested, renest.Note); + var plate = Assert.Single(nest.Plates); + Assert.Equal(new Size(48, 96), plate.Size); + Assert.Empty(plate.StockItemCodes); + Assert.Equal("MaterialLedger not used: unreachable: timed out.", note); + Assert.Equal(0, ledger.InUseReads); + } + + [Fact] + public async Task UncheckedOtherNests_AreNamedInTheNote() + { + var ledger = new FakeLedger + { + Stock = Stock(Sheet("S-1", 60, 120)), + InUse = LedgerCodesInUse.Unknown("the nest server did not answer"), + }; + + var (_, note) = await Release(ledger.Create(), NestOf(20, 2)); + + Assert.Equal("1 sheet from MaterialLedger stock. Codes on other nests were not checked: " + + "the nest server did not answer.", note); + } +}