From 81404fc67344489505b8f3c2fad83c3390f08244 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 12:22:40 -0400 Subject: [PATCH] feat(mcp): complete whole jobs across matching plates --- OpenNest.FrontEnd.Tests/McpWholeJobTests.cs | 169 ++++++++++++++++++++ OpenNest.Mcp/Tools/NestingTools.cs | 108 +++++++++++++ docs/automatic-nesting.md | 4 +- 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 OpenNest.FrontEnd.Tests/McpWholeJobTests.cs diff --git a/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs b/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs new file mode 100644 index 0000000..84fc168 --- /dev/null +++ b/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs @@ -0,0 +1,169 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.IO; +using OpenNest.Mcp; +using OpenNest.Mcp.Tools; + +namespace OpenNest.FrontEnd.Tests; + +[Collection("FrontEndRegistry")] +public class McpWholeJobTests : IDisposable +{ + private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-mcp-job-" + Guid.NewGuid()); + public McpWholeJobTests() => Directory.CreateDirectory(directory); + public void Dispose() => Directory.Delete(directory, true); + + private static Drawing Square(string name) + { + 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))); + return new Drawing(name, OpenNest.Converters.ConvertGeometry.ToProgram(shape)); + } + + private static NestSession Session() + { + var session = new NestSession { Nest = new Nest("job") }; + session.Nest.Drawings.Add(Square("A")); + session.Nest.Drawings.Add(Square("B")); + session.Nest.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 1 }); + return session; + } + + private static string Call(NestingTools tools, int plateIndex, string names, string quantities, + string engine, bool noNew = false) + { + var method = typeof(NestingTools).GetMethod("AutoNestJob"); + Assert.NotNull(method); + var args = method.GetParameters().Select(p => p.Name switch + { + "plateIndex" => (object)plateIndex, + "drawingNames" => names, + "quantities" => quantities, + "engine" => engine, + "no_new_plates" => noNew, + _ => p.DefaultValue, + }).ToArray(); + return (string)method.Invoke(tools, args)!; + } + + [Fact] + public void CreatesSheetsForCompleteMixedJobThenSavesAndReloads() + { + var session = Session(); + var original = session.GetPlate(0); + var engine = "JobTwoSheets-" + Guid.NewGuid(); + NestingEngineRegistry.Register(engine, "two safe sheets", () => new TwoSheetStub()); + var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine); + Assert.Contains("complete", text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(2, session.AllPlates().Count); + Assert.Same(original, session.GetPlate(0)); + Assert.Single(session.Plates); + Assert.All(session.AllPlates(), plate => + { + Assert.Equal(2, plate.Parts.Count); + Assert.Equal(0.25, plate.PartSpacing); + Assert.Equal(1, plate.Quantity); + }); + Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested); + Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested); + var path = Path.Combine(directory, "complete.nest"); + Assert.Contains("Saved nest", new InputTools(session).SaveNest(path)); + var loaded = new NestReader(path).Read(); + Assert.Equal(2, loaded.Plates.Count); + Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "A").Quantity.Nested); + Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "B").Quantity.Nested); + } + + [Fact] + public void NoNewPlateFlagRefusesIncompleteProposalWithoutMutation() + { + var session = Session(); + var original = session.GetPlate(0); + var engine = "JobLimited-" + Guid.NewGuid(); + NestingEngineRegistry.Register(engine, "one sheet when capped", () => new TwoSheetStub()); + var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine, noNew: true); + Assert.Contains("incomplete", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Nothing committed", text); + Assert.Single(session.AllPlates()); + Assert.Same(original, session.GetPlate(0)); + Assert.Empty(original.Parts); + Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested)); + } + + [Fact] + public void ExistingQuantityAndPrecreatedSheetsCompleteWithRealEngine() + { + var session = Session(); + var occupied = session.GetPlate(0); + occupied.Quantity = 2; + occupied.Parts.Add(new Part(session.Nest.Drawings.Single(d => d.Name == "A"))); + var first = new Plate(3, 3) { PartSpacing = 0.25, Quantity = 1 }; + var second = new Plate(3, 3) { PartSpacing = 0.25, Quantity = 1 }; + session.Plates.Add(first); + session.Plates.Add(second); + var text = Call(new NestingTools(session), 1, "A,B", "3,1", "Rectangles", noNew: true); + Assert.Contains("Whole job complete", text); + Assert.Contains("0 new sheet(s)", text); + Assert.Same(first, session.GetPlate(1)); + Assert.Same(second, session.GetPlate(2)); + Assert.Equal(3, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested); + Assert.Equal(1, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested); + Assert.Equal(3, session.AllPlates().Count); + var path = Path.Combine(directory, "existing.nest"); + Assert.Contains("Saved nest", new InputTools(session).SaveNest(path)); + var loaded = new NestReader(path).Read(); + Assert.Equal(3, loaded.Plates.Count); + Assert.Equal(3, loaded.Drawings.Single(d => d.Name == "A").Quantity.Nested); + Assert.Equal(1, loaded.Drawings.Single(d => d.Name == "B").Quantity.Nested); + } + + [Fact] + public void RealEngineAddsSheetsWhenOnlyTemplateExists() + { + var session = Session(); + session.GetPlate(0).Size = new Size(3, 3); + var text = Call(new NestingTools(session), 0, "A,B", "2,2", "Rectangles"); + Assert.Contains("Whole job complete", text); + Assert.Equal(4, session.AllPlates().Count); + Assert.All(session.AllPlates(), p => Assert.Single(p.Parts)); + Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested); + Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested); + } + + [Fact] + public void InvalidPluginLayoutCannotCreateOrOccupySheets() + { + var session = Session(); + var original = session.GetPlate(0); + var engine = "JobOverlap-" + Guid.NewGuid(); + NestingEngineRegistry.Register(engine, "invalid layout", () => new TwoSheetStub(overlap: true)); + var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine); + Assert.Contains("invalid", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Nothing committed", text); + Assert.Single(session.AllPlates()); + Assert.Same(original, session.GetPlate(0)); + Assert.Empty(original.Parts); + Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested)); + } + + private sealed class TwoSheetStub(bool overlap = false) : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + var stock = Assert.Single(job.Plates); + var sheets = new List(); + for (var i = 0; i < System.Math.Min(2, stock.Quantity ?? 2); i++) + sheets.Add(new NestJobPlateResult(i, stock, + [new NestJobPlacement(job.Parts[0].Id, i, 1, 1, 0), + new NestJobPlacement(job.Parts[1].Id, i, overlap ? 1 : 5, 1, 0)])); + return new NestJobResult(sheets.Count == 2 ? NestJobStatus.Complete : NestJobStatus.Incomplete, + sheets.Count == 2 ? NestJobStopReason.Completed : NestJobStopReason.StockExhausted, + sheets, [], []); + } + } +} diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index c8f90f8..9f7cb2b 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -334,6 +334,114 @@ namespace OpenNest.Mcp.Tools return sb.ToString(); } + [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.")] + 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 + ) + { + var template = _session.GetPlate(plateIndex); + if (template == null) + 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 (string.IsNullOrWhiteSpace(drawingNames) || string.IsNullOrWhiteSpace(quantities)) + return "Error: drawingNames and quantities are required"; + var parsed = ParseItems(drawingNames, quantities); + if (parsed.error != null) + return parsed.error; + if (parsed.items.Any(item => item.Quantity <= 0)) + return "Error: whole-job quantities must be positive"; + + var existing = _session.AllPlates(); + if (existing.Any(p => p.Parts.Count > 0 && p.Quantity <= 0)) + return "Error: an occupied plate has a nonpositive quantity. Nothing committed."; + var remaining = new List(parsed.items.Count); + var counts = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var item in parsed.items) + { + var nested = existing.Sum(p => (long)p.Parts.Count(part => ReferenceEquals(part.BaseDrawing, item.Drawing)) * p.Quantity); + if (nested > item.Quantity) + return $"Error: drawing '{item.Drawing.Name}' already exceeds requested quantity ({nested} > {item.Quantity}). Nothing committed."; + counts[item.Drawing] = nested; + if (nested < item.Quantity) + remaining.Add(new NestItem { Drawing = item.Drawing, Quantity = (int)(item.Quantity - nested), Priority = item.Priority }); + } + if (remaining.Count == 0) + return "Job already complete: all requested quantities are present. Nothing committed."; + + var available = new List { template }; + available.AddRange(existing.Where(p => !ReferenceEquals(p, template) + && p.Parts.Count == 0 && p.CutOffs.Count == 0 && SameStock(p, template))); + 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 engineName = string.IsNullOrWhiteSpace(engine) ? _session.DefaultEngineName : engine.Trim(); + NestPipelineResult result; + try + { + result = NestPipeline.Run(new NestPipelineRequest(engineName, remaining, + NestStockBuilder.FromTemplate(template, null, no_new_plates ? available.Count : null), + new NestJobOptions(maxPlates: limit)), token: cancellationToken); + } + catch (NotSupportedException) + { + return UnknownEngineMessage(engineName); + } + cancellationToken.ThrowIfCancellationRequested(); + if (!result.CanKeep || !result.IsValid) + return "Error: invalid whole-job proposal. Nothing committed. " + string.Join("; ", result.Violations); + + var placed = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var sheet in result.Plates) + foreach (var part in sheet.Parts) + placed[part.BaseDrawing] = placed.GetValueOrDefault(part.BaseDrawing) + 1; + if (remaining.Any(item => placed.GetValueOrDefault(item.Drawing) != item.Quantity)) + { + var summary = string.Join(", ", remaining.Select(item => + $"{item.Drawing.Name}: {placed.GetValueOrDefault(item.Drawing)}/{item.Quantity} newly placed")); + return $"Error: incomplete whole-job proposal ({result.StopReason}); {summary}. Nothing committed. A solver's no-placement result does not prove geometric impossibility."; + } + 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++) + { + var proposed = result.Plates[i]; + var target = i < available.Count ? available[i] : new Plate(proposed.Stock.Size) + { + GrainAngle = template.GrainAngle, + CuttingParameters = template.CuttingParameters, + }; + 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) + _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."); + 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 + && a.PartSpacing == b.PartSpacing && a.EdgeSpacing.Equals(b.EdgeSpacing) + && a.Quadrant == b.Quadrant && a.GrainAngle == b.GrainAngle + && ReferenceEquals(a.CuttingParameters, b.CuttingParameters); + private static void AppendMixSummary(StringBuilder sb, IReadOnlyList items, IEnumerable newlyPlaced) { var counts = new Dictionary(ReferenceEqualityComparer.Instance); diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index d274f97..5bed13f 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -48,7 +48,9 @@ Invalid output is printed and rejected with exit code 2 without saving or postin MCP `autonest_plate` requires an empty target. The stdio server serializes all tool calls sharing its mutable session, so another request cannot change drawings or occupy a target during a solve. `allow_invalid` defaults to false. It reports violations and makes no changes on rejection, including with an override when the output is unrepresentable or contains multiple sheets. `engine` defaults to Default, and its description lists the built-in engines with what each suits. Fill tools (`fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`) remain separate and default to the Fill strategy. Console and MCP load jobs plug-ins from `Engines/` beside their executable. -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 tools operate on a single selected plate and do not yet automatically finish a multi-sheet job; callers must track and place the outstanding quantities. 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. +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 and spacings; other matching empty plates 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. 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.