feat(mcp): complete jobs with finite mixed sheet stock

This commit is contained in:
aj committed 2026-10-09 13:14:34 -04:00
1 parent f1d1df3f17
commit 41737a4f4c
4 files changed
+258 -18

No files matched your search

+172 -5
View File
@@ -15,13 +15,13 @@ public class McpWholeJobTests : IDisposable
public McpWholeJobTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
private static Drawing Square(string name)
private static Drawing Square(string name, double size = 2)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(size, 0)));
shape.Entities.Add(new Line(new Vector(size, 0), new Vector(size, size)));
shape.Entities.Add(new Line(new Vector(size, size), new Vector(0, size)));
shape.Entities.Add(new Line(new Vector(0, size), new Vector(0, 0)));
return new Drawing(name, OpenNest.Converters.ConvertGeometry.ToProgram(shape));
}
@@ -38,6 +38,173 @@ public class McpWholeJobTests : IDisposable
string engine, bool noNew = false, CancellationToken token = default) =>
tools.AutoNestJob(plateIndex, names, quantities, engine, noNew, token);
[Fact]
public void FiniteSheetInputsReachEngineWithTemplateSettings()
{
var session = Session();
var template = session.GetPlate(0);
template.Size = new Size(60, 120);
template.EdgeSpacing = new Spacing(1, 2, 3, 4);
template.Quadrant = 3;
var engine = "StockMap-" + Guid.NewGuid();
var called = false;
NestingEngineRegistry.Register(engine, "inspect finite stock", () => new InspectStub(job =>
{
called = true;
Assert.Equal(2, job.Plates.Count);
Assert.Equal(["stock-0", "stock-1"], job.Plates.Select(s => s.Id));
Assert.Equal([new Size(60, 120), new Size(48, 96)], job.Plates.Select(s => s.Size));
Assert.Equal([1, 2], job.Plates.Select(s => s.Quantity));
Assert.All(job.Plates, stock =>
{
Assert.Equal(0.25, stock.PartSpacing);
Assert.Equal(template.EdgeSpacing, stock.EdgeSpacing);
Assert.Equal(3, stock.Quadrant);
});
Assert.Equal(2, job.Options.MaxPlates); // capped by demand, not plate.Quantity
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.StockExhausted, [], [], []);
}));
var rows = new[] { new SheetStockInput { Width = 60, Length = 120, Quantity = 1 },
new SheetStockInput { Width = 48, Length = 96, Quantity = 2 } };
var tool = new NestingTools(session);
Assert.Contains("incomplete", tool.AutoNestJob(0, "A,B", "1,1", engine, sheets: rows));
Assert.True(called);
called = false;
rows[1].Width = double.NaN;
Assert.Contains("sheets[1]", tool.AutoNestJob(0, "A,B", "1,1", engine, sheets: rows));
Assert.False(called);
Assert.Single(session.AllPlates());
Assert.Empty(template.Parts);
Assert.Equal(1, template.Quantity); // no inventory drawn from the template quantity
}
private sealed class InspectStub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job);
}
[Fact]
public void FiniteMixedSheetsCommitByStockIdAndRoundTrip()
{
var session = Session();
var template = session.GetPlate(0);
template.Size = new Size(6, 6);
template.Quantity = 9; // repeat count is not inventory
var small = session.Nest.Drawings.Single(d => d.Name == "B");
var large = Square("large", 4); // four-by-four cannot fit a three-by-three sheet
session.Nest.Drawings.Add(large);
var rows = new[] { new SheetStockInput { Width = 6, Length = 6, Quantity = 1 },
new SheetStockInput { Width = 3, Length = 3, Quantity = 2 } };
var engine = "MixedStock-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "two different sheets", () => new InspectStub(job =>
{
Assert.Equal(2, job.Options.MaxPlates);
Assert.Equal([1, 2], job.Plates.Select(s => s.Quantity));
// Small stock first: result ordinal must not resize/reuse the large template.
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, job.Plates[1], [new NestJobPlacement(job.Parts[1].Id, 0, 0.5, 0.5, 0)]),
new NestJobPlateResult(1, job.Plates[0], [new NestJobPlacement(job.Parts[0].Id, 1, 0.5, 0.5, 0)])], [], []);
}));
var text = new NestingTools(session).AutoNestJob(0, "large,B", "1,1", engine, sheets: rows);
Assert.Contains("Whole job complete", text);
Assert.Contains("stock-0 (6x6): used=1, remaining=0, reused=1, created=0", text);
Assert.Contains("stock-1 (3x3): used=1, remaining=1, reused=0, created=1", text);
Assert.Contains("plate 0 -> stock-0", text);
Assert.Contains("plate 1 -> stock-1", text);
Assert.Same(template, session.GetPlate(0));
Assert.Equal(new Size(6, 6), template.Size);
Assert.Same(large, Assert.Single(template.Parts).BaseDrawing);
Assert.Same(small, Assert.Single(session.GetPlate(1).Parts).BaseDrawing);
Assert.Equal(1, template.Quantity);
var path = Path.Combine(directory, "mixed.nest");
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
var reloaded = new NestReader(path).Read();
Assert.Equal([new Size(6, 6), new Size(3, 3)], reloaded.Plates.Select(p => p.Size));
Assert.All(reloaded.Plates, p => Assert.Equal(1, p.Quantity));
Assert.Equal(1, reloaded.Drawings.Single(d => d.Name == "large").Quantity.Nested);
Assert.Equal(1, reloaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
}
[Fact]
public void FiniteStockOverdrawIsRejectedBeforeAnySessionMutation()
{
var session = Session();
var plate = session.GetPlate(0);
plate.Size = new Size(3, 3);
var engine = "Overdraw-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "overdraw stock", () => new InspectStub(job =>
new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, job.Plates[0], [new NestJobPlacement(job.Parts[0].Id, 0, 0.5, 0.5, 0)]),
new NestJobPlateResult(1, job.Plates[0], [new NestJobPlacement(job.Parts[1].Id, 1, 0.5, 0.5, 0)])], [], [])));
var text = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", engine,
sheets: [new SheetStockInput { Width = 3, Length = 3, Quantity = 1 },
new SheetStockInput { Width = 6, Length = 6, Quantity = 2 }]);
Assert.Contains("stock-0", text);
Assert.Contains("invalid", text, StringComparison.OrdinalIgnoreCase);
Assert.Single(session.AllPlates());
Assert.Empty(plate.Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
}
[Fact]
public void FiniteStockRejectsInvalidInventoryBeforeSolving()
{
var session = Session();
var plate = session.GetPlate(0);
var engine = "NeverSolve-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "should not be called", () => new InspectStub(_ =>
throw new Xunit.Sdk.XunitException("invalid input reached solver")));
var tools = new NestingTools(session);
var valid = new SheetStockInput { Width = 10, Length = 10, Quantity = 1 };
Assert.Contains("at least one", tools.AutoNestJob(0, "A", "1", engine, sheets: []));
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = 0 }]));
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = double.PositiveInfinity, Length = 10, Quantity = 1 }]));
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = 0, Length = 10, Quantity = 1 }]));
Assert.Contains("sheets[1] duplicates sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
sheets: [valid, new SheetStockInput { Width = 10, Length = 10, Quantity = 2 }]));
Assert.Contains("sheet inventory exceeds", tools.AutoNestJob(0, "A", "1", engine,
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = int.MaxValue },
new SheetStockInput { Width = 11, Length = 11, Quantity = 1 }]));
Assert.Contains("cannot be combined", tools.AutoNestJob(0, "A", "1", engine,
no_new_plates: true, sheets: [valid]));
Assert.Single(session.AllPlates());
Assert.Empty(plate.Parts);
Assert.Equal(1, plate.Quantity);
}
[Fact]
public void FiniteInventoryUsesOnlyOneOfTwoMatchingEmptySheets()
{
var session = Session();
var original = session.GetPlate(0);
session.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 5 });
var text = new NestingTools(session).AutoNestJob(0, "A", "1", "Rectangles",
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = 1 }]);
Assert.Contains("Whole job complete", text);
Assert.Contains("stock-0 (10x10): used=1, remaining=0", text);
Assert.Single(original.Parts);
Assert.Empty(session.GetPlate(1).Parts);
Assert.Equal(5, session.GetPlate(1).Quantity);
}
[Fact]
public void LaterSelectedTemplateIsReusedBeforeOtherCompatibleEmptySheet()
{
var session = Session();
var earlier = session.GetPlate(0);
var selected = new Plate(10, 10) { PartSpacing = 0.25, Quantity = 3 };
session.Plates.Add(selected);
var text = new NestingTools(session).AutoNestJob(1, "A", "1", "Rectangles");
Assert.Contains("Whole job complete", text);
Assert.Empty(earlier.Parts);
Assert.Single(selected.Parts);
Assert.Equal(1, selected.Quantity);
}
[Fact]
public void CreatesSheetsForCompleteMixedJobThenSavesAndReloads()
{
+9
View File
@@ -0,0 +1,9 @@
namespace OpenNest.Mcp;
/// <summary>Request-local physical inventory for one whole-job solve.</summary>
public sealed class SheetStockInput
{
public double Width { get; set; }
public double Length { get; set; }
public int Quantity { get; set; }
}
+74 -12
View File
@@ -336,14 +336,15 @@ namespace OpenNest.Mcp.Tools
}
[McpServerTool(Name = "autonest_job")]
[Description("Complete requested drawing quantities across as many matching sheets as needed. The selected empty plate is the stock template; by default new sheets may be added. An incomplete or invalid proposal changes nothing.")]
[Description("Complete requested drawing quantities across physical sheets. The selected empty plate supplies common settings; optional sheets offer finite sizes and quantities. An incomplete or invalid proposal changes nothing.")]
public string AutoNestJob(
[Description("Index of an empty plate to use as the stock template")] int plateIndex,
[Description("Comma-separated drawing names")] string drawingNames,
[Description("Comma-separated total requested quantities for the entire session")] string quantities,
[Description(JobEngines)] string engine = null,
[Description("Use only already-created empty plates with matching dimensions and spacing; do not add sheets")] bool no_new_plates = false,
CancellationToken cancellationToken = default
CancellationToken cancellationToken = default,
[Description("Optional finite physical sheet stock: width, length and available quantity per size. Incompatible with no_new_plates.")] SheetStockInput[] sheets = null
)
{
var template = _session.GetPlate(plateIndex);
@@ -351,6 +352,11 @@ namespace OpenNest.Mcp.Tools
return $"Error: plate {plateIndex} not found";
if (template.Parts.Count != 0 || template.CutOffs.Count != 0)
return "Error: stock template must be empty and have no cutoff definitions. Nothing committed.";
if (sheets != null && no_new_plates)
return "Error: sheets cannot be combined with no_new_plates. Nothing committed.";
var offeredStock = BuildSheetStock(template, sheets);
if (offeredStock.error != null)
return offeredStock.error;
if (string.IsNullOrWhiteSpace(drawingNames) || string.IsNullOrWhiteSpace(quantities))
return "Error: drawingNames and quantities are required";
var parsed = ParseItems(drawingNames, quantities);
@@ -390,7 +396,8 @@ namespace OpenNest.Mcp.Tools
var demand = remaining.Sum(item => (long)item.Quantity);
if (demand > int.MaxValue)
return "Error: total remaining demand exceeds the supported sheet limit. Nothing committed.";
var limit = no_new_plates ? System.Math.Min(available.Count, (int)demand) : (int)demand;
var limit = no_new_plates ? System.Math.Min(available.Count, (int)demand)
: sheets != null ? (int)System.Math.Min(offeredStock.total, demand) : (int)demand;
var engineName = string.IsNullOrWhiteSpace(engine) ? _session.DefaultEngineName : engine.Trim();
if (NestingEngineRegistry.ResolveName(engineName) == null)
return $"Error: unknown whole-job engine '{engineName}'. Nothing committed. Available: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}";
@@ -398,7 +405,7 @@ namespace OpenNest.Mcp.Tools
try
{
result = NestPipeline.Run(new NestPipelineRequest(engineName, remaining,
NestStockBuilder.FromTemplate(template, null, no_new_plates ? available.Count : null),
sheets == null ? NestStockBuilder.FromTemplate(template, null, no_new_plates ? available.Count : null) : offeredStock.stock,
new NestJobOptions(maxPlates: limit)), token: cancellationToken);
}
catch (NotSupportedException ex)
@@ -422,33 +429,88 @@ namespace OpenNest.Mcp.Tools
if (result.Plates.Count > limit)
return "Error: proposal exceeds the allowed sheet count. Nothing committed.";
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < result.Plates.Count; i++)
// Build the entire target map before attaching a single part. Indexes in a mixed
// stock proposal are solver ordinals, not indexes into the session's empty plates.
var usedTargets = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
var targets = new List<(Plate plate, bool created, string stockId)>();
foreach (var proposed in result.Plates)
{
var proposed = result.Plates[i];
var target = i < available.Count ? available[i] : new Plate(proposed.Stock.Size)
var target = new[] { template }.Concat(existing.Where(p => !ReferenceEquals(p, template)))
.FirstOrDefault(p => !usedTargets.Contains(p)
&& p.Parts.Count == 0 && p.CutOffs.Count == 0
&& SameStock(p, template, proposed.Stock.Size));
var created = target == null;
target ??= new Plate(proposed.Stock.Size)
{
GrainAngle = template.GrainAngle,
CuttingParameters = template.CuttingParameters,
};
usedTargets.Add(target);
targets.Add((target, created, proposed.Stock.Id));
}
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < result.Plates.Count; i++)
{
var proposed = result.Plates[i];
var (target, created, _) = targets[i];
target.Size = proposed.Stock.Size;
target.PartSpacing = proposed.Stock.PartSpacing;
target.EdgeSpacing = proposed.Stock.EdgeSpacing;
target.Quadrant = proposed.Stock.Quadrant;
target.Quantity = 1;
if (i >= available.Count)
if (created)
_session.Plates.Add(target);
target.Parts.AddRange(proposed.Parts);
}
var lines = new StringBuilder();
lines.AppendLine($"Whole job complete: {result.Plates.Count} sheet(s) placed, {System.Math.Max(0, result.Plates.Count - available.Count)} new sheet(s) created.");
lines.AppendLine($"Whole job complete: {result.Plates.Count} sheet(s) placed, {targets.Count(t => t.created)} new sheet(s) created.");
if (sheets != null)
{
foreach (var stock in offeredStock.stock)
{
var used = targets.Count(t => t.stockId == stock.Id);
lines.AppendLine($" {stock.Id} ({stock.Size.Width}x{stock.Size.Length}): used={used}, remaining={stock.Quantity - used}, reused={targets.Count(t => !t.created && t.stockId == stock.Id)}, created={targets.Count(t => t.created && t.stockId == stock.Id)}");
}
foreach (var (target, _, stockId) in targets)
lines.AppendLine($" plate {_session.AllPlates().FindIndex(p => ReferenceEquals(p, target))} -> {stockId}");
}
foreach (var item in parsed.items)
lines.AppendLine($" {item.Drawing.Name}: requested={item.Quantity}, already={counts[item.Drawing]}, newly placed={placed.GetValueOrDefault(item.Drawing)}, remaining=0");
return lines.ToString();
}
private static bool SameStock(Plate a, Plate b) =>
a.Size.Width == b.Size.Width && a.Size.Length == b.Size.Length
private static (List<NestPlateStock> stock, long total, string error) BuildSheetStock(Plate template, SheetStockInput[] sheets)
{
if (sheets == null)
return (null, 0, null);
if (sheets.Length == 0)
return (null, 0, "Error: sheets must contain at least one stock size. Nothing committed.");
var stock = new List<NestPlateStock>(sheets.Length);
long total = 0;
for (var i = 0; i < sheets.Length; i++)
{
var row = sheets[i];
if (row == null || !double.IsFinite(row.Width) || !double.IsFinite(row.Length)
|| row.Width <= 0 || row.Length <= 0 || row.Quantity <= 0
|| row.Length <= template.EdgeSpacing.Left + template.EdgeSpacing.Right
|| row.Width <= template.EdgeSpacing.Top + template.EdgeSpacing.Bottom)
return (null, 0, $"Error: sheets[{i}] requires finite positive usable dimensions and a positive physical quantity. Nothing committed.");
var duplicate = stock.FindIndex(s => s.Size.Width == row.Width && s.Size.Length == row.Length);
if (duplicate >= 0)
return (null, 0, $"Error: sheets[{i}] duplicates sheets[{duplicate}] size. Nothing committed.");
total += row.Quantity;
if (total > int.MaxValue)
return (null, 0, "Error: total sheet inventory exceeds the supported sheet limit. Nothing committed.");
stock.Add(new NestPlateStock($"stock-{i}", new Size(row.Width, row.Length), row.Quantity,
template.PartSpacing, template.EdgeSpacing, template.Quadrant));
}
return (stock, total, null);
}
private static bool SameStock(Plate a, Plate b) => SameStock(a, b, b.Size);
private static bool SameStock(Plate a, Plate b, Size size) =>
a.Size.Width == size.Width && a.Size.Length == size.Length
&& a.PartSpacing == b.PartSpacing && a.EdgeSpacing.Equals(b.EdgeSpacing)
&& a.Quadrant == b.Quadrant && a.GrainAngle == b.GrainAngle
&& (a.CuttingParameters == null && b.CuttingParameters == null
+3 -1
View File
@@ -50,7 +50,9 @@ 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)` is the whole-session completion path. Quantities are total targets across existing placed parts (physical plate quantity included) plus newly proposed 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` limits the solve to existing matching empty plates. 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` limits this legacy solve to existing matching empty plates. 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 used/remaining/reused/created physical counts, plus committed session plate 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.
MCP `delete_plate(plateIndex)` removes only a sheet with no parts and no cutoff definitions, from either loaded or newly created session plates. Occupied, cutoff-bearing and invalid indices are refused without changing the session. After deletion every later index shifts down by one; inspect `get_plate_info` before further edits. This is session cleanup, not a data purge or a save-time option. `save_nest` lists all session plates in its response, but the existing `.nest` writer omits empty cutoff-free plates from the serialized file; reloading may therefore have fewer plates than the response says. There is no opt-in switch to preserve empty plates in the file.