From f1d1df3f17cf6ca8b5c4b9cd9f63b37150f15cda Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 12:34:30 -0400 Subject: [PATCH] fix(mcp): preserve job constraints and compatible stock --- OpenNest.FrontEnd.Tests/McpWholeJobTests.cs | 150 ++++++++++++++++++-- OpenNest.Mcp/Tools/NestingTools.cs | 32 ++++- docs/automatic-nesting.md | 2 +- 3 files changed, 163 insertions(+), 21 deletions(-) diff --git a/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs b/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs index 84fc168..3669fe7 100644 --- a/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs +++ b/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs @@ -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(() => + 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? 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? 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? progress = null, + CancellationToken token = default) => throw new NotSupportedException("unsupported fixture"); + } + private sealed class TwoSheetStub(bool overlap = false) : INestingEngine { public NestJobResult Solve(NestJob job, IProgress? progress = null, diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 9f7cb2b..4d40378 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -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 items, IEnumerable 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); diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index 5bed13f..45eca4f 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -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.