diff --git a/OpenNest.Core/Nest.cs b/OpenNest.Core/Nest.cs
index 0d3c9063..47b2c906 100644
--- a/OpenNest.Core/Nest.cs
+++ b/OpenNest.Core/Nest.cs
@@ -51,9 +51,51 @@ namespace OpenNest
public string MadeBy { get; set; } = "";
- public double Thickness { get; set; }
+ /// The stock thickness. A different value clears every plate's .
+ public double Thickness
+ {
+ get { return thickness; }
+ set
+ {
+ if (value != thickness)
+ ClearStockItemCodes();
+ thickness = value;
+ }
+ }
- public Material Material { get; set; }
+ private double thickness;
+
+ ///
+ /// The stock material. A different name or grade (ignoring case and spacing) clears every
+ /// plate's : the coded items are another material.
+ ///
+ public Material Material
+ {
+ get { return material; }
+ set
+ {
+ if (!SameStock(material, value))
+ ClearStockItemCodes();
+ material = value;
+ }
+ }
+
+ private Material material;
+
+ private static bool SameStock(Material a, Material b) =>
+ NameKey(a?.Name) == NameKey(b?.Name) && NameKey(a?.Grade) == NameKey(b?.Grade);
+
+ private static string NameKey(string name) =>
+ name == null ? "" : string.Join(' ', name.Split((char[])null, StringSplitOptions.RemoveEmptyEntries)).ToUpperInvariant();
+
+ private void ClearStockItemCodes()
+ {
+ // Plates is a public field a caller may have replaced.
+ if (Plates == null)
+ return;
+ foreach (var plate in Plates)
+ plate.StockItemCodes = Array.Empty();
+ }
public Units Units { get; set; }
diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs
index 8eca5685..ba93ec56 100644
--- a/OpenNest.Core/Plate.cs
+++ b/OpenNest.Core/Plate.cs
@@ -95,6 +95,9 @@ namespace OpenNest
get { return size; }
set
{
+ // A turned sheet is the same sheet; any other size is no longer the coded item.
+ if (stockItemCodes.Count > 0 && !SameSheet(size, value))
+ stockItemCodes = Array.Empty();
size = value;
ResolveClampRail();
}
@@ -102,6 +105,30 @@ namespace OpenNest
private Size size;
+ private static bool SameSheet(Size a, Size b) =>
+ a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
+
+ ///
+ /// The inventory item each physical sheet of this plate is cut from: entry i names sheet
+ /// i of , and sheets past the end are uncoded. Empty when no sheet
+ /// is coded. A resize (other than turning the sheet) clears it, lowering
+ /// truncates it, and raising it leaves the extra sheets uncoded.
+ /// Display and hand-off data only: quantity accounting never reads it.
+ ///
+ public IReadOnlyList StockItemCodes
+ {
+ get { return stockItemCodes; }
+ set
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ if (value.Any(string.IsNullOrWhiteSpace))
+ throw new ArgumentException("Stock item codes must not be blank.", nameof(value));
+ stockItemCodes = value.Take(System.Math.Max(quantity, 0)).ToArray();
+ }
+ }
+
+ private IReadOnlyList stockItemCodes = Array.Empty();
+
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
///
@@ -457,9 +484,21 @@ namespace OpenNest
}
///
- /// The number of times to cut the plate.
+ /// The number of times to cut the plate. Lowering it drops the
+ /// of the sheets no longer cut.
///
- public int Quantity { get; set; }
+ public int Quantity
+ {
+ get { return quantity; }
+ set
+ {
+ quantity = value;
+ if (stockItemCodes.Count > System.Math.Max(value, 0))
+ stockItemCodes = stockItemCodes.Take(System.Math.Max(value, 0)).ToArray();
+ }
+ }
+
+ private int quantity;
///
/// The quadrant the plate is located in.
diff --git a/OpenNest.Core/PlateManager.cs b/OpenNest.Core/PlateManager.cs
index 17d0ebae..92bca444 100644
--- a/OpenNest.Core/PlateManager.cs
+++ b/OpenNest.Core/PlateManager.cs
@@ -236,6 +236,7 @@ namespace OpenNest
remaining.CutOffs.Clear();
remaining.Parts.Clear();
remaining.Quantity = 1;
+ remaining.StockItemCodes = Array.Empty();
}
finally
{
diff --git a/OpenNest.Data/MaterialLedger/LedgerStockAssignment.cs b/OpenNest.Data/MaterialLedger/LedgerStockAssignment.cs
new file mode 100644
index 00000000..d87fad03
--- /dev/null
+++ b/OpenNest.Data/MaterialLedger/LedgerStockAssignment.cs
@@ -0,0 +1,84 @@
+namespace OpenNest.Data.MaterialLedger;
+
+///
+/// Gives the plates Auto Nest committed on ledger stock their item codes. Runs only after the
+/// proposal was validated, kept and committed, so a rejected or cancelled one never changes
+/// codes. Codes are hand-off data for MaterialLedger holds, not inventory accounting.
+///
+public static class LedgerStockAssignment
+{
+ // ClampRemovalRecovery solves a sheet with clamps removed as "{stock id}::removed:{ids}".
+ private const string VariantSeparator = "::";
+
+ /// The nest's plates; codes are dealt out in this order.
+ /// Each plate the commit attached, with the engine stock id it was solved on.
+ /// The ledger rows offered to the run.
+ ///
+ /// Each row's codes are a queue: a plate takes the next codes of
+ /// its row (a bundle repeats its one code). A plate whose row runs out keeps the rest of its
+ /// sheets uncoded. A committed plate on hand-typed stock has no codes.
+ ///
+ public static void Assign(IList nestPlates, IEnumerable<(Plate Plate, string StockId)> committed,
+ IEnumerable rows)
+ {
+ ArgumentNullException.ThrowIfNull(nestPlates);
+ ArgumentNullException.ThrowIfNull(committed);
+ ArgumentNullException.ThrowIfNull(rows);
+ var queues = new Dictionary>(StringComparer.Ordinal);
+ foreach (var row in rows)
+ queues.TryAdd(row.Id, new Queue(row.ItemCodes));
+
+ foreach (var (plate, stockId) in committed.OrderBy(c => IndexOf(nestPlates, c.Plate)))
+ {
+ var codes = new List();
+ if (stockId != null && queues.TryGetValue(BaseStockId(stockId), out var queue))
+ {
+ while (codes.Count < plate.Quantity && queue.Count > 0)
+ codes.Add(queue.Dequeue());
+ }
+ plate.StockItemCodes = codes;
+ }
+ }
+
+ /// The offered stock id a solved sheet came from, without a clamp-removal suffix.
+ public static string BaseStockId(string stockId)
+ {
+ ArgumentNullException.ThrowIfNull(stockId);
+ var separator = stockId.IndexOf(VariantSeparator, StringComparison.Ordinal);
+ return separator < 0 ? stockId : stockId[..separator];
+ }
+
+ ///
+ /// "Stock: DROP-1182 (B3)" for display, with runs of one code counted ("SHEET-456 (A1) x3")
+ /// and uncoded sheets noted; null when no sheet is coded. Locations are display-only.
+ ///
+ public static string? Describe(Plate plate, IReadOnlyDictionary? locations)
+ {
+ ArgumentNullException.ThrowIfNull(plate);
+ var codes = plate.StockItemCodes;
+ if (codes.Count == 0)
+ return null;
+ var parts = new List();
+ for (var i = 0; i < codes.Count;)
+ {
+ var run = 1;
+ while (i + run < codes.Count && codes[i + run] == codes[i])
+ run++;
+ var text = locations != null && locations.TryGetValue(codes[i], out var location)
+ ? $"{codes[i]} ({location})"
+ : codes[i];
+ parts.Add(run > 1 ? $"{text} x{run}" : text);
+ i += run;
+ }
+ var uncoded = plate.Quantity - codes.Count;
+ if (uncoded > 0)
+ parts.Add($"{uncoded} uncoded");
+ return "Stock: " + string.Join(", ", parts);
+ }
+
+ private static int IndexOf(IList plates, Plate plate)
+ {
+ var index = plates.IndexOf(plate);
+ return index < 0 ? int.MaxValue : index;
+ }
+}
diff --git a/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs b/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs
index 69e79e7d..a8eeb983 100644
--- a/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs
+++ b/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs
@@ -15,7 +15,12 @@ public sealed record LedgerStockRow(
bool IsDrop,
bool IsIrregular,
IReadOnlyList ItemCodes,
- IReadOnlyList Locations);
+ IReadOnlyList Locations)
+{
+ /// Each item code's ledger location code, for display only.
+ public IReadOnlyDictionary ItemLocations { get; init; } =
+ new Dictionary(StringComparer.Ordinal);
+}
/// The rows offered to Auto Nest and the counts of ledger items left out.
public sealed record LedgerStockMapping(
@@ -135,8 +140,17 @@ public static class LedgerStockMapper
var codes = ordered.SelectMany(i => Enumerable.Repeat(i.ItemCode, i.Quantity)).ToList();
var locations = ordered.Select(i => i.LocationCode).Where(code => !string.IsNullOrEmpty(code))
.Distinct(StringComparer.Ordinal).ToList();
+ var itemLocations = new Dictionary(StringComparer.Ordinal);
+ foreach (var item in ordered)
+ {
+ if (!string.IsNullOrEmpty(item.LocationCode))
+ itemLocations.TryAdd(item.ItemCode, item.LocationCode);
+ }
rows.Add(new LedgerStockRow($"{IdPrefix}{rows.Count + 1}", entry.Size, codes.Count,
- entry.IsDrop, entry.IsIrregular, codes, locations));
+ entry.IsDrop, entry.IsIrregular, codes, locations)
+ {
+ ItemLocations = itemLocations,
+ });
}
return new LedgerStockMapping(rows, unknown, tooSmall);
}
diff --git a/OpenNest.Data/NestSheets.cs b/OpenNest.Data/NestSheets.cs
index 0987df86..62cab5ab 100644
--- a/OpenNest.Data/NestSheets.cs
+++ b/OpenNest.Data/NestSheets.cs
@@ -15,6 +15,31 @@ public sealed class NestSheets
/// One entry per drawing, including drawings with nothing nested, ordered by name.
public IReadOnlyList Parts { get; init; } = Array.Empty();
+
+ /// One entry per plate in nest order, the same plates groups.
+ public IReadOnlyList Plates { get; init; } = Array.Empty();
+}
+
+///
+/// One plate of the nest: its size as stored with the shorter side, how many
+/// times it is cut, and the inventory item each of those sheets is cut from.
+///
+public sealed class NestPlateSheet
+{
+ /// The plate's 0-based position in the nest.
+ public int Index { get; init; }
+
+ public double Width { get; init; }
+
+ public double Length { get; init; }
+
+ public int Quantity { get; init; }
+
+ ///
+ /// Entry i is the inventory item code of sheet i; sheets past the end are uncoded, and a
+ /// bundle used for several sheets repeats its code. Empty when no sheet is coded.
+ ///
+ public IReadOnlyList StockItemCodes { get; init; } = Array.Empty();
}
/// A sheet size as stored, with the shorter side.
diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs
index e9be6eb9..3cbfcab1 100644
--- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs
+++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs
@@ -49,6 +49,24 @@ public static class NestPipelineCommit
return Attach(result, nextPlate);
}
+ ///
+ /// Pairs the plates or returned
+ /// for with the stock each was solved on: one per proposed plate
+ /// with parts, in proposal order.
+ ///
+ public static IReadOnlyList<(Plate Plate, NestPlateStock Stock)> PairWithStock(
+ NestPipelineResult result,
+ IReadOnlyList applied
+ )
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ ArgumentNullException.ThrowIfNull(applied);
+ var solved = result.Plates.Where(p => p.Parts.Count > 0).ToList();
+ if (solved.Count != applied.Count)
+ throw new ArgumentException("The plates were not committed from this result.", nameof(applied));
+ return applied.Zip(solved, (plate, proposed) => (plate, proposed.Stock)).ToList();
+ }
+
private static void EnsureCommittable(NestPipelineResult result, bool allowInvalid, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(result);
diff --git a/OpenNest.IO.Tests/StockItemCodesPersistenceTests.cs b/OpenNest.IO.Tests/StockItemCodesPersistenceTests.cs
new file mode 100644
index 00000000..4ffc6a6f
--- /dev/null
+++ b/OpenNest.IO.Tests/StockItemCodesPersistenceTests.cs
@@ -0,0 +1,79 @@
+using System.Text.Json.Nodes;
+using OpenNest.Geometry;
+using OpenNest.Shapes;
+using static OpenNest.IO.Tests.NestArchiveJson;
+
+namespace OpenNest.IO.Tests;
+
+///
+/// A plate's stock item codes survive the .nest round trip as an optional
+/// stockItemCodes array; files without it load with no codes.
+///
+public class StockItemCodesPersistenceTests
+{
+ private static Nest NestWithPlate(int quantity, params string[] codes)
+ {
+ var nest = new Nest("coded") { Material = new Material("Mild Steel", "A36"), Thickness = 0.25 };
+ var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing();
+ nest.Drawings.Add(drawing);
+ var plate = new Plate(30, 52.5) { Quantity = quantity };
+ plate.Parts.Add(new Part(drawing, new Vector(1, 1)));
+ nest.Plates.Add(plate);
+ plate.StockItemCodes = codes;
+ return nest;
+ }
+
+ private static byte[] Write(Nest nest)
+ {
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ return stream.ToArray();
+ }
+
+ [Fact]
+ public void CodesRoundTripInSheetOrder()
+ {
+ var (reopened, _) = RoundTrip(Write(NestWithPlate(3, "SHEET-456", "SHEET-456")));
+
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Equal(3, plate.Quantity);
+ Assert.Equal(["SHEET-456", "SHEET-456"], plate.StockItemCodes);
+ Assert.Equal("Mild Steel", reopened.Material.Name);
+ }
+
+ [Fact]
+ public void UncodedPlate_WritesNoKey()
+ {
+ var json = NestJson(Write(NestWithPlate(1)));
+
+ Assert.False(json["plates"]![0]!.AsObject().ContainsKey("stockItemCodes"));
+ }
+
+ [Fact]
+ public void OldFileWithoutTheKey_LoadsWithNoCodes()
+ {
+ var archive = Write(NestWithPlate(1, "DROP-1182"));
+ var json = NestJson(archive);
+ Assert.True(json["plates"]![0]!.AsObject().Remove("stockItemCodes"));
+
+ var (reopened, _) = RoundTrip(ReplaceNestJson(archive, json));
+
+ Assert.Empty(Assert.Single(reopened.Plates).StockItemCodes);
+ }
+
+ [Theory]
+ [InlineData("\"DROP-1\"", new string[0])]
+ [InlineData("[\"DROP-1\", 7, \"DROP-2\"]", new[] { "DROP-1", "DROP-2" })]
+ [InlineData("[\"DROP-1\", \"\", \"DROP-2\"]", new[] { "DROP-1" })]
+ [InlineData("[\"A\", \"B\", \"C\", \"D\"]", new[] { "A", "B", "C" })]
+ public void MalformedCodes_ReadLeniently(string value, string[] expected)
+ {
+ var archive = Write(NestWithPlate(3, "X"));
+ var json = NestJson(archive);
+ json["plates"]![0]!["stockItemCodes"] = JsonNode.Parse(value);
+
+ var (reopened, _) = RoundTrip(ReplaceNestJson(archive, json));
+
+ Assert.Equal(expected, Assert.Single(reopened.Plates).StockItemCodes);
+ }
+}
diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs
index 59446ffc..f8088d6f 100644
--- a/OpenNest.IO/NestFormat.cs
+++ b/OpenNest.IO/NestFormat.cs
@@ -649,6 +649,15 @@ namespace OpenNest.IO
[JsonConverter(typeof(LenientStringListConverter))]
public List RemovedClamps { get; init; } = new();
+ ///
+ /// The inventory item code of each physical sheet, in sheet order; sheets past the
+ /// end are uncoded. Omitted when no sheet is coded; missing means none. Read
+ /// leniently: a wrongly shaped value reads as none, and non-string entries are skipped.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonConverter(typeof(LenientStringListConverter))]
+ public List StockItemCodes { get; init; } = new();
+
///
/// The machine clamp rail this sheet follows, written only when it is not the
/// nest's default rail (otherwise the sheet re-attaches to that one on load).
diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs
index 5d954e1e..8d5c66e0 100644
--- a/OpenNest.IO/NestReader.cs
+++ b/OpenNest.IO/NestReader.cs
@@ -639,6 +639,10 @@ namespace OpenNest.IO
if (plateRail != null && plateRail.Matches(plate, removedClamps))
plate.SetClampRail(plateRail, removedClamps);
+ // Codes name sheets in order, so a blank entry ends the coded sheets.
+ if (p.StockItemCodes != null)
+ plate.StockItemCodes = p.StockItemCodes.TakeWhile(code => !string.IsNullOrWhiteSpace(code)).ToList();
+
for (var partIndex = 0; partIndex < p.Parts.Count; partIndex++)
{
var partDto = p.Parts[partIndex];
diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs
index 2d2ff045..af97b9d7 100644
--- a/OpenNest.IO/NestWriter.cs
+++ b/OpenNest.IO/NestWriter.cs
@@ -448,6 +448,7 @@ namespace OpenNest.IO
ClampRail = Equals(plate.ClampRail, nest.PlateDefaults.ClampRail)
? null
: ToDto(plate.ClampRail),
+ StockItemCodes = plate.StockItemCodes.Count == 0 ? null : plate.StockItemCodes.ToList(),
}
);
}
diff --git a/OpenNest.Server.Tests/NestSheetsTests.cs b/OpenNest.Server.Tests/NestSheetsTests.cs
index 1d29f3be..4321edac 100644
--- a/OpenNest.Server.Tests/NestSheetsTests.cs
+++ b/OpenNest.Server.Tests/NestSheetsTests.cs
@@ -114,6 +114,75 @@ public sealed class NestSheetsTests
Assert.Equal([("Kept", 2, 2)], Parts(sheets));
}
+ [Fact]
+ public async Task Plates_ListEachPlateWithItsStockItemCodes()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var nest = NestApiTests.SyntheticNest();
+ var drawing = nest.Drawings.Single();
+ nest.Plates.Single().StockItemCodes = ["DROP-1182"];
+ // Stored rotated: width is still the short side. Two of three sheets are coded.
+ var bundle = new Plate(new Size(120, 60)) { Quantity = 3 };
+ bundle.Parts.Add(new Part(drawing, new Vector(2, 3)));
+ bundle.StockItemCodes = ["SHEET-456", "SHEET-456"];
+ nest.Plates.Add(bundle);
+ var uncoded = new Plate(new Size(48, 96)) { Quantity = 1 };
+ uncoded.Parts.Add(new Part(drawing, new Vector(2, 3)));
+ nest.Plates.Add(uncoded);
+ nest.Plates.Add(new Plate(new Size(48, 96)) { Quantity = 1 });
+
+ var sheets = await Sheets(client, nest);
+
+ Assert.Equal(
+ [
+ (0, 20.0, 30.0, 1, "DROP-1182"),
+ (1, 60.0, 120.0, 3, "SHEET-456,SHEET-456"),
+ (2, 48.0, 96.0, 1, ""),
+ ],
+ Plates(sheets));
+ Assert.Equal([(20.0, 30.0, 1), (48.0, 96.0, 1), (60.0, 120.0, 3)], Sizes(sheets));
+ }
+
+ [Fact]
+ public async Task Plates_ReadStockItemCodesAsNestReaderDoes()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var archive = TestNests.Zip("nest.json", """
+ {"drawings":[{"id":1,"name":"Kept","quantity":{"required":2}}],
+ "plates":[{"id":1,"size":{"width":48,"length":96},"quantity":2,"parts":[{"drawingId":1}],
+ "stockItemCodes":["A",7,"B","C"]},
+ {"id":2,"size":{"width":30,"length":40},"quantity":2,"parts":[{"drawingId":1}],
+ "stockItemCodes":["D","","E"]},
+ {"id":3,"size":{"width":30,"length":40},"quantity":1,"parts":[{"drawingId":1}],
+ "stockItemCodes":{"bad":true}}]}
+ """);
+ var id = await Create(client, archive);
+
+ var sheets = (await client.GetFromJsonAsync($"/api/nests/{id}/sheets", Options))!;
+
+ Assert.Equal([(0, 48.0, 96.0, 2, "A,B"), (1, 30.0, 40.0, 2, "D"), (2, 30.0, 40.0, 1, "")], Plates(sheets));
+ }
+
+ [Fact]
+ public async Task Plates_SerializeAsTheDocumentedShape()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var nest = NestApiTests.SyntheticNest();
+ nest.Plates.Single().StockItemCodes = ["DROP-1182"];
+ var id = await Create(client, nest);
+
+ using var document = JsonDocument.Parse(await client.GetStringAsync($"/api/nests/{id}/sheets"));
+
+ var plate = Assert.Single(document.RootElement.GetProperty("plates").EnumerateArray());
+ Assert.Equal(
+ ["index", "width", "length", "quantity", "stockItemCodes"],
+ plate.EnumerateObject().Select(p => p.Name).ToArray());
+ Assert.Equal("DROP-1182", Assert.Single(plate.GetProperty("stockItemCodes").EnumerateArray()).GetString());
+ }
+
[Fact]
public async Task UnknownNest_Returns404()
{
@@ -128,6 +197,9 @@ public sealed class NestSheetsTests
private static (double, double, int)[] Sizes(NestSheets sheets) =>
sheets.Sheets.Select(s => (s.Width, s.Length, s.Quantity)).ToArray();
+ private static (int, double, double, int, string)[] Plates(NestSheets sheets) =>
+ sheets.Plates.Select(p => (p.Index, p.Width, p.Length, p.Quantity, string.Join(",", p.StockItemCodes))).ToArray();
+
private static (string, int, int)[] Parts(NestSheets sheets) =>
sheets.Parts.Select(p => (p.Name, p.Required, p.Nested)).ToArray();
diff --git a/OpenNest.Server/NestArchive.cs b/OpenNest.Server/NestArchive.cs
index a6639d8b..a7892897 100644
--- a/OpenNest.Server/NestArchive.cs
+++ b/OpenNest.Server/NestArchive.cs
@@ -1,5 +1,6 @@
using System.IO.Compression;
using System.Text.Json;
+using System.Text.Json.Serialization;
using OpenNest.Data;
namespace OpenNest.Server;
@@ -85,7 +86,33 @@ internal static class NestArchive
.OrderBy(p => p.Name, StringComparer.Ordinal)
.ToList();
- sheets = new NestSheets { NestId = record.Id, Revision = record.Revision, Sheets = sizes, Parts = parts };
+ // NestReader: codes end at the first blank entry and at the plate's quantity.
+ var platesOut = plates
+ .Select((p, index) =>
+ {
+ var size = p.Size ?? new SizeJson();
+ return new NestPlateSheet
+ {
+ Index = index,
+ Width = System.Math.Min(size.Width, size.Length),
+ Length = System.Math.Max(size.Width, size.Length),
+ Quantity = p.Quantity,
+ StockItemCodes = (p.StockItemCodes ?? new())
+ .TakeWhile(code => !string.IsNullOrWhiteSpace(code))
+ .Take(System.Math.Max(p.Quantity, 0))
+ .ToList(),
+ };
+ })
+ .ToList();
+
+ sheets = new NestSheets
+ {
+ NestId = record.Id,
+ Revision = record.Revision,
+ Sheets = sizes,
+ Parts = parts,
+ Plates = platesOut,
+ };
return null;
}
@@ -187,6 +214,30 @@ internal static class NestArchive
public int Quantity { get; set; } = 1;
public List? Parts { get; set; }
public List? CutOffs { get; set; }
+
+ [JsonConverter(typeof(LenientStringListJson))]
+ public List? StockItemCodes { get; set; }
+ }
+
+ // NestReader's lenient list: a wrongly shaped value reads as empty, non-strings are skipped.
+ private sealed class LenientStringListJson : JsonConverter>
+ {
+ public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ var list = new List();
+ if (document.RootElement.ValueKind != JsonValueKind.Array)
+ return list;
+ foreach (var element in document.RootElement.EnumerateArray())
+ {
+ if (element.ValueKind == JsonValueKind.String)
+ list.Add(element.GetString()!);
+ }
+ return list;
+ }
+
+ public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) =>
+ JsonSerializer.Serialize(writer, value);
}
private sealed class SizeJson
diff --git a/OpenNest.Tests/Data/LedgerStockAssignmentTests.cs b/OpenNest.Tests/Data/LedgerStockAssignmentTests.cs
new file mode 100644
index 00000000..a9950794
--- /dev/null
+++ b/OpenNest.Tests/Data/LedgerStockAssignmentTests.cs
@@ -0,0 +1,169 @@
+using OpenNest.Data.MaterialLedger;
+using OpenNest.Engine;
+using OpenNest.Engine.Jobs;
+using OpenNest.Geometry;
+using OpenNest.Shapes;
+
+namespace OpenNest.Tests.Data;
+
+///
+/// After Auto Nest commits, each plate on ledger stock takes the next codes of its row in nest
+/// order; nothing else touches codes, so a rejected proposal leaves them as they were.
+///
+public class LedgerStockAssignmentTests
+{
+ private static LedgerStockRow Row(string id, double width, double length, params string[] codes) =>
+ new(id, new Size(width, length), codes.Length, IsDrop: codes.Distinct().Count() > 1, IsIrregular: false,
+ codes, Array.Empty());
+
+ [Fact]
+ public void Plates_DequeueTheirRowsCodes_InNestOrder_AndBundlesRepeatTheirCode()
+ {
+ var drops = Row("ledger:1", 30, 52.5, "DROP-1182", "DROP-1190", "DROP-1201");
+ var bundle = Row("ledger:2", 60, 120, "SHEET-456", "SHEET-456", "SHEET-456");
+ var nest = new Nest();
+ var first = Add(nest, 1);
+ var second = Add(nest, 2);
+ var third = Add(nest, 2);
+ var hand = Add(nest, 1);
+ hand.StockItemCodes = ["STALE"];
+
+ // Committed out of nest order: codes still follow Nest.Plates.
+ LedgerStockAssignment.Assign(nest.Plates,
+ [(third, "ledger:2"), (first, "ledger:1"), (second, "ledger:1"), (hand, "option-0")],
+ [drops, bundle]);
+
+ Assert.Equal(["DROP-1182"], first.StockItemCodes);
+ Assert.Equal(["DROP-1190", "DROP-1201"], second.StockItemCodes);
+ Assert.Equal(["SHEET-456", "SHEET-456"], third.StockItemCodes);
+ Assert.Empty(hand.StockItemCodes);
+ }
+
+ [Fact]
+ public void ARowThatRunsOut_LeavesTheRemainingSheetsUncoded()
+ {
+ var nest = new Nest();
+ var first = Add(nest, 2);
+ var second = Add(nest, 1);
+
+ LedgerStockAssignment.Assign(nest.Plates, [(first, "ledger:1"), (second, "ledger:1")],
+ [Row("ledger:1", 30, 52.5, "DROP-1")]);
+
+ Assert.Equal(["DROP-1"], first.StockItemCodes);
+ Assert.Empty(second.StockItemCodes);
+ }
+
+ [Fact]
+ public void ASheetSolvedWithClampsRemoved_TakesItsBaseRowsCodes()
+ {
+ var nest = new Nest();
+ var plate = Add(nest, 1);
+
+ LedgerStockAssignment.Assign(nest.Plates, [(plate, "ledger:3::removed:clamp-2")],
+ [Row("ledger:3", 48, 96, "SHEET-9")]);
+
+ Assert.Equal(["SHEET-9"], plate.StockItemCodes);
+ Assert.Equal("ledger:3", LedgerStockAssignment.BaseStockId("ledger:3::removed:clamp-2,clamp-3"));
+ }
+
+ [Fact]
+ public void CommittedProposal_CodesThePlatesItWasSolvedOn()
+ {
+ var (nest, drawing, manager) = Workspace();
+ using (manager)
+ {
+ var rows = new[] { Row("ledger:1", 30, 52.5, "DROP-1", "DROP-2") };
+ var result = Solve(drawing, overlap: false);
+ Assert.True(result.IsValid, string.Join("; ", result.Violations));
+
+ var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
+ LedgerStockAssignment.Assign(nest.Plates,
+ NestPipelineCommit.PairWithStock(result, applied).Select(p => (p.Plate, p.Stock.Id)), rows);
+
+ Assert.Equal(["DROP-1"], Assert.Single(applied).StockItemCodes);
+ }
+ }
+
+ [Fact]
+ public void RejectedProposal_LeavesCodesUntouched()
+ {
+ var (nest, drawing, manager) = Workspace();
+ using (manager)
+ {
+ var empty = nest.Plates.Single();
+ empty.StockItemCodes = ["SHEET-OLD"];
+ var result = Solve(drawing, overlap: true);
+ Assert.False(result.IsValid);
+
+ // Auto Nest assigns only after a commit; without consent there is none.
+ Assert.Throws(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager));
+
+ Assert.Equal(["SHEET-OLD"], empty.StockItemCodes);
+ Assert.Empty(empty.Parts);
+ }
+ }
+
+ [Fact]
+ public void Describe_NamesEachCodeWithItsLocation_CountsRuns_AndNotesUncodedSheets()
+ {
+ var plate = new Plate(60, 120) { Quantity = 4 };
+ Assert.Null(LedgerStockAssignment.Describe(plate, null));
+
+ plate.StockItemCodes = ["SHEET-456", "SHEET-456", "DROP-1182"];
+ var locations = new Dictionary { ["SHEET-456"] = "A1" };
+
+ Assert.Equal("Stock: SHEET-456 (A1) x2, DROP-1182, 1 uncoded", LedgerStockAssignment.Describe(plate, locations));
+ }
+
+ [Fact]
+ public void Mapper_KeepsEachItemsLocationForDisplay()
+ {
+ var mapping = LedgerStockMapper.Map(
+ [
+ new LedgerStockItem("D-1", 1, LedgerItemKinds.Drop, 24, 36, 1, LedgerItemShapes.Rectangular,
+ "R1", "Rack 1", null, null, DateTime.UtcNow),
+ new LedgerStockItem("D-2", 1, LedgerItemKinds.Drop, 24, 36, 1, LedgerItemShapes.Rectangular,
+ "R2", "Rack 2", null, null, DateTime.UtcNow),
+ ], Units.Inches);
+
+ var row = Assert.Single(mapping.Rows);
+ Assert.Equal("R1", row.ItemLocations["D-1"]);
+ Assert.Equal("R2", row.ItemLocations["D-2"]);
+ }
+
+ private static Plate Add(Nest nest, int quantity)
+ {
+ var plate = new Plate(30, 52.5) { Quantity = quantity };
+ nest.Plates.Add(plate);
+ return plate;
+ }
+
+ private static (Nest Nest, Drawing Drawing, PlateManager Manager) Workspace()
+ {
+ var nest = new Nest();
+ var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing();
+ nest.Drawings.Add(drawing);
+ nest.CreatePlate();
+ return (nest, drawing, new PlateManager(nest));
+ }
+
+ private static NestPipelineResult Solve(Drawing drawing, bool overlap) =>
+ NestPipeline.Run(new OnFirstStock(overlap), "stub", new NestPipelineRequest("stub",
+ [new NestItem { Drawing = drawing, Quantity = 2 }],
+ [new NestPlateStock("ledger:1", new Size(30, 52.5), 2, 0.25, new Spacing(1, 1), 1)]));
+
+ // Two copies on one sheet of the first stock row; overlapping when asked, so validation fails.
+ private sealed class OnFirstStock(bool overlap) : INestingEngine
+ {
+ public NestJobResult Solve(NestJob job, IProgress progress = null,
+ CancellationToken token = default) => new(
+ NestJobStatus.Complete, NestJobStopReason.Completed,
+ [new NestJobPlateResult(0, job.Plates[0],
+ [
+ new NestJobPlacement(job.Parts[0].Id, 0, 2, 2, 0),
+ new NestJobPlacement(job.Parts[0].Id, 1, overlap ? 2 : 20, 2, 0),
+ ])],
+ [new PartFulfillment(job.Parts[0].Id, 2, 2, 0)],
+ [new StockUsage(job.Plates[0].Id, 1, 1)]);
+ }
+}
diff --git a/OpenNest.Tests/Engine/NestRenestTests.cs b/OpenNest.Tests/Engine/NestRenestTests.cs
index 936b5325..2d9a5243 100644
--- a/OpenNest.Tests/Engine/NestRenestTests.cs
+++ b/OpenNest.Tests/Engine/NestRenestTests.cs
@@ -42,6 +42,20 @@ public class NestRenestTests
Assert.StartsWith("Nested on 1 sheet, ", result.Note);
}
+ [Fact]
+ public void Run_DropsTheOldPlatesStockItemCodes()
+ {
+ var nest = NestOf(("SYN-A", 4, 2, 2));
+ var old = nest.CreatePlate();
+ old.Parts.Add(new Part(nest.Drawings.First()));
+ old.StockItemCodes = ["DROP-1182"];
+
+ var result = NestRenest.Run(nest, "Rectangles");
+
+ Assert.True(result.Nested, result.Note);
+ Assert.All(nest.Plates, p => Assert.Empty(p.StockItemCodes));
+ }
+
[Fact]
public void Run_LeavesCopiesThatDoNotFit_AsRemainingQuantity_AndListsThem()
{
diff --git a/OpenNest.Tests/PlateStockItemCodesTests.cs b/OpenNest.Tests/PlateStockItemCodesTests.cs
new file mode 100644
index 00000000..6edd6065
--- /dev/null
+++ b/OpenNest.Tests/PlateStockItemCodesTests.cs
@@ -0,0 +1,136 @@
+using OpenNest.Geometry;
+using OpenNest.Shapes;
+
+namespace OpenNest.Tests;
+
+///
+/// name the inventory item of each physical sheet, and Core
+/// drops them as soon as they stop describing the sheet.
+///
+public class PlateStockItemCodesTests
+{
+ private static Plate Coded(int quantity, params string[] codes)
+ {
+ var plate = new Plate(30, 52.5) { Quantity = quantity };
+ plate.StockItemCodes = codes;
+ return plate;
+ }
+
+ [Fact]
+ public void NewPlate_HasNoCodes()
+ {
+ Assert.Empty(new Plate().StockItemCodes);
+ Assert.Empty(new Nest().CreatePlate().StockItemCodes);
+ }
+
+ [Fact]
+ public void Resize_ClearsCodes_ButTurningTheSheetKeepsThem()
+ {
+ var plate = Coded(1, "DROP-1182");
+
+ plate.Size = new Size(52.5, 30);
+ Assert.Equal(["DROP-1182"], plate.StockItemCodes);
+ plate.Rotate90(RotationType.CW);
+ Assert.Equal(["DROP-1182"], plate.StockItemCodes);
+
+ plate.Size = new Size(30, 60);
+ Assert.Empty(plate.StockItemCodes);
+ }
+
+ [Fact]
+ public void LowerQuantity_TruncatesCodes_AndHigherQuantityLeavesTheExtraSheetsUncoded()
+ {
+ var plate = Coded(3, "SHEET-456", "SHEET-456", "SHEET-789");
+
+ plate.Quantity = 2;
+ Assert.Equal(["SHEET-456", "SHEET-456"], plate.StockItemCodes);
+
+ plate.Quantity = 5;
+ Assert.Equal(["SHEET-456", "SHEET-456"], plate.StockItemCodes);
+
+ plate.Quantity = 0;
+ Assert.Empty(plate.StockItemCodes);
+ }
+
+ [Fact]
+ public void Codes_BeyondQuantityAreDropped_AndBlankCodesAreRefused()
+ {
+ Assert.Equal(["A"], Coded(1, "A", "B").StockItemCodes);
+ Assert.Throws(() => Coded(2, "A", " "));
+ Assert.Throws(() => new Plate().StockItemCodes = null);
+ }
+
+ [Fact]
+ public void NestMaterialOrThicknessChange_ClearsEveryPlatesCodes()
+ {
+ var nest = new Nest { Material = new Material("Mild Steel", "A36"), Thickness = 0.25 };
+ var first = Coded(1, "DROP-1");
+ var second = Coded(2, "SHEET-2", "SHEET-2");
+ nest.Plates.Add(first);
+ nest.Plates.Add(second);
+
+ // Same material written differently, and the same thickness, are the same stock.
+ nest.Material = new Material(" mild steel ", "a36", 0.284);
+ nest.Thickness = 0.25;
+ Assert.Equal(["DROP-1"], first.StockItemCodes);
+ Assert.Equal(["SHEET-2", "SHEET-2"], second.StockItemCodes);
+
+ nest.Material = new Material("Mild Steel", "A572");
+ Assert.Empty(first.StockItemCodes);
+ Assert.Empty(second.StockItemCodes);
+
+ first.StockItemCodes = ["DROP-1"];
+ nest.Thickness = 0.1875;
+ Assert.Empty(first.StockItemCodes);
+
+ first.StockItemCodes = ["DROP-1"];
+ nest.Material = new Material("Stainless");
+ Assert.Empty(first.StockItemCodes);
+ }
+
+ [Fact]
+ public void PlateFromAnExistingPlate_CopiesNoCodes()
+ {
+ var nest = new Nest();
+ var coded = Coded(1, "DROP-1182");
+ nest.Plates.Add(coded);
+
+ nest.PlateDefaults.SetFromExisting(coded);
+ var copy = nest.CreatePlate();
+
+ Assert.Equal(coded.Size, copy.Size);
+ Assert.Empty(copy.StockItemCodes);
+ }
+
+ [Fact]
+ public void ClearAll_DropsTheRemainingPlatesCodes()
+ {
+ var nest = new Nest();
+ var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing();
+ nest.Drawings.Add(drawing);
+ var plate = Coded(2, "SHEET-1", "SHEET-1");
+ plate.Parts.Add(new Part(drawing));
+ nest.Plates.Add(plate);
+ using var manager = new PlateManager(nest);
+
+ manager.ClearAll();
+
+ Assert.Empty(nest.Plates[0].StockItemCodes);
+ }
+
+ [Fact]
+ public void Codes_AreNotQuantityAccounting()
+ {
+ var nest = new Nest();
+ var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing();
+ nest.Drawings.Add(drawing);
+ var plate = new Plate(48, 96) { Quantity = 2 };
+ nest.Plates.Add(plate);
+ plate.Parts.Add(new Part(drawing));
+
+ plate.StockItemCodes = ["SHEET-1", "SHEET-1"];
+ plate.StockItemCodes = [];
+
+ Assert.Equal(2, drawing.Quantity.Nested);
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs b/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs
index 67022fed..b54951b6 100644
--- a/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs
@@ -206,6 +206,22 @@ public class AutoNestFormLedgerStockTests
Assert.Contains("R1, R2", tooltip);
}
+ [Fact]
+ public void LedgerRowsCarryTheItemCodesAutoNestAssigns() => RunSta(() =>
+ {
+ using var form = new LedgerTestForm();
+ form.LoadPlateOptions(Manual(0, 0), 0.5);
+ GetGrid(form);
+ Assert.Empty(form.LedgerRows);
+
+ form.ApplyLedgerStock(Stock);
+
+ Assert.Equal(new[] { "ledger:1", "ledger:2" }, form.LedgerRows.Select(r => r.Id));
+ Assert.Equal(new[] { "B-1", "B-1", "B-1" }, form.LedgerRows[0].ItemCodes);
+ Assert.Equal(new[] { "D-1", "D-2" }, form.LedgerRows[1].ItemCodes);
+ Assert.Equal("R1", form.LedgerRows[1].ItemLocations["D-2"]);
+ });
+
private sealed class LedgerTestForm : AutoNestForm
{
public LedgerTestForm() : this(LedgerNest()) { }
diff --git a/OpenNest.WinForms.Tests/Forms/EditNestPlateStockTooltipTests.cs b/OpenNest.WinForms.Tests/Forms/EditNestPlateStockTooltipTests.cs
new file mode 100644
index 00000000..c0afdd67
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Forms/EditNestPlateStockTooltipTests.cs
@@ -0,0 +1,33 @@
+using System.Windows.Forms;
+using OpenNest.Forms;
+using OpenNest.Shapes;
+
+namespace OpenNest.WinForms.Tests.Forms;
+
+/// The plate list names each coded plate's inventory item, with its ledger location when known.
+public class EditNestPlateStockTooltipTests
+{
+ [Fact]
+ public void CodedPlateShowsItsStockInThePlateListTooltip() => RunSta(() =>
+ {
+ var nest = new Nest("stock tooltip");
+ var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing();
+ nest.Drawings.Add(drawing);
+ var plate = nest.CreatePlate();
+ plate.Parts.Add(new Part(drawing));
+ plate.StockItemCodes = ["DROP-1182"];
+ using var form = new EditNestForm(nest);
+ form.PlateView.SetOverlapAutoCheck(null);
+
+ form.LedgerLocations["DROP-1182"] = "B3";
+ form.UpdatePlateList();
+
+ var plates = Assert.IsType(Assert.Single(form.Controls.Find("platesListView", true)));
+ Assert.True(plates.ShowItemToolTips);
+ Assert.Equal("Stock: DROP-1182 (B3)", plates.Items[0].ToolTipText);
+ Assert.Equal("", plates.Items[^1].ToolTipText);
+ });
+
+ private static void RunSta(System.Action testBody) =>
+ StaTestThread.Run(testBody, TimeSpan.FromSeconds(60), "The stock tooltip STA test did not complete.");
+}
diff --git a/OpenNest/Forms/AutoNestForm.cs b/OpenNest/Forms/AutoNestForm.cs
index 6c0e2202..5a6dab4a 100644
--- a/OpenNest/Forms/AutoNestForm.cs
+++ b/OpenNest/Forms/AutoNestForm.cs
@@ -274,6 +274,12 @@ namespace OpenNest.Forms
return options;
}
+ /// The ledger rows in the grid, ticked or not; their ids name the run's ledger stock.
+ internal IReadOnlyList LedgerRows =>
+ plateGrid.DataSource is BindingList items
+ ? items.Where(i => i.LedgerRow != null).Select(i => i.LedgerRow).ToList()
+ : Array.Empty();
+
public bool TryGetPlateOptions(out List options, out string error) =>
TryGetPlateOptions(out options, out _, out error);
diff --git a/OpenNest/Forms/Commands/AutoNestCommand.cs b/OpenNest/Forms/Commands/AutoNestCommand.cs
index a69493ef..7d772acf 100644
--- a/OpenNest/Forms/Commands/AutoNestCommand.cs
+++ b/OpenNest/Forms/Commands/AutoNestCommand.cs
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
+using OpenNest.Data.MaterialLedger;
using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
@@ -29,6 +30,7 @@ namespace OpenNest.Forms.Commands
private readonly double salvageRate;
private readonly double minRemnantSize;
private List savedOptions;
+ private IReadOnlyList ledgerRows = Array.Empty();
private AutoNestCommand(IWin32Window owner, EditNestForm target, List items,
List plateOptions, string engineName, double salvageRate, double minRemnantSize)
@@ -73,6 +75,7 @@ namespace OpenNest.Forms.Commands
form.EngineName ?? EngineSelection.EngineName, form.SalvageRate, form.MinRemnantSize)
{
savedOptions = savedOptions,
+ ledgerRows = form.OptimizePlateSize ? form.LedgerRows : Array.Empty(),
};
}
@@ -113,6 +116,7 @@ namespace OpenNest.Forms.Commands
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, target.PlateManager,
allowInvalid, cts.Token);
+ AssignLedgerStock(result, applied);
if (applied.Count > 0)
target.PlateManager.LoadAt(target.Nest.Plates.IndexOf(applied[0]));
if (plateOptions?.Count > 0)
@@ -192,6 +196,20 @@ namespace OpenNest.Forms.Commands
return await solve;
}
+ // Only a committed proposal gets codes: every return above leaves them as they were.
+ private void AssignLedgerStock(NestPipelineResult result, IReadOnlyList applied)
+ {
+ LedgerStockAssignment.Assign(target.Nest.Plates,
+ NestPipelineCommit.PairWithStock(result, applied).Select(p => (p.Plate, p.Stock.Id)),
+ ledgerRows);
+ foreach (var row in ledgerRows)
+ {
+ foreach (var (code, location) in row.ItemLocations)
+ target.LedgerLocations[code] = location;
+ }
+ target.UpdatePlateList();
+ }
+
// Costs and unplaced parts were shown in the review; clamp removals exist only once kept.
private void ShowResultNotices(IReadOnlyList applied)
{
diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs
index 39ed9bf4..cd41a162 100644
--- a/OpenNest/Forms/EditNestForm.cs
+++ b/OpenNest/Forms/EditNestForm.cs
@@ -13,6 +13,7 @@ using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections;
using OpenNest.Controls;
using OpenNest.Data;
+using OpenNest.Data.MaterialLedger;
using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.Sequencing;
@@ -89,6 +90,7 @@ namespace OpenNest.Forms
toolStrip1.Renderer = renderer;
toolStrip2.Renderer = renderer;
+ platesListView.ShowItemToolTips = true;
platesListView.SelectedIndexChanged += (sender, e) =>
{
if (updatingPlateList || platesListView.SelectedIndices.Count == 0)
@@ -290,6 +292,12 @@ namespace OpenNest.Forms
}
internal System.Action CuttingBeforePlan { get; set; }
+ ///
+ /// Ledger location of each item code Auto Nest loaded in this window, for the plate list's
+ /// stock tooltip. Display-only: only the codes are saved with the nest.
+ ///
+ internal Dictionary LedgerLocations { get; } = new(StringComparer.Ordinal);
+
internal void OpenValidationInspection()
{
if (CuttingInspection != null)
@@ -1468,9 +1476,10 @@ namespace OpenNest.Forms
#endregion
- private static ListViewItem GetListViewItem(Plate plate, int id)
+ private ListViewItem GetListViewItem(Plate plate, int id)
{
var item = new ListViewItem();
+ item.ToolTipText = LedgerStockAssignment.Describe(plate, LedgerLocations) ?? "";
item.Text = id.ToString();
item.SubItems.Add(plate.Size.ToString());
item.SubItems.Add(plate.Quantity.ToString());
diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md
index e33b4e01..c6fec3bc 100644
--- a/docs/automatic-nesting.md
+++ b/docs/automatic-nesting.md
@@ -310,6 +310,17 @@ unmapped material clears the ledger rows. A failed request keeps them.
`Nest.PlateOptions`. After a kept result, the nest saves the hand-typed rows with their
typed costs, as before.
+**Item codes.** After a kept result is committed, `LedgerStockAssignment` gives each
+plate on a ledger row that row's next item codes, in nest plate order, one per sheet of
+the plate's quantity (a bundle repeats its code). It reads each sheet's stock from
+`NestPipelineCommit.PairWithStock`; a sheet Auto Nest solved with clamps removed takes its
+base row's codes. Plates committed on hand-typed stock get none, and a discarded,
+rejected or cancelled result changes no codes. The plate keeps them as
+`Plate.StockItemCodes` (saved in the nest, see the [nest file format](nest-file-format.md)),
+and `/api/nests/{id}/sheets` reports them per plate so MaterialLedger can hold those exact
+items. The plate list's tooltip shows "Stock: CODE (location)"; the location comes from
+this window's ledger load and is never saved. A re-nest builds new plates, so it drops codes.
+
**Cost.** Ledger rows load without cost. A ledger `CostPerUnit` is per lot unit, and a
drop's is its source sheet's. While any ledger row is ticked, the run scores by area and
hand-typed costs are ignored. The summary line says so, and the saved options keep
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index a52edbfe..a6fd6209 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -155,6 +155,23 @@ and sheets with parts, cut-offs, removed clamps or explicit zones are not change
An empty plate with other zones, or a rail-following one whose size, quadrant or
spacing differs from the saved defaults, is still written.
+## Stock item codes
+
+A plate may carry an optional `stockItemCodes` array: the inventory item (a
+MaterialLedger item code) each physical sheet of its `quantity` is cut from, in
+sheet order. Entry *i* names sheet *i*; sheets past the end are uncoded, and a
+bundle used for several sheets repeats its code. It is written only when at
+least one sheet is coded, so files without coded plates are unchanged. A
+missing or malformed value loads as no codes; non-string entries are skipped,
+a blank entry ends the coded sheets, and entries beyond `quantity` are dropped.
+Older readers ignore the key.
+
+Codes are hand-off data for the inventory system, never quantity accounting.
+The model drops them when they stop describing the sheet: resizing a plate
+(other than turning it) clears its codes, lowering its quantity truncates them,
+and changing the nest's material, grade or thickness clears every plate's.
+Auto Nest assigns them only to sheets it nests on ledger stock.
+
## Support grid
`plateDefaults.supportBed` optionally stores the machine support bed every sheet of
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index d1888e94..34d4fe7b 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -102,7 +102,7 @@ entry, never programs or geometry, and takes the archive-derived fields below fr
| GET | `/api/nests/query?search=&status=&material=&materialGrade=&thickness=&sort=&order=&offset=&limit=` | — | `{ "items": NestRecord[], "total", "offset", "limit" }`: one bounded page filtered in SQL (see below), or 400 for an invalid parameter |
| GET | `/api/nests/{id}` | — | `NestRecord` or 404 |
| GET | `/api/nests/{id}/file` | — | `.nest` archive bytes (`application/zip`) or 404 |
-| GET | `/api/nests/{id}/sheets` | — | `{ "nestId", "revision", "sheets": [{ "width", "length", "quantity" }], "parts": [{ "name", "required", "nested" }] }` read from the current archive (see below), 404, or 500 problem when the stored archive is unreadable |
+| GET | `/api/nests/{id}/sheets` | — | `{ "nestId", "revision", "sheets": [{ "width", "length", "quantity" }], "parts": [{ "name", "required", "nested" }], "plates": [{ "index", "width", "length", "quantity", "stockItemCodes" }] }` read from the current archive (see below), 404, or 500 problem when the stored archive is unreadable |
| POST | `/api/nests` | multipart: `metadata` (JSON `NestRecord`) + `file` (`.nest` bytes) | `NestRecord` with server-assigned `id` when the client sends an empty guid; 409 with the current record (active or deleted) when the `id` already exists, storing nothing |
| PUT | `/api/nests/{id}/file` | multipart: `metadata` + `file` | Updated `NestRecord`, or 404 if `id` is unknown |
| PUT | `/api/nests/{id}/metadata` | JSON `NestRecord` | Updated `NestRecord` (archive untouched), or 404 |
@@ -132,6 +132,15 @@ entry per drawing, ordered by name: `required` is the drawing's required
quantity, `nested` its parts on each plate times that plate's quantity; parts
whose drawing is missing are skipped.
+`plates` lists the same plates one by one, in nest order: `index` (0-based
+position), `width` (the shorter side) and `length` as stored, `quantity`, and
+`stockItemCodes`, the inventory item each sheet of that plate is cut from
+(entry *i* is sheet *i*; sheets past the end are uncoded and a bundle repeats
+its code; empty when none is coded). Codes come from the plate's optional
+`stockItemCodes` in `nest.json` (see the [nest file format](nest-file-format.md)),
+read as `NestReader` reads them. MaterialLedger can hold exactly those items and
+match uncoded sheets by size.
+
### Generated nest names
**File > New** names a nest `N-` (`OpenNest.Data.NestNames`): the