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);
+ }
+}