diff --git a/OpenNest.Data/MaterialLedger/LedgerCodeFilter.cs b/OpenNest.Data/MaterialLedger/LedgerCodeFilter.cs index 47572d27..090e621c 100644 --- a/OpenNest.Data/MaterialLedger/LedgerCodeFilter.cs +++ b/OpenNest.Data/MaterialLedger/LedgerCodeFilter.cs @@ -20,13 +20,41 @@ public static class LedgerCodeFilter ArgumentNullException.ThrowIfNull(rows); ArgumentNullException.ThrowIfNull(codesInUse); var exclude = codesInUse as IReadOnlySet ?? codesInUse.ToHashSet(StringComparer.Ordinal); + return Rebuild(rows, exclude.Contains, out removed); + } + + /// + /// with only of each code taken out, in + /// row and ledger order: a bundle whose one code repeats per sheet loses as many sheets + /// as were taken, not the whole bundle. Otherwise as + /// . + /// + public static IReadOnlyList ExcludeCopies(IEnumerable rows, + IReadOnlyDictionary copies, out int removed) + { + ArgumentNullException.ThrowIfNull(rows); + ArgumentNullException.ThrowIfNull(copies); + var left = new Dictionary(copies, StringComparer.Ordinal); + return Rebuild(rows, code => + { + if (!left.TryGetValue(code, out var count) || count <= 0) + return false; + left[code] = count - 1; + return true; + }, out removed); + } + + // takeOut is asked once per copy, in row order and the ledger order within a row. + private static IReadOnlyList Rebuild(IEnumerable rows, + Func takeOut, out int removed) + { removed = 0; var kept = new List(); foreach (var row in rows) { if (row == null) continue; - var codes = row.ItemCodes.Where(code => !exclude.Contains(code)).ToList(); + var codes = row.ItemCodes.Where(code => !takeOut(code)).ToList(); var dropped = row.ItemCodes.Count - codes.Count; if (dropped == 0) { @@ -37,8 +65,9 @@ public static class LedgerCodeFilter if (codes.Count == 0) continue; + var left = codes.ToHashSet(StringComparer.Ordinal); var itemLocations = row.ItemLocations - .Where(pair => !exclude.Contains(pair.Key)) + .Where(pair => left.Contains(pair.Key) || !row.ItemCodes.Contains(pair.Key)) .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); // A location stays listed while an item left in the row is there, or when the row // never said which item it held. diff --git a/OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs b/OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs index 4eff8077..8179d2e3 100644 --- a/OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs +++ b/OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs @@ -19,7 +19,7 @@ public sealed record ReleaseLedgerOffer(IReadOnlyList Rows, stri /// /// The rows as engine stock: finite, under their ledger:{n} ids, no costs. /// - public IReadOnlyList Options => Rows + public IReadOnlyList Options { get; } = Rows .Select(row => new PlateOption { Width = row.Size.Width, @@ -33,9 +33,9 @@ public sealed record ReleaseLedgerOffer(IReadOnlyList Rows, stri /// /// 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. +/// once per run) and the sheets 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 { @@ -45,7 +45,8 @@ public sealed class ReleaseLedgerStock private readonly StockReader _readStock; private readonly Func> _readCodesInUse; - private readonly HashSet _taken = new(StringComparer.Ordinal); + // Sheets taken per code: a bundle repeats its one code per sheet. + private readonly Dictionary _taken = new(StringComparer.Ordinal); private LedgerCodesInUse? _codesInUse; public ReleaseLedgerStock(StockReader readStock, @@ -56,8 +57,9 @@ public sealed class ReleaseLedgerStock ?? throw new ArgumentNullException(nameof(readCodesInUse)); } - /// The codes nests of this run have taken so far. - public IReadOnlyCollection Taken => _taken; + /// The sheets nests of this run have taken so far, per code. + public IReadOnlyDictionary Taken => + new Dictionary(_taken, StringComparer.Ordinal); /// /// The stock to offer , whose drawings carry their release @@ -73,6 +75,7 @@ public sealed class ReleaseLedgerStock var grade = nest.Material?.Grade?.Trim() ?? ""; var thicknessIn = LedgerStockMapper.ThicknessInches(nest.Thickness, nest.Units); var result = await _readStock(material, grade, thicknessIn, cancellationToken); + var described = Describe(nest, material, grade); switch (result) { case LedgerStockResult.Ok ok: @@ -81,60 +84,96 @@ public sealed class ReleaseLedgerStock _codesInUse ??= await _readCodesInUse(cancellationToken); if (_codesInUse.IsKnown) mapping = LedgerCodeFilter.Exclude(mapping, _codesInUse.Codes); - var rows = LedgerCodeFilter.Exclude(mapping.Rows, _taken, out _); + var rows = LedgerCodeFilter.ExcludeCopies(mapping.Rows, _taken, out var taken); if (rows.Count == 0) - return ReleaseLedgerOffer.None( - $"no free stock of {Describe(nest, material, grade)}"); + return ReleaseLedgerOffer.None(Unusable(described, mapping, taken)); return new ReleaseLedgerOffer(rows, null) { OtherNestsUnchecked = _codesInUse.Unavailable, }; case LedgerStockResult.Unmapped: - return ReleaseLedgerOffer.None($"no mapping for {Describe(nest, material, grade)}"); + return ReleaseLedgerOffer.None($"no mapping for {described}"); case LedgerStockResult.Unauthorized: return ReleaseLedgerOffer.None("it rejected the API key (Tools > Options)"); case LedgerStockResult.Unreachable unreachable: - return ReleaseLedgerOffer.None($"unreachable: {unreachable.Reason}"); + return ReleaseLedgerOffer.None($"unreachable: {unreachable.Reason.TrimEnd('.')}"); 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. + /// Gives the plates just committed on 's rows their item codes, as + /// Auto Nest does, and keeps those sheets from later nests of this run. Call it inside the + /// commit's undo step. Returns each assigned code's ledger location, for display. /// /// - /// Each committed plate with the engine stock id it was solved on. + /// Each plate the commit attached, with the engine stock id it was solved on. /// - public string Assign(Nest nest, ReleaseLedgerOffer offer, + public IReadOnlyDictionary AssignCodes(Nest nest, ReleaseLedgerOffer offer, IReadOnlyList<(Plate Plate, string StockId)> committed) { ArgumentNullException.ThrowIfNull(nest); ArgumentNullException.ThrowIfNull(offer); ArgumentNullException.ThrowIfNull(committed); + var locations = new Dictionary(StringComparer.Ordinal); + LedgerStockAssignment.Assign(nest.Plates, committed, offer.Rows); + foreach (var (plate, _) in committed) + { + foreach (var code in plate.StockItemCodes) + { + _taken[code] = _taken.GetValueOrDefault(code) + 1; + var row = offer.Rows.FirstOrDefault(r => r.ItemLocations.ContainsKey(code)); + if (row != null) + locations[code] = row.ItemLocations[code]; + } + } + return locations; + } + + /// + /// The release note for nested on : how + /// many sheets came from the ledger, or why it was not used. + /// + /// + /// True when Auto Nest could not keep a layout on the offer. + /// + public static string Note(ReleaseLedgerOffer offer, Nest nest, bool layoutNotKept) + { + ArgumentNullException.ThrowIfNull(offer); + ArgumentNullException.ThrowIfNull(nest); 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"); + var sheets = nest.Plates.Sum(p => p.StockItemCodes.Count); + var note = layoutNotKept + ? "MaterialLedger not used: Auto Nest could not keep a layout on its stock." + : sheets == 0 + ? "No MaterialLedger stock used: no part fits it." + : 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}."; } + // Why no row is left, in the counts Auto Nest's ledger summary uses. + private static string Unusable(string described, LedgerStockMapping mapping, int taken) + { + var reasons = new List(); + if (mapping.OnOtherNests > 0) + reasons.Add($"{mapping.OnOtherNests} on other nests"); + if (taken > 0) + reasons.Add($"{taken} taken by earlier nests of this release"); + if (mapping.SkippedTooSmall > 0) + reasons.Add($"{mapping.SkippedTooSmall} too small for any part"); + if (mapping.SkippedUnknownShape > 0) + reasons.Add($"{mapping.SkippedUnknownShape} of unknown shape"); + return reasons.Count == 0 + ? $"no free stock of {described}" + : $"no usable stock of {described} ({string.Join(", ", reasons)})"; + } + private static List PartEnvelopes(Nest nest) { var envelopes = new List(); diff --git a/OpenNest.Desktop.Tests/ReleaseRenestTests.cs b/OpenNest.Desktop.Tests/ReleaseRenestTests.cs new file mode 100644 index 00000000..a80583f6 --- /dev/null +++ b/OpenNest.Desktop.Tests/ReleaseRenestTests.cs @@ -0,0 +1,299 @@ +using OpenNest.Data; +using OpenNest.Data.MaterialLedger; +using OpenNest.Engine; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.Shapes; + +namespace OpenNest.Desktop.Tests; + +public class ReleaseRenestTests +{ + 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 LedgerStockItem Bundle(string code, decimal width, decimal length, + int quantity) => + new(code, 1, LedgerItemKinds.FullSheetBundle, width, length, quantity, + LedgerItemShapes.Rectangular, "B3", "Bay 3", 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); + }); + } + + /// + /// Rectangles, except that a sheet overlaps picks gets every copy at one spot. + /// + private sealed class OverlapOn(Func overlaps) : INestingEngine + { + private readonly INestingEngine _inner = NestingEngineRegistry.Create("Rectangles"); + + public NestJobResult Solve(NestJob job, IProgress progress = null, + CancellationToken token = default) + { + if (!overlaps(job.Plates[0].Id)) + return _inner.Solve(job, progress, token); + var builder = new NestJobResultBuilder(job); + builder.AddSheet(job.Plates[0], Enumerable.Range(0, job.Parts[0].Quantity) + .Select(_ => (job.Parts[0].Id, 5.0, 5.0, 0.0)).ToArray()); + return builder.Build(NestJobStopReason.Completed); + } + } + + private static Task Release(ReleaseLedgerStock ledger, Nest nest, + INestingEngine engine = null) => + ReleaseRenest.RunHeadlessAsync(nest, ledger, + engine ?? NestingEngineRegistry.Create("Rectangles"), "Rectangles", 0, + CancellationToken.None); + + [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 result = await Release(ledger.Create(), nest); + + Assert.True(result.Nested, result.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.EndsWith(" 1 sheet from MaterialLedger stock.", result.Note); + Assert.Equal("R1", result.LedgerLocations["S-1"]); + 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 result = await Release(ledger.Create(), nest); + + Assert.True(result.Nested, result.Note); + 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.StartsWith("Nested on 3 sheets, ", result.Note); + Assert.EndsWith(" 2 sheets from MaterialLedger stock.", result.Note); + } + + [Fact] + public async Task ALaterNestOfTheSameRun_DoesNotGetASheetAnEarlierNestTook() + { + // 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 result = await Release(run, second); + + Assert.True(result.Nested, result.Note); + var plate = Assert.Single(second.Plates); + Assert.Empty(plate.StockItemCodes); + Assert.Equal(new Size(48, 96), plate.Size); + Assert.EndsWith(" MaterialLedger not used: no usable stock of Synthetic Steel SYN-2 0.25 in" + + " (1 taken by earlier nests of this release).", result.Note); + Assert.Equal(1, run.Taken["S-1"]); + Assert.Equal(1, ledger.InUseReads); + } + + [Fact] + public async Task ABundleIsSharedSheetBySheet_AcrossNestsOfTheRun() + { + var ledger = new FakeLedger { Stock = Stock(Bundle("B-1", 60, 120, 10)) }; + var run = ledger.Create(); + await Release(run, NestOf(20, 2)); + + var offer = await run.OfferAsync(NestOf(20, 2, grade: "SYN-2"), CancellationToken.None); + + Assert.Equal(9, Assert.Single(offer.Rows).Quantity); + } + + [Fact] + public async Task CodesOnOtherServerNests_AreLeftOut_AndNamedWhenNothingIsLeft() + { + var inUse = LedgerCodesInUse.Known( + [new NestStockCodeUse { ItemCode = "S-1", NestName = "N26-0099" }]); + var partly = new FakeLedger + { + Stock = Stock(Sheet("S-1", 60, 120), Sheet("S-2", 60, 120)), + InUse = inUse, + }; + var all = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)), InUse = inUse }; + + var offer = await partly.Create().OfferAsync(NestOf(20, 2), CancellationToken.None); + var none = await all.Create().OfferAsync(NestOf(20, 2), CancellationToken.None); + + Assert.Null(offer.NotUsed); + Assert.Equal(["S-2"], Assert.Single(offer.Rows).ItemCodes); + Assert.Equal("no usable stock of Synthetic Steel SYN-1 0.25 in (1 on other nests)", + none.NotUsed); + } + + [Fact] + public async Task NoStockAtAll_IsCalledNoFreeStock() + { + var result = await Release(new FakeLedger().Create(), NestOf(20, 2)); + + Assert.EndsWith(" MaterialLedger not used: no free stock of Synthetic Steel SYN-1 0.25 in.", + result.Note); + } + + [Theory] + [InlineData("unreachable", "unreachable: No answer within 10 s")] + [InlineData("unauthorized", "it rejected the API key (Tools > Options)")] + [InlineData("unmapped", "no mapping for Synthetic Steel SYN-1 0.25 in")] + [InlineData("not configured", "not set up on this PC (Tools > Options)")] + public async Task AnUnusableLedger_NestsOnTheNestDefaultsSheet_AndSaysWhy(string state, + string why) + { + LedgerStockResult stock = state switch + { + "unreachable" => new LedgerStockResult.Unreachable("No answer within 10 s."), + "unauthorized" => new LedgerStockResult.Unauthorized(), + "unmapped" => new LedgerStockResult.Unmapped(), + _ => new LedgerStockResult.NotConfigured(), + }; + var ledger = new FakeLedger { Stock = stock }; + var nest = NestOf(20, 2); + + var result = await Release(ledger.Create(), nest); + + Assert.True(result.Nested, result.Note); + var plate = Assert.Single(nest.Plates); + Assert.Equal(new Size(48, 96), plate.Size); + Assert.Empty(plate.StockItemCodes); + Assert.EndsWith($" MaterialLedger not used: {why}.", result.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 result = await Release(ledger.Create(), NestOf(20, 2)); + + Assert.EndsWith(" 1 sheet from MaterialLedger stock. Codes on other nests were not " + + "checked: the nest server did not answer.", result.Note); + } + + [Fact] + public async Task NothingToNest_DoesNotReadTheLedger() + { + var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) }; + + var result = await Release(ledger.Create(), NestOf(20, 0)); + + Assert.Equal((false, NestRenest.NothingToNestNote), (result.Nested, result.Note)); + Assert.Empty(ledger.StockReads); + } + + [Fact] + public async Task ARejectedLedgerLayout_FallsToTheNestDefaults_AndSaysTheLedgerWasNotUsed() + { + var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) }; + var nest = NestOf(20, 2); + + var result = await Release(ledger.Create(), nest, + new OverlapOn(LedgerStockMapper.IsLedgerId)); + + Assert.True(result.Nested, result.Note); + Assert.Equal(new Size(48, 96), Assert.Single(nest.Plates).Size); + Assert.EndsWith(" MaterialLedger not used: Auto Nest could not keep a layout on its stock.", + result.Note); + } + + [Fact] + public async Task ARejectedDefaultsLayout_ListsEveryCopyItAskedFor() + { + // One 40x40 ledger sheet holds one of three copies; the defaults layout never validates. + var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 40, 40)) }; + var nest = NestOf(30, 3); + + var result = await Release(ledger.Create(), nest, + new OverlapOn(id => !LedgerStockMapper.IsLedgerId(id))); + + Assert.True(result.Nested, result.Note); + Assert.Equal(2, nest.Drawings.First().Quantity.Remaining); + Assert.Contains(" 2 copies not placed, left to nest: " + + "SYN-A: 2 not placed (the proposal cannot be kept).", result.Note); + Assert.EndsWith(" 1 sheet from MaterialLedger stock.", result.Note); + } + + [Fact] + public async Task CodesAreGivenInsideTheCommit_BeforeItsUndoStepCloses() + { + var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) }.Create(); + var nest = NestOf(20, 2); + var codesAtClose = new List(); + + await ReleaseRenest.RunAsync(nest, ledger, "Rectangles", 0, + request => Task.FromResult(NestPipeline.Run(NestingEngineRegistry.Create("Rectangles"), + "Rectangles", request, null, CancellationToken.None)), + (result, finish) => + { + var plates = NestPipelineCommit.ApplyToPlates(result, nest.CreatePlate); + finish(plates); + // Where the live tab completes its undo step. + codesAtClose.AddRange(plates.SelectMany(p => p.StockItemCodes)); + return plates; + }, + CancellationToken.None); + + Assert.Equal(["S-1"], codesAtClose); + } +} diff --git a/OpenNest.Desktop/ReleaseRenest.cs b/OpenNest.Desktop/ReleaseRenest.cs new file mode 100644 index 00000000..b42980c5 --- /dev/null +++ b/OpenNest.Desktop/ReleaseRenest.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenNest.Data.MaterialLedger; +using OpenNest.Engine; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Desktop +{ + /// + /// What Make Nests' renest step kept, its note and the ledger codes' locations. + /// + public sealed record ReleaseRenestResult(bool Nested, string Note, + IReadOnlyDictionary LedgerLocations); + + /// + /// Make Nests' Auto Nest of one released nest: on the run's MaterialLedger stock first, + /// then on the nest's plate defaults (), with the + /// ledger codes given to the plates inside each commit, so they are part of its undo step. + /// Shared by the headless and the live (main window) release. + /// + public static class ReleaseRenest + { + /// + /// Attaches a kept result's sheets to the nest's plates and returns them. It must call + /// finish with those plates before its undo step closes; that gives them their + /// ledger codes. + /// + public delegate IReadOnlyList Commit(NestPipelineResult result, + Action> finish); + + /// + /// Nests , which has no parts placed, on 's + /// stock (null: the plate defaults only) and then its plate defaults. The ledger is not + /// read when nothing has a quantity to nest. + /// + public static async Task RunAsync(Nest nest, ReleaseLedgerStock ledger, + string engineName, double minimumSalvageDimension, + Func> solve, Commit commit, + CancellationToken token) + { + ArgumentNullException.ThrowIfNull(nest); + ArgumentNullException.ThrowIfNull(solve); + ArgumentNullException.ThrowIfNull(commit); + var wanted = nest.Drawings.Any(d => !d.IsCutOff && d.Quantity.Remaining > 0); + var offer = ledger != null && wanted ? await ledger.OfferAsync(nest, token) : null; + var locations = new Dictionary(StringComparer.Ordinal); + + var renested = await NestRenest.RunStagedAsync(nest, engineName, offer?.Options, + minimumSalvageDimension, solve, + result => commit(result, plates => + { + if (offer == null) + return; + var stock = NestPipelineCommit.PairWithStock(result, plates) + .Select(p => (p.Plate, p.Stock.Id)) + .ToList(); + foreach (var (code, location) in ledger.AssignCodes(nest, offer, stock)) + locations[code] = location; + }), + token); + + var ledgerNote = offer == null + ? null + : ReleaseLedgerStock.Note(offer, nest, renested.FirstStockNotKept); + return new ReleaseRenestResult(renested.Nested, Join(renested.Note, ledgerNote), + locations); + } + + /// + /// for a nest no one is looking at: every plate is removed, the + /// layouts are solved on the calling thread and attached as new plates. + /// + public static Task RunHeadlessAsync(Nest nest, + ReleaseLedgerStock ledger, INestingEngine engine, string engineName, + double minimumSalvageDimension, + CancellationToken token) + { + ArgumentNullException.ThrowIfNull(nest); + ArgumentNullException.ThrowIfNull(engine); + token.ThrowIfCancellationRequested(); + nest.Plates.Clear(); + nest.UpdateDrawingQuantities(); + return RunAsync(nest, ledger, engineName, minimumSalvageDimension, + request => Task.FromResult( + NestPipeline.Run(engine, engineName, request, null, token)), + (result, finish) => + { + var plates = NestPipelineCommit.ApplyToPlates(result, nest.CreatePlate, + token: token); + finish(plates); + return plates; + }, + token); + } + + /// Auto Nest's note, then what MaterialLedger stock the nest used. + public static string Join(string renestNote, string ledgerNote) => + string.Join(" ", new[] { renestNote, ledgerNote }.Where(n => !string.IsNullOrEmpty(n))); + } +} diff --git a/OpenNest.Engine/Jobs/NestRenest.cs b/OpenNest.Engine/Jobs/NestRenest.cs index aaf72214..cee769a4 100644 --- a/OpenNest.Engine/Jobs/NestRenest.cs +++ b/OpenNest.Engine/Jobs/NestRenest.cs @@ -24,6 +24,12 @@ public sealed record NestRenestResult( /// public IReadOnlyList<(Plate Plate, string StockId)> Committed { get; init; } = Array.Empty<(Plate Plate, string StockId)>(); + + /// + /// True when a staged run's first stock got a layout that was not valid or not keepable, + /// so none of it was used (). + /// + public bool FirstStockNotKept { get; init; } } /// @@ -57,16 +63,7 @@ public static class NestRenest /// public static NestRenestResult Run(Nest nest, INestingEngine engine, string engineName, 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) + CancellationToken token = default) { ArgumentNullException.ThrowIfNull(nest); ArgumentNullException.ThrowIfNull(engine); @@ -75,7 +72,7 @@ public static class NestRenest nest.Plates.Clear(); nest.UpdateDrawingQuantities(); // Solved synchronously: the returned task has already completed. - return RunStagedAsync(nest, engineName, firstStock, minimumSalvageDimension, + return RunStagedAsync(nest, engineName, null, minimumSalvageDimension, request => Task.FromResult( NestPipeline.Run(engine, engineName, request, progress, token)), kept => NestPipelineCommit.ApplyToPlates(kept, nest.CreatePlate, token: token), @@ -89,8 +86,11 @@ public static class NestRenest /// (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. + /// remaining, listed as unplaced when no later stage takes them. + /// runs a request; attaches a kept result's sheets to the nest's + /// plates on the nest's own thread and returns them. Attaching the parts counts them as + /// nested, which is how the next stage asks only for the rest. Each stage may use up to + /// sheets. /// public static async Task RunStagedAsync(Nest nest, string engineName, IReadOnlyList firstStock, double minimumSalvageDimension, @@ -106,6 +106,7 @@ public static class NestRenest var committed = new List<(Plate Plate, string StockId)>(); IReadOnlyList unplaced = Array.Empty(); var anyRequest = false; + var firstStockNotKept = false; var stages = firstStock is { Count: > 0 } ? new[] { firstStock, null } : new IReadOnlyList[] { null }; @@ -121,8 +122,15 @@ public static class NestRenest token.ThrowIfCancellationRequested(); unplaced = result.Unplaced; if (!IsKeepable(result)) + { + if (!result.IsValid || !result.CanKeep) + { + // Every copy this stage asked for is left, not just what its layout missed. + unplaced = NotKept(request); + firstStockNotKept |= stock != null; + } 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); @@ -132,8 +140,15 @@ public static class NestRenest 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 }; + return new NestRenestResult(false, 0, 0, unplaced, NotKeptNote) + { + FirstStockNotKept = firstStockNotKept, + }; + return Summarize(applied, unplaced) with + { + Committed = committed, + FirstStockNotKept = firstStockNotKept, + }; } /// @@ -197,6 +212,12 @@ public static class NestRenest .Select(p => (p.Plate, p.Stock.Id)) .ToList(); + private static IReadOnlyList NotKept(NestPipelineRequest request) => + request.Items + .Select(item => new NestUnplacedPart(item.Drawing.Name, item.Drawing, item.Quantity, + NestUnplacedReason.ProposalNotKeepable)) + .ToList(); + private static bool IsKeepable(NestPipelineResult result) => result.IsValid && result.CanKeep && result.Plates.Any(p => p.Parts.Count > 0); diff --git a/OpenNest.Tests/Data/LedgerCodeFilterTests.cs b/OpenNest.Tests/Data/LedgerCodeFilterTests.cs index 6a39d001..cb357956 100644 --- a/OpenNest.Tests/Data/LedgerCodeFilterTests.cs +++ b/OpenNest.Tests/Data/LedgerCodeFilterTests.cs @@ -142,6 +142,23 @@ public class LedgerCodeFilterTests internal static NestStockCodeUse Use(string code, string nest) => new() { ItemCode = code, NestId = Guid.NewGuid(), NestName = nest, Status = NestStatus.Quote }; + [Fact] + public void ExcludeCopies_TakesOnlyTheCopiesTaken_InLedgerOrder() + { + var bundle = Row("ledger:1", ["B-1", "B-1", "B-1"], ("B-1", "R1")); + var drops = Row("ledger:2", ["D-1", "D-2"], ("D-1", "R1"), ("D-2", "R2")); + + var taken = new Dictionary { ["B-1"] = 2, ["D-2"] = 1, ["UNRELATED"] = 1 }; + + var rows = LedgerCodeFilter.ExcludeCopies([bundle, drops], taken, out var removed); + + Assert.Equal(3, removed); + Assert.Equal((1, "B-1"), (rows[0].Quantity, Assert.Single(rows[0].ItemCodes))); + Assert.Equal("R1", rows[0].ItemLocations["B-1"]); + Assert.Equal(["D-1"], rows[1].ItemCodes); + Assert.Equal(["R1"], rows[1].Locations); + } + private static LedgerStockRow Row(string id, string[] codes, params (string Code, string Location)[] locations) => new(id, new Size(48, 96), codes.Length, IsDrop: codes.Distinct().Count() > 1, IsIrregular: false, codes, locations.Select(l => l.Location).Distinct().ToList()) diff --git a/OpenNest.Tests/Data/ReleaseLedgerStockTests.cs b/OpenNest.Tests/Data/ReleaseLedgerStockTests.cs deleted file mode 100644 index dd981f92..00000000 --- a/OpenNest.Tests/Data/ReleaseLedgerStockTests.cs +++ /dev/null @@ -1,173 +0,0 @@ -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); - } -} diff --git a/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs b/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs index 560f3f06..41ca9956 100644 --- a/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs +++ b/OpenNest.WinForms.Tests/Forms/MainFormReleaseNestingTests.cs @@ -111,8 +111,14 @@ public class MainFormReleaseNestingTests && 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. + // Each stage is its own Auto Nest step in the tab, and its codes are part of that step. Assert.Equal(2, tab.History.UndoCount); + tab.Undo(); + tab.Undo(); + tab.Redo(); + tab.Redo(); + var redone = Assert.Single(tab.Nest.Plates, p => p.StockItemCodes.Count > 0); + Assert.Equal("S-1", Assert.Single(redone.StockItemCodes)); host.End(null!); Application.DoEvents(); }, TestTimeout, "The live release ledger test did not complete."); diff --git a/OpenNest/Controls/ReleasePanel.cs b/OpenNest/Controls/ReleasePanel.cs index 233e5ea8..ab823875 100644 --- a/OpenNest/Controls/ReleasePanel.cs +++ b/OpenNest/Controls/ReleasePanel.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using System.Windows.Forms; using OpenNest.Data; using OpenNest.Data.MaterialLedger; +using OpenNest.Desktop; using OpenNest.Engine.BestFit; using OpenNest.Engine.Jobs; using OpenNest.Forms; @@ -1161,23 +1162,13 @@ namespace OpenNest.Controls var engineName = EngineSelection.EngineName; return Task.Run(async () => { - 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)); + var renested = await ReleaseRenest.RunHeadlessAsync(nest, ledger, + NestingEngineRegistry.Create(engineName), engineName, + AutoNestForm.DefaultMinRemnantSize, token); + return new SetReleaseRenest(renested.Nested, renested.Note); }, token); } - /// - /// The note a released nest shows: Auto Nest's, then what MaterialLedger stock it used. - /// - 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); diff --git a/OpenNest/Forms/MainForm.Release.cs b/OpenNest/Forms/MainForm.Release.cs index 1ac580f5..796d9bdc 100644 --- a/OpenNest/Forms/MainForm.Release.cs +++ b/OpenNest/Forms/MainForm.Release.cs @@ -7,6 +7,7 @@ using System.Windows.Forms; using OpenNest.Controls; using OpenNest.Data; using OpenNest.Data.MaterialLedger; +using OpenNest.Desktop; using OpenNest.Engine.Jobs; using OpenNest.Forms.Commands; @@ -54,26 +55,27 @@ namespace OpenNest.Forms if (tab.PlateManager.CanClearAll) tab.ClearNest(); nest.UpdateDrawingQuantities(); - 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. + // As for Auto Nest, switching tabs while it runs (or reads the ledger) stops it. nestingCts = cts; - NestRenestResult renested; + ReleaseRenestResult renested; try { - // Ledger stock first, then the plate defaults: each stage is its own Auto Nest run. + // Ledger stock first, then the plate defaults: each stage is its own Auto Nest + // run and undo step, with its ledger codes recorded in that step. var solve = SolveReleasedNest ?? ((t, r, c) => AutoNestCommand.SolveAsync(this, t, r, c)); - renested = await NestRenest.RunStagedAsync(nest, EngineSelection.EngineName, - offer?.Options, AutoNestForm.DefaultMinRemnantSize, + renested = await ReleaseRenest.RunAsync(nest, ledger, EngineSelection.EngineName, + AutoNestForm.DefaultMinRemnantSize, request => solve(tab, request, cts), - r => + (r, finish) => { using var change = tab.BeginChange("Auto Nest"); change.MarkChanged(); var applied = NestPipelineCommit.ApplyToEmptyPlates(r, tab.PlateManager, token: cts.Token); + finish(applied); change.Complete(); return applied; }, cts.Token); @@ -83,20 +85,15 @@ namespace OpenNest.Forms nestingCts = null; } - var ledgerNote = offer == null ? null : ledger.Assign(nest, offer, renested.Committed); - foreach (var row in offer?.Rows ?? Array.Empty()) - { - foreach (var (code, location) in row.ItemLocations) - tab.LedgerLocations[code] = location; - } + foreach (var (code, location) in renested.LedgerLocations) + tab.LedgerLocations[code] = location; if (renested.Nested) { tab.UpdatePlateList(); tab.PlateManager.LoadFirst(); } tab.PlateView.ZoomToFit(); - return new SetReleaseRenest(renested.Nested, - ReleasePanel.ReleaseNote(renested.Note, ledgerNote)); + return new SetReleaseRenest(renested.Nested, renested.Note); } void IReleaseNestingHost.End(SetReleaseResult result)