mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-11 17:54:40 -04:00
feat(mcp): complete whole jobs across matching plates
This commit is contained in:
1 parent
a5b6c8a00e
commit
81404fc673
3 files changed
+280
-1
No files matched your search
@@ -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, [], []);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user