fix(mcp): release repeated stock reservations with their owner

This commit is contained in:
aj committed 2026-10-12 13:10:38 -04:00
1 parent 522de2761c
commit e5c82e2c92
5 files changed
+355 -19

No files matched your search

@@ -0,0 +1,284 @@
using System.Runtime.CompilerServices;
using OpenNest.Mcp;
using OpenNest.Mcp.Tools;
namespace OpenNest.FrontEnd.Tests;
[Collection("FrontEndRegistry")]
public class McpRepeatReservationLifecycleTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-mcp-reservations-" + Guid.NewGuid());
public McpRepeatReservationLifecycleTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
private static NestSession Session(int plateCount = 2)
{
var session = new NestSession { Nest = new Nest("reservations") };
var input = new InputTools(session);
input.CreateDrawing("A", "rectangle", 2, 2);
input.CreateDrawing("B", "rectangle", 2, 2);
var setup = new SetupTools(session);
for (var i = 0; i < plateCount; i++)
setup.CreatePlate(3, 3, partSpacing: 0.25);
return session;
}
private static string Repeat(NestSession session, int index = 0, string name = "A", int quantity = 2) =>
new NestingTools(session).AutoNestJob(index, name, quantity.ToString(), "Rectangles", no_new_plates: true);
private static int Index(NestSession session, Plate plate) =>
session.AllPlates().FindIndex(p => ReferenceEquals(p, plate));
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ClearingOwnerAllowsCompleteRenestWithoutCreatingOrDeletingSheets(bool noNew)
{
var session = Session();
var original = session.AllPlates().ToArray();
var tools = new NestingTools(session);
var first = tools.AutoNestJob(0, "A", "2", "Rectangles", no_new_plates: noNew,
sheets: noNew ? null : [new SheetStockInput { Width = 3, Length = 3, Quantity = 2 }]);
Assert.Contains("2 sheet(s) placed in 1 layout(s)", first);
Assert.Equal(2, original[0].Quantity);
Assert.Empty(original[1].Parts);
Assert.Contains("removed 1 parts", new SetupTools(session).ClearPlate(0));
Assert.Equal(0, session.GetDrawing("A").Quantity.Nested);
Assert.Contains("2 sheet(s) placed in 1 layout(s)", Repeat(session));
Assert.Equal(original, session.AllPlates());
Assert.Single(original[0].Parts);
Assert.Equal(2, original[0].Quantity);
Assert.Empty(original[1].Parts);
Assert.Equal(2, session.GetDrawing("A").Quantity.Nested);
}
[Fact]
public void ClearingPlaceholderDoesNotReleasePopulatedOwnersPhysicalStock()
{
var session = Session(3);
Assert.Contains("2 sheet(s) placed in 1 layout(s)", Repeat(session));
var owner = session.GetPlate(0);
var part = Assert.Single(owner.Parts);
var setup = new SetupTools(session);
Assert.Contains("removed 0 parts", setup.ClearPlate(1));
var tools = new NestingTools(session);
Assert.Contains("consumed", Repeat(session, 1, "B", 1));
Assert.Contains("consumed", tools.FillPlate(1, "B", 1));
Assert.Contains("consumed", tools.FillArea(1, "B", 0, 0, 3, 3, 1));
Assert.Contains("consumed", tools.FillRemnants(1, "B", 1));
Assert.Contains("consumed", tools.PackPlate(1, "B", "1"));
Assert.Contains("consumed", tools.AutoNestPlate(1, "B", "1", allow_invalid: true));
Assert.Contains("Nothing committed", Repeat(session, 2, "B"));
Assert.Same(part, Assert.Single(owner.Parts));
Assert.Equal(2, session.GetDrawing("A").Quantity.Nested);
Assert.Equal(0, session.GetDrawing("B").Quantity.Nested);
Assert.All(session.AllPlates().Skip(1), plate => Assert.Empty(plate.Parts));
}
[Fact]
public void ClearingOneOwnerDoesNotFreeAnotherPopulatedLayoutsReservation()
{
var session = Session(5);
Assert.Contains("Whole job complete", Repeat(session));
Assert.Contains("Whole job complete", Repeat(session, 2, "B"));
var otherOwner = session.GetPlate(2);
var otherPart = Assert.Single(otherOwner.Parts);
var otherPlaceholder = session.GetPlate(3);
new SetupTools(session).ClearPlate(0);
Assert.Contains("consumed", Repeat(session, 3, "B", 3));
Assert.Contains("3 sheet(s) placed in 1 layout(s)", Repeat(session, quantity: 3));
Assert.Same(otherPart, Assert.Single(otherOwner.Parts));
Assert.Equal(2, otherOwner.Quantity);
Assert.Empty(otherPlaceholder.Parts);
Assert.Contains("consumed", new NestingTools(session).FillPlate(Index(session, otherPlaceholder), "A", 1));
Assert.Equal(5, session.AllPlates().Count);
Assert.Equal(3, session.GetDrawing("A").Quantity.Nested);
Assert.Equal(2, session.GetDrawing("B").Quantity.Nested);
}
[Fact]
public void DeletingPlaceholderDoesNotReleaseItsOwnersOtherCopiesOrAnotherOwner()
{
var session = Session(5);
Assert.Contains("Whole job complete", Repeat(session, quantity: 3));
Assert.Contains("Whole job complete", Repeat(session, 3, "B"));
var original = session.AllPlates().ToArray();
var setup = new SetupTools(session);
Assert.Contains("occupied", setup.DeletePlate(0));
Assert.Contains("Deleted plate 1", setup.DeletePlate(1));
Assert.Equal(new[] { original[0], original[2], original[3], original[4] }, session.AllPlates());
Assert.Contains("consumed", Repeat(session, 1, "A", 4));
Assert.Contains("consumed", Repeat(session, 3, "B", 3));
Assert.Equal(3, original[0].Quantity);
Assert.Equal(2, original[3].Quantity);
Assert.Single(original[0].Parts);
Assert.Single(original[3].Parts);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void MutableCollectionsReleaseOnlyRemovedOwnersReservation(bool loadedOwner)
{
var session = Session(4);
if (loadedOwner)
{
session.Nest.Plates.Add(session.Plates[0]);
session.Plates.RemoveAt(0);
}
Assert.Contains("Whole job complete", Repeat(session));
Assert.Contains("Whole job complete", Repeat(session, 2, "B"));
var removed = session.GetPlate(0);
var freed = session.GetPlate(1);
var otherOwner = session.GetPlate(2);
var otherPlaceholder = session.GetPlate(3);
if (loadedOwner)
session.Nest.Plates.Remove(removed);
else
session.Plates.Remove(removed); // List removal leaves the detached owner's parts populated.
Assert.Contains("consumed", Repeat(session, Index(session, otherPlaceholder), "B", 3));
Assert.Contains("Parts added: 1", new NestingTools(session).FillPlate(Index(session, freed), "A", 1));
Assert.Single(freed.Parts);
Assert.Single(otherOwner.Parts);
Assert.Equal(2, otherOwner.Quantity);
Assert.Empty(otherPlaceholder.Parts);
Assert.Equal(3, session.AllPlates().Count);
}
[Fact]
public void DirectlyClearedThenDeletedOwnerReleasesCopiesWithoutCorruptingOtherOwner()
{
var session = Session(4);
Assert.Contains("Whole job complete", Repeat(session));
Assert.Contains("Whole job complete", Repeat(session, 2, "B"));
var freed = session.GetPlate(1);
var otherPlaceholder = session.GetPlate(3);
session.GetPlate(0).Parts.Clear();
Assert.Contains("Deleted plate 0", new SetupTools(session).DeletePlate(0));
Assert.Contains("consumed", Repeat(session, Index(session, otherPlaceholder), "B", 3));
Assert.Contains("1 sheet(s) placed in 1 layout(s)", Repeat(session, Index(session, freed), quantity: 1));
Assert.Single(freed.Parts);
Assert.Empty(otherPlaceholder.Parts);
}
[Fact]
public void LoadingReplacementReleasesRemovedOwnerButPreservesAppendedOwnersReservation()
{
var session = Session(4);
session.Nest.Plates.Add(session.Plates[0]);
session.Plates.RemoveAt(0);
Assert.Contains("Whole job complete", Repeat(session));
Assert.Contains("Whole job complete", Repeat(session, 2, "B"));
var freed = session.GetPlate(1);
var otherOwner = session.GetPlate(2);
var otherPlaceholder = session.GetPlate(3);
var path = ReplacementFile();
Assert.Contains("Loaded nest", new InputTools(session).LoadNest(path));
Assert.Contains("consumed", Repeat(session, Index(session, otherPlaceholder), "B", 3));
Assert.Contains("1 sheet(s) placed in 1 layout(s)", Repeat(session, Index(session, freed), quantity: 1));
Assert.Single(freed.Parts);
Assert.Single(otherOwner.Parts);
Assert.Equal(2, otherOwner.Quantity);
Assert.Empty(otherPlaceholder.Parts);
Assert.Equal(4, session.AllPlates().Count);
}
private string ReplacementFile()
{
var replacement = Session(1);
replacement.Drawings.Clear();
new InputTools(replacement).CreateDrawing("LoadedOnly", "rectangle", 2, 2);
Assert.Contains("Whole job complete", Repeat(replacement, name: "LoadedOnly", quantity: 1));
var path = Path.Combine(directory, "replacement.nest");
Assert.Contains("Saved nest", new InputTools(replacement).SaveNest(path));
return path;
}
[Fact]
public void LoadingAwayPlaceholderDoesNotReleaseAppendedOwnersRemainingReservation()
{
var session = Session(3);
Assert.Contains("Whole job complete", Repeat(session, quantity: 3));
var owner = session.GetPlate(0);
var remaining = session.GetPlate(2);
var replaced = ReplaceLoadedPlaceholder(session, ReplacementFile());
Assert.Contains("consumed", Repeat(session, Index(session, remaining), "B", 1));
Assert.Single(owner.Parts);
Assert.Equal(3, owner.Quantity);
Assert.Empty(remaining.Parts);
AssertCollected(replaced);
GC.KeepAlive(session);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference ReplaceLoadedPlaceholder(NestSession session, string path)
{
var placeholder = session.GetPlate(1);
session.Plates.Remove(placeholder);
session.Nest.Plates.Add(placeholder);
Assert.Contains("Loaded nest", new InputTools(session).LoadNest(path));
return new WeakReference(placeholder);
}
[Fact]
public void DeletedPlaceholderReferencesAreNotRetainedWhileSiblingReservationStaysBlocked()
{
var session = Session(3);
Assert.Contains("Whole job complete", Repeat(session, quantity: 3));
var deleted = DeletePlaceholder(session);
Assert.Contains("consumed", Repeat(session, 1, "B", 1));
AssertCollected(deleted);
GC.KeepAlive(session);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference DeletePlaceholder(NestSession session)
{
var placeholder = session.GetPlate(1);
Assert.Contains("Deleted plate 1", new SetupTools(session).DeletePlate(1));
return new WeakReference(placeholder);
}
[Fact]
public void RepeatedLoadedNestReplacementDoesNotRetainOldOwnersOrPlaceholders()
{
var session = new NestSession();
var path = ReplacementFile();
var oldPlates = ReplaceLoadedReservations(session, path);
Assert.All(oldPlates, AssertCollected);
GC.KeepAlive(session);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static List<WeakReference> ReplaceLoadedReservations(NestSession session, string path)
{
var oldPlates = new List<WeakReference>();
for (var i = 0; i < 8; i++)
{
var fixture = Session();
foreach (var drawing in fixture.AllDrawings())
fixture.Nest.Drawings.Add(drawing);
foreach (var plate in fixture.AllPlates())
fixture.Nest.Plates.Add(plate);
session.Nest = fixture.Nest;
Assert.Contains("Whole job complete", Repeat(session));
oldPlates.AddRange(session.AllPlates().Select(p => new WeakReference(p)));
Assert.Contains("Loaded nest", new InputTools(session).LoadNest(path));
}
return oldPlates;
}
private static void AssertCollected(WeakReference reference)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Assert.False(reference.IsAlive, "Session retained a plate outside its live reservation ownership.");
}
}
+56 -6
View File
@@ -1,18 +1,66 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Mcp
{
public class NestSession
{
public Nest Nest { get; set; }
private Nest nest;
public Nest Nest
{
get => nest;
set
{
nest = value;
ReconcileStockReservations();
}
}
public List<Plate> Plates { get; } = new();
public List<Drawing> Drawings { get; } = new();
// A quantity-bearing layout can consume existing physical sheets without
// filling every display entry. Keep those placeholders, but never offer
// them as fresh stock again in this session. New/user-authored plates and
// pre-existing repeat quantities are not inferred to be consumed here.
internal HashSet<Plate> ConsumedStockPlates { get; } = new(ReferenceEqualityComparer.Instance);
// A repeated layout owns its otherwise-empty physical sheet placeholders.
// Clearing/removing that owner releases only its copies, never another layout's.
// User-authored plates and pre-existing quantities imply no reservations.
private readonly Dictionary<Plate, HashSet<Plate>> stockReservations = new(ReferenceEqualityComparer.Instance);
internal bool IsConsumedStockPlate(Plate plate)
{
ReconcileStockReservations();
return stockReservations.Values.Any(copies => copies.Contains(plate));
}
internal void ReserveStockCopies(Plate owner, IEnumerable<Plate> copies)
{
var reserved = new HashSet<Plate>(copies, ReferenceEqualityComparer.Instance);
if (reserved.Count > 0)
stockReservations[owner] = reserved;
}
internal void ReleaseStockCopies(Plate owner) => stockReservations.Remove(owner);
private void ReconcileStockReservations()
{
if (stockReservations.Count == 0)
return;
// Both collections are publicly mutable. Reconcile by reference, not index,
// so removals/replacements cannot retain stale plates or free unrelated stock.
var live = new HashSet<Plate>(Plates, ReferenceEqualityComparer.Instance);
if (Nest != null)
live.UnionWith(Nest.Plates);
foreach (var (owner, copies) in stockReservations.ToArray())
{
if (!live.Contains(owner) || owner.Parts.Count == 0)
{
stockReservations.Remove(owner);
continue;
}
copies.RemoveWhere(copy => !live.Contains(copy));
if (copies.Count == 0)
stockReservations.Remove(owner);
}
}
/// <summary>
/// Whole-job engine <c>autonest_plate</c> uses when a call does not pass an explicit
@@ -64,6 +112,7 @@ namespace OpenNest.Mcp
else
Plates.RemoveAt(index - loadedCount);
ReconcileStockReservations();
reason = null;
return true;
}
@@ -90,6 +139,7 @@ namespace OpenNest.Mcp
public List<Plate> AllPlates()
{
ReconcileStockReservations();
var all = new List<Plate>();
if (Nest != null)
all.AddRange(Nest.Plates);
+13 -12
View File
@@ -55,7 +55,7 @@ namespace OpenNest.Mcp.Tools
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (_session.ConsumedStockPlates.Contains(plate))
if (_session.IsConsumedStockPlate(plate))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
var drawing = _session.GetDrawing(drawingName);
@@ -110,7 +110,7 @@ namespace OpenNest.Mcp.Tools
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (_session.ConsumedStockPlates.Contains(plate))
if (_session.IsConsumedStockPlate(plate))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
var drawing = _session.GetDrawing(drawingName);
@@ -162,7 +162,7 @@ namespace OpenNest.Mcp.Tools
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (_session.ConsumedStockPlates.Contains(plate))
if (_session.IsConsumedStockPlate(plate))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
var drawing = _session.GetDrawing(drawingName);
@@ -227,7 +227,7 @@ namespace OpenNest.Mcp.Tools
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (_session.ConsumedStockPlates.Contains(plate))
if (_session.IsConsumedStockPlate(plate))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
if (string.IsNullOrWhiteSpace(drawingNames))
@@ -285,7 +285,7 @@ namespace OpenNest.Mcp.Tools
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (_session.ConsumedStockPlates.Contains(plate))
if (_session.IsConsumedStockPlate(plate))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
if (plate.Parts.Count > 0)
return "Error: autonest cannot use an occupied plate. Use fill_area or fill_remnants for existing obstacles.";
@@ -360,7 +360,7 @@ namespace OpenNest.Mcp.Tools
var template = _session.GetPlate(plateIndex);
if (template == null)
return $"Error: plate {plateIndex} not found";
if (_session.ConsumedStockPlates.Contains(template))
if (_session.IsConsumedStockPlate(template))
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
if (template.Parts.Count != 0 || template.CutOffs.Count != 0)
return "Error: stock template must be empty and have no cutoff definitions. Nothing committed.";
@@ -404,7 +404,7 @@ namespace OpenNest.Mcp.Tools
var available = new List<Plate> { template };
available.AddRange(existing.Where(p => !ReferenceEquals(p, template)
&& !_session.ConsumedStockPlates.Contains(p)
&& !_session.IsConsumedStockPlate(p)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0 && SameStock(p, template)));
var demand = remaining.Sum(item => (long)item.Quantity);
if (demand > int.MaxValue)
@@ -452,7 +452,7 @@ namespace OpenNest.Mcp.Tools
{
var target = new[] { template }.Concat(existing.Where(p => !ReferenceEquals(p, template)))
.FirstOrDefault(p => !usedTargets.Contains(p)
&& !_session.ConsumedStockPlates.Contains(p)
&& !_session.IsConsumedStockPlate(p)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0
&& SameStock(p, template, proposed.Stock.Size));
var created = target == null;
@@ -469,18 +469,18 @@ namespace OpenNest.Mcp.Tools
// Reserve physical copies separately from display-layout targets. Do this
// only after mapping every layout, so a repeat cannot steal a residual's
// target. A rejected/cancelled proposal reserves nothing and deletes nothing.
var consumed = new List<Plate>();
var reservations = new List<List<Plate>>();
foreach (var proposed in result.Plates)
{
var copies = existing.Where(p => !usedTargets.Contains(p)
&& !_session.ConsumedStockPlates.Contains(p)
&& !_session.IsConsumedStockPlate(p)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0
&& SameStock(p, template, proposed.Stock.Size))
.Take(proposed.Quantity - 1).ToList();
if (no_new_plates && copies.Count != proposed.Quantity - 1)
return "Error: insufficient unused physical stock for the repeated layout. Nothing committed.";
usedTargets.UnionWith(copies);
consumed.AddRange(copies);
reservations.Add(copies);
}
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < result.Plates.Count; i++)
@@ -496,7 +496,8 @@ namespace OpenNest.Mcp.Tools
_session.Plates.Add(target);
target.Parts.AddRange(proposed.Parts);
}
_session.ConsumedStockPlates.UnionWith(consumed);
for (var i = 0; i < targets.Count; i++)
_session.ReserveStockCopies(targets[i].plate, reservations[i]);
foreach (var item in parsed.items)
item.Drawing.Quantity.Required = item.Quantity;
if (sheets != null)
+1
View File
@@ -85,6 +85,7 @@ namespace OpenNest.Mcp.Tools
var count = plate.Parts.Count;
plate.Parts.Clear();
_session.ReleaseStockCopies(plate);
return $"Cleared plate {plateIndex}: removed {count} parts";
}
+1 -1
View File
@@ -58,7 +58,7 @@ MCP `autonest_plate` requires an empty target. The stdio server serializes all t
MCP `pack_plate` and `autonest_plate` report each requested drawing's newly placed count and remaining quantity, indexed by request; partial fulfillment is explicitly warned about. Per-sheet ratios are not enforced or required: an uneven sheet is acceptable if the remaining quantities are placed elsewhere in the nest. These two plate tools operate on a single selected plate and do not automatically finish a multi-sheet job; callers can use `autonest_job` for that. Duplicate request tokens and ambiguous drawing names are refused before placement. A zero-quantity pack request places zero parts for that drawing; negative quantities are rejected.
`autonest_job(plateIndex, drawingNames, quantities, engine?, no_new_plates=false, sheets?)` is the whole-session completion path. Quantities are total targets across existing placed parts (physical plate quantity included) plus newly proposed sheets. Without `sheets`, the selected empty cutoff-free plate supplies stock size, spacing, quadrant, grain and cutting settings; other empty plates with equivalent persisted settings may be reused first, then additional matching plates are created as needed. `no_new_plates=true` caps physical stock by the number of existing matching empty plates, not the number of grouped output layouts or their old repeat quantities. Exact repeats still attach once with their total quantity; redundant empty entries remain unchanged and are omitted on save. Empty entries consumed by those physical copies are tracked in the live MCP session and cannot be offered again or filled by another nesting tool. Genuinely unused entries remain available. No automatic deletion of user plates occurs. No new layout entries are created in this mode. The request uses the independently validated `NestPipeline` and commits only when every requested quantity is met; invalid, incomplete, cancelled or unknown-engine proposals leave existing plates and counts unchanged. A no-placement solver result is not proof that geometry cannot fit. `pack_plate` and `autonest_plate` remain single-sheet operations.
`autonest_job(plateIndex, drawingNames, quantities, engine?, no_new_plates=false, sheets?)` is the whole-session completion path. Quantities are total targets across existing placed parts (physical plate quantity included) plus newly proposed sheets. Without `sheets`, the selected empty cutoff-free plate supplies stock size, spacing, quadrant, grain and cutting settings; other empty plates with equivalent persisted settings may be reused first, then additional matching plates are created as needed. `no_new_plates=true` caps physical stock by the number of existing matching empty plates, not the number of grouped output layouts or their old repeat quantities. Exact repeats still attach once with their total quantity; redundant empty entries remain unchanged and are omitted on save. Empty entries consumed by those physical copies are reserved by their populated layout in the live MCP session and cannot be offered again or filled by another nesting tool while that owner remains populated. Clearing or removing the owner (including replacement by `load_nest`) releases only its surviving placeholders; clearing or deleting a placeholder does not release the owner's other copies or another layout's reservations. Genuinely unused entries remain available. No automatic deletion of user plates occurs. No new layout entries are created in this mode. The request uses the independently validated `NestPipeline` and commits only when every requested quantity is met; invalid, incomplete, cancelled or unknown-engine proposals leave existing plates and counts unchanged. A no-placement solver result is not proof that geometry cannot fit. `pack_plate` and `autonest_plate` remain single-sheet operations.
For finite mixed-size inventory, supply `sheets` as an array, for example `{"plateIndex":0,"drawingNames":"large,small","quantities":"1,1","engine":"Rectangles","sheets":[{"width":60,"length":120,"quantity":1},{"width":48,"length":96,"quantity":2}]}`. Width and length have the same units and orientation as `create_plate`. Every row's positive integer `quantity` is the total physical sheets available for this solve, including any compatible pre-created empty plates; it is never a `Plate.Quantity` repeat multiplier or additional stock. The selected plate supplies common spacing, edge spacing, quadrant, grain and cutting settings, but is reused only if its size matches a chosen sheet. Other compatible empty sheets can be reused; occupied or cutoff-bearing sheets cannot. Only needed different-sized sheets are created. Duplicate size pairs, nonpositive or nonfinite dimensions, unusable work areas, nonpositive quantities, an empty list and excessive total inventory are refused before solving. `sheets` cannot be combined with `no_new_plates=true`. Omit `sheets` to retain the previous single-size behavior. Successful output reports `stock-0`, `stock-1`, etc. in input order with physical used/remaining counts, reused/created layout counts, plus committed session layout index to stock ID and per-drawing totals. Stock is request-local, not a persistent purchasing catalog. Unused empty templates may be omitted by the `.nest` writer on save.