fix(mcp): preserve job constraints and compatible stock

This commit is contained in:
aj committed 2026-10-09 12:34:30 -04:00
1 parent 81404fc673
commit f1d1df3f17
3 files changed
+163 -21

No files matched your search

+135 -15
View File
@@ -1,4 +1,5 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.IO;
@@ -34,21 +35,8 @@ public class McpWholeJobTests : IDisposable
}
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)!;
}
string engine, bool noNew = false, CancellationToken token = default) =>
tools.AutoNestJob(plateIndex, names, quantities, engine, noNew, token);
[Fact]
public void CreatesSheetsForCompleteMixedJobThenSavesAndReloads()
@@ -150,6 +138,138 @@ public class McpWholeJobTests : IDisposable
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
}
[Fact]
public void RestrictedDrawingRejectsPluginRotationWithoutMutationAfterReload()
{
var session = Session();
var drawing = session.Nest.Drawings.Single(d => d.Name == "A");
drawing.Constraints = new NestConstraints
{
StepAngle = OpenNest.Math.Angle.TwoPI,
StartAngle = 0,
EndAngle = 0,
};
session.GetPlate(0).Parts.Add(new Part(session.Nest.Drawings.Single(d => d.Name == "B")));
var path = Path.Combine(directory, "rotation.nest");
Assert.True(new NestWriter(session.Nest).Write(path));
session = new NestSession { Nest = new NestReader(path).Read() };
Assert.Equal(OpenNest.Math.Angle.TwoPI, session.Nest.Drawings.Single(d => d.Name == "A").Constraints.StepAngle);
session.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 1 });
var engine = "JobForbiddenRotation-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "rotated proposal", () => new RotatedStub());
var plateTool = new NestingTools(session).AutoNestPlate(1, "A", "1", engine);
Assert.Contains("Violation", plateTool);
Assert.Contains("rotation", plateTool, StringComparison.OrdinalIgnoreCase);
Assert.Empty(session.GetPlate(1).Parts);
var text = Call(new NestingTools(session), 1, "A", "1", engine);
Assert.True(text.Contains("rotation", StringComparison.OrdinalIgnoreCase), text);
Assert.Contains("Nothing committed", text);
Assert.Equal(2, session.AllPlates().Count);
Assert.Empty(session.GetPlate(1).Parts);
Assert.Single(session.GetPlate(0).Parts);
Assert.Equal(0, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
}
[Fact]
public void EquivalentDeserializedCuttingSettingsReuseExistingSheets()
{
var session = Session();
var first = session.GetPlate(0);
first.Size = new Size(3, 3);
first.CuttingParameters = new CuttingParameters { MachineName = "Same" };
var second = new Plate(3, 3)
{
Quantity = 1,
PartSpacing = 0.25,
CuttingParameters = new CuttingParameters { MachineName = "Same" }
};
session.Plates.Add(second);
Assert.NotSame(first.CuttingParameters, second.CuttingParameters);
var text = Call(new NestingTools(session), 0, "A", "2", "Rectangles", noNew: true);
Assert.Contains("Whole job complete", text);
Assert.Equal(2, session.AllPlates().Count);
Assert.Single(first.Parts);
Assert.Single(second.Parts);
}
[Fact]
public void DifferentCuttingSettingsAreNotReusedAsStock()
{
var session = Session();
var first = session.GetPlate(0);
first.Size = new Size(3, 3);
first.CuttingParameters = new CuttingParameters { MachineName = "First" };
var second = new Plate(3, 3)
{
Quantity = 1,
PartSpacing = 0.25,
CuttingParameters = new CuttingParameters { MachineName = "Other" }
};
session.Plates.Add(second);
var text = Call(new NestingTools(session), 0, "A", "2", "Rectangles", noNew: true);
Assert.Contains("incomplete", text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Nothing committed", text);
Assert.Empty(first.Parts);
Assert.Empty(second.Parts);
Assert.Equal(2, session.AllPlates().Count);
}
[Fact]
public void SolverNotSupportedErrorNamesActualCause()
{
var session = Session();
var engine = "JobUnsupported-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "unsupported solver", () => new UnsupportedStub());
var text = Call(new NestingTools(session), 0, "A", "1", engine);
Assert.Contains("unsupported fixture", text);
Assert.DoesNotContain("Use autonest_plate", text);
Assert.Contains("Nothing committed", text);
Assert.Empty(session.GetPlate(0).Parts);
}
[Fact]
public void CancellationAfterPluginIgnoresTokenDoesNotCommit()
{
var session = Session();
using var cts = new CancellationTokenSource();
var engine = "JobCancel-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "cancels after solving", () => new CancelStub(cts));
Assert.ThrowsAny<OperationCanceledException>(() =>
Call(new NestingTools(session), 0, "A,B", "2,2", engine, token: cts.Token));
Assert.Single(session.AllPlates());
Assert.Empty(session.GetPlate(0).Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
}
private sealed class CancelStub(CancellationTokenSource source) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var result = new TwoSheetStub().Solve(job, progress, token);
source.Cancel();
return result;
}
}
private sealed class RotatedStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var stock = Assert.Single(job.Plates);
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, stock,
[new NestJobPlacement(job.Parts[0].Id, 0, 4, 4, System.Math.PI / 2)])], [], []);
}
}
private sealed class UnsupportedStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => throw new NotSupportedException("unsupported fixture");
}
private sealed class TwoSheetStub(bool overlap = false) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
+27 -5
View File
@@ -10,6 +10,7 @@ using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Mcp.Tools
{
@@ -370,7 +371,15 @@ namespace OpenNest.Mcp.Tools
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 });
remaining.Add(new NestItem
{
Drawing = item.Drawing,
Quantity = (int)(item.Quantity - nested),
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
});
}
if (remaining.Count == 0)
return "Job already complete: all requested quantities are present. Nothing committed.";
@@ -383,6 +392,8 @@ namespace OpenNest.Mcp.Tools
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();
if (NestingEngineRegistry.ResolveName(engineName) == null)
return $"Error: unknown whole-job engine '{engineName}'. Nothing committed. Available: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}";
NestPipelineResult result;
try
{
@@ -390,9 +401,9 @@ namespace OpenNest.Mcp.Tools
NestStockBuilder.FromTemplate(template, null, no_new_plates ? available.Count : null),
new NestJobOptions(maxPlates: limit)), token: cancellationToken);
}
catch (NotSupportedException)
catch (NotSupportedException ex)
{
return UnknownEngineMessage(engineName);
return $"Error: whole-job request unsupported: {ex.Message}. Nothing committed.";
}
cancellationToken.ThrowIfCancellationRequested();
if (!result.CanKeep || !result.IsValid)
@@ -440,7 +451,10 @@ namespace OpenNest.Mcp.Tools
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);
&& (a.CuttingParameters == null && b.CuttingParameters == null
|| a.CuttingParameters != null && b.CuttingParameters != null
&& string.Equals(CuttingParametersSerializer.Serialize(a.CuttingParameters),
CuttingParametersSerializer.Serialize(b.CuttingParameters), StringComparison.Ordinal));
private static void AppendMixSummary(StringBuilder sb, IReadOnlyList<NestItem> items, IEnumerable<Part> newlyPlaced)
{
@@ -528,7 +542,15 @@ namespace OpenNest.Mcp.Tools
if (!seen.Add(drawing))
return (null, $"Error: duplicate drawing '{names[i]}' in request");
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] });
items.Add(new NestItem
{
Drawing = drawing,
Quantity = qtys[i],
Priority = drawing.Priority,
StepAngle = drawing.Constraints?.StepAngle ?? 0,
RotationStart = drawing.Constraints?.StartAngle ?? 0,
RotationEnd = drawing.Constraints?.EndAngle ?? 0,
});
}
return (items, null);
+1 -1
View File
@@ -50,7 +50,7 @@ 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 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.
`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.
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.