diff --git a/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs b/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs index 3669fe7..37b8f3b 100644 --- a/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs +++ b/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs @@ -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 solve) : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? 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() { diff --git a/OpenNest.Mcp/SheetStockInput.cs b/OpenNest.Mcp/SheetStockInput.cs new file mode 100644 index 0000000..2ccf5cf --- /dev/null +++ b/OpenNest.Mcp/SheetStockInput.cs @@ -0,0 +1,9 @@ +namespace OpenNest.Mcp; + +/// Request-local physical inventory for one whole-job solve. +public sealed class SheetStockInput +{ + public double Width { get; set; } + public double Length { get; set; } + public int Quantity { get; set; } +} diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 4d40378..6ecfb12 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -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(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 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(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 diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index 45eca4f..c25db52 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -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.