mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 07:34:14 -04:00
fix(mcp): preserve job constraints and compatible stock
This commit is contained in:
1 parent
81404fc673
commit
f1d1df3f17
3 files changed
+163
-21
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user