feat(mcp): complete whole jobs across matching plates

This commit is contained in:
aj committed 2026-10-09 12:22:40 -04:00
1 parent a5b6c8a00e
commit 81404fc673
3 files changed
+280 -1

No files matched your search

+169
View File
@@ -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<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var stock = Assert.Single(job.Plates);
var sheets = new List<NestJobPlateResult>();
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, [], []);
}
}
}
+108
View File
@@ -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<NestItem>(parsed.items.Count);
var counts = new Dictionary<Drawing, long>(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<Plate> { 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<Drawing, long>(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<NestItem> items, IEnumerable<Part> newlyPlaced)
{
var counts = new Dictionary<Drawing, int>(ReferenceEqualityComparer.Instance);
+3 -1
View File
@@ -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.