feat(nest): plates name the ledger item each sheet is cut from

Auto Nest on MaterialLedger stock now records which inventory item each
physical sheet uses, so ML can hold exactly those items instead of matching
by size.

- Core: Plate.StockItemCodes (entry i = sheet i of Quantity; empty = uncoded).
  Core invalidates it: a resize clears it (turning the sheet keeps it), a lower
  Quantity truncates it, a nest material/grade/thickness change clears every
  plate's, ClearAll clears it and new plates never copy it.
- Desktop Auto Nest: after a kept proposal is committed, LedgerStockAssignment
  deals each ledger row's codes to its plates in Nest.Plates order (bundles
  repeat their code; clamp-removal variants use their base row). Rejected,
  discarded or cancelled proposals never reach it. NestPipelineCommit.PairWithStock
  pairs committed plates with their solved stock; the engine stays ledger-free.
- Plate list tooltip: "Stock: CODE (location)"; locations are display-only.
- nest.json: optional plate stockItemCodes, omitted when empty, read leniently.
- /api/nests/{id}/sheets: new plates[] { index, width, length, quantity,
  stockItemCodes }; sheets[] unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 17:40:44 -04:00
1 parent ea1decc097
commit 15750949dd
24 files changed
+886 -9

No files matched your search

+44 -2
View File
@@ -51,9 +51,51 @@ namespace OpenNest
public string MadeBy { get; set; } = "";
public double Thickness { get; set; }
/// <summary>The stock thickness. A different value clears every plate's <see cref="Plate.StockItemCodes"/>.</summary>
public double Thickness
{
get { return thickness; }
set
{
if (value != thickness)
ClearStockItemCodes();
thickness = value;
}
}
public Material Material { get; set; }
private double thickness;
/// <summary>
/// The stock material. A different name or grade (ignoring case and spacing) clears every
/// plate's <see cref="Plate.StockItemCodes"/>: the coded items are another material.
/// </summary>
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<string>();
}
public Units Units { get; set; }
+41 -2
View File
@@ -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<string>();
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;
/// <summary>
/// The inventory item each physical sheet of this plate is cut from: entry i names sheet
/// i of <see cref="Quantity"/>, and sheets past the end are uncoded. Empty when no sheet
/// is coded. A resize (other than turning the sheet) clears it, lowering
/// <see cref="Quantity"/> truncates it, and raising it leaves the extra sheets uncoded.
/// Display and hand-off data only: quantity accounting never reads it.
/// </summary>
public IReadOnlyList<string> 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<string> stockItemCodes = Array.Empty<string>();
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
/// <summary>
@@ -457,9 +484,21 @@ namespace OpenNest
}
/// <summary>
/// The number of times to cut the plate.
/// The number of times to cut the plate. Lowering it drops the
/// <see cref="StockItemCodes"/> of the sheets no longer cut.
/// </summary>
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;
/// <summary>
/// The quadrant the plate is located in.
+1
View File
@@ -236,6 +236,7 @@ namespace OpenNest
remaining.CutOffs.Clear();
remaining.Parts.Clear();
remaining.Quantity = 1;
remaining.StockItemCodes = Array.Empty<string>();
}
finally
{
@@ -0,0 +1,84 @@
namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// 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.
/// </summary>
public static class LedgerStockAssignment
{
// ClampRemovalRecovery solves a sheet with clamps removed as "{stock id}::removed:{ids}".
private const string VariantSeparator = "::";
/// <param name="nestPlates">The nest's plates; codes are dealt out in this order.</param>
/// <param name="committed">Each plate the commit attached, with the engine stock id it was solved on.</param>
/// <param name="rows">The ledger rows offered to the run.</param>
/// <remarks>
/// Each row's codes are a queue: a plate takes the next <see cref="Plate.Quantity"/> 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.
/// </remarks>
public static void Assign(IList<Plate> nestPlates, IEnumerable<(Plate Plate, string StockId)> committed,
IEnumerable<LedgerStockRow> rows)
{
ArgumentNullException.ThrowIfNull(nestPlates);
ArgumentNullException.ThrowIfNull(committed);
ArgumentNullException.ThrowIfNull(rows);
var queues = new Dictionary<string, Queue<string>>(StringComparer.Ordinal);
foreach (var row in rows)
queues.TryAdd(row.Id, new Queue<string>(row.ItemCodes));
foreach (var (plate, stockId) in committed.OrderBy(c => IndexOf(nestPlates, c.Plate)))
{
var codes = new List<string>();
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;
}
}
/// <summary>The offered stock id a solved sheet came from, without a clamp-removal suffix.</summary>
public static string BaseStockId(string stockId)
{
ArgumentNullException.ThrowIfNull(stockId);
var separator = stockId.IndexOf(VariantSeparator, StringComparison.Ordinal);
return separator < 0 ? stockId : stockId[..separator];
}
/// <summary>
/// "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.
/// </summary>
public static string? Describe(Plate plate, IReadOnlyDictionary<string, string>? locations)
{
ArgumentNullException.ThrowIfNull(plate);
var codes = plate.StockItemCodes;
if (codes.Count == 0)
return null;
var parts = new List<string>();
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<Plate> plates, Plate plate)
{
var index = plates.IndexOf(plate);
return index < 0 ? int.MaxValue : index;
}
}
@@ -15,7 +15,12 @@ public sealed record LedgerStockRow(
bool IsDrop,
bool IsIrregular,
IReadOnlyList<string> ItemCodes,
IReadOnlyList<string> Locations);
IReadOnlyList<string> Locations)
{
/// <summary>Each item code's ledger location code, for display only.</summary>
public IReadOnlyDictionary<string, string> ItemLocations { get; init; } =
new Dictionary<string, string>(StringComparer.Ordinal);
}
/// <summary>The rows offered to Auto Nest and the counts of ledger items left out.</summary>
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<string, string>(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);
}
+25
View File
@@ -15,6 +15,31 @@ public sealed class NestSheets
/// <summary>One entry per drawing, including drawings with nothing nested, ordered by name.</summary>
public IReadOnlyList<NestPartCount> Parts { get; init; } = Array.Empty<NestPartCount>();
/// <summary>One entry per plate in nest order, the same plates <see cref="Sheets"/> groups.</summary>
public IReadOnlyList<NestPlateSheet> Plates { get; init; } = Array.Empty<NestPlateSheet>();
}
/// <summary>
/// One plate of the nest: its size as stored with <see cref="Width"/> the shorter side, how many
/// times it is cut, and the inventory item each of those sheets is cut from.
/// </summary>
public sealed class NestPlateSheet
{
/// <summary>The plate's 0-based position in the nest.</summary>
public int Index { get; init; }
public double Width { get; init; }
public double Length { get; init; }
public int Quantity { get; init; }
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<string> StockItemCodes { get; init; } = Array.Empty<string>();
}
/// <summary>A sheet size as stored, with <see cref="Width"/> the shorter side.</summary>
@@ -49,6 +49,24 @@ public static class NestPipelineCommit
return Attach(result, nextPlate);
}
/// <summary>
/// Pairs the plates <see cref="ApplyToEmptyPlates"/> or <see cref="ApplyToPlates"/> returned
/// for <paramref name="result"/> with the stock each was solved on: one per proposed plate
/// with parts, in proposal order.
/// </summary>
public static IReadOnlyList<(Plate Plate, NestPlateStock Stock)> PairWithStock(
NestPipelineResult result,
IReadOnlyList<Plate> 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);
@@ -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;
/// <summary>
/// A plate's stock item codes survive the .nest round trip as an optional
/// <c>stockItemCodes</c> array; files without it load with no codes.
/// </summary>
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);
}
}
+9
View File
@@ -649,6 +649,15 @@ namespace OpenNest.IO
[JsonConverter(typeof(LenientStringListConverter))]
public List<string> RemovedClamps { get; init; } = new();
/// <summary>
/// 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.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(LenientStringListConverter))]
public List<string> StockItemCodes { get; init; } = new();
/// <summary>
/// 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).
+4
View File
@@ -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];
+1
View File
@@ -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(),
}
);
}
+72
View File
@@ -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<NestSheets>($"/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();
+52 -1
View File
@@ -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<PartJson>? Parts { get; set; }
public List<CutOffJson>? CutOffs { get; set; }
[JsonConverter(typeof(LenientStringListJson))]
public List<string>? StockItemCodes { get; set; }
}
// NestReader's lenient list: a wrongly shaped value reads as empty, non-strings are skipped.
private sealed class LenientStringListJson : JsonConverter<List<string>>
{
public override List<string> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var document = JsonDocument.ParseValue(ref reader);
var list = new List<string>();
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<string> value, JsonSerializerOptions options) =>
JsonSerializer.Serialize(writer, value);
}
private sealed class SizeJson
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<string>());
[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<InvalidOperationException>(() => 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<string, string> { ["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<NestJobProgress> 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)]);
}
}
+14
View File
@@ -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()
{
+136
View File
@@ -0,0 +1,136 @@
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests;
/// <summary>
/// <see cref="Plate.StockItemCodes"/> name the inventory item of each physical sheet, and Core
/// drops them as soon as they stop describing the sheet.
/// </summary>
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<ArgumentException>(() => Coded(2, "A", " "));
Assert.Throws<ArgumentNullException>(() => 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);
}
}
@@ -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()) { }
@@ -0,0 +1,33 @@
using System.Windows.Forms;
using OpenNest.Forms;
using OpenNest.Shapes;
namespace OpenNest.WinForms.Tests.Forms;
/// <summary>The plate list names each coded plate's inventory item, with its ledger location when known.</summary>
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<ListView>(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.");
}
+6
View File
@@ -274,6 +274,12 @@ namespace OpenNest.Forms
return options;
}
/// <summary>The ledger rows in the grid, ticked or not; their ids name the run's ledger stock.</summary>
internal IReadOnlyList<LedgerStockRow> LedgerRows =>
plateGrid.DataSource is BindingList<PlateOptionItem> items
? items.Where(i => i.LedgerRow != null).Select(i => i.LedgerRow).ToList()
: Array.Empty<LedgerStockRow>();
public bool TryGetPlateOptions(out List<PlateOption> options, out string error) =>
TryGetPlateOptions(out options, out _, out error);
@@ -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<PlateOption> savedOptions;
private IReadOnlyList<LedgerStockRow> ledgerRows = Array.Empty<LedgerStockRow>();
private AutoNestCommand(IWin32Window owner, EditNestForm target, List<NestItem> items,
List<PlateOption> 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<LedgerStockRow>(),
};
}
@@ -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<Plate> 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<Plate> applied)
{
+10 -1
View File
@@ -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<System.Threading.CancellationToken> CuttingBeforePlan { get; set; }
/// <summary>
/// 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.
/// </summary>
internal Dictionary<string, string> 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());
+11
View File
@@ -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
+17
View File
@@ -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
+10 -1
View File
@@ -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<yy>-<seq>` (`OpenNest.Data.NestNames`): the