From 391a27b2f9331c9511396f030408f7c4321129bf Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 18:46:28 -0400 Subject: [PATCH] feat(console,mcp): add cut planning to the headless front ends Console --plan-cutting and MCP plan_cutting/apply_cutting_plan run the desktop's CuttingPlanBatch: capture, plan, then an all-or-nothing apply. Settings come from an explicit JSON file or the plates' identical saved settings, never defaults. Unverified plans need per-call acceptance, which is not posting consent. Console --no-fill plans a loaded nest as is; a blocked plan exits 2 and saves nothing. Scrap cut-offs stay desktop-only. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Console/Program.cs | 227 ++++++++++++++--- .../CuttingPlanning/CuttingPlanBatch.cs | 10 +- .../CuttingPlanFrontEndTests.cs | 238 ++++++++++++++++++ OpenNest.IO/CuttingParametersSerializer.cs | 11 + OpenNest.Mcp/NestSession.cs | 7 + OpenNest.Mcp/Tools/CuttingTools.cs | 170 +++++++++++++ README.md | 4 + docs/cutting-planner.md | 29 ++- 8 files changed, 660 insertions(+), 36 deletions(-) create mode 100644 OpenNest.FrontEnd.Tests/CuttingPlanFrontEndTests.cs create mode 100644 OpenNest.Mcp/Tools/CuttingTools.cs diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 53bc0c53..d9464c37 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -5,11 +5,15 @@ using System.Globalization; using System.IO; using System.Linq; using System.Reflection; +using System.Text.Json; using System.Threading; using OpenNest; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; using OpenNest.Diagnostics; using OpenNest.Engine; using OpenNest.Engine.BestFit; +using OpenNest.Engine.CuttingPlanning; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Placement; using OpenNest.Geometry; @@ -117,6 +121,25 @@ static class NestConsole } } + if (!options.PlanCutting && (options.CuttingParamsFile != null || options.KeepPartOrder + || options.AcceptUnverifiedPlan || options.AllPlates)) + { + Console.Error.WriteLine( + "Error: --cutting-params, --keep-part-order, --accept-unverified-plan and --all-plates require --plan-cutting." + ); + return 1; + } + + if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null + || options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue + || options.TemplateFile != null || options.InputFiles.Any(IsCadFile))) + { + Console.Error.WriteLine( + "Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template." + ); + return 1; + } + if (options.InputFiles.Count == 0) { PrintUsage(); @@ -132,42 +155,74 @@ static class NestConsole } } + // Read explicit settings before nesting, so a bad file fails without wasting the fill. + CuttingParameters cuttingParameters = null; + if (options.CuttingParamsFile != null) + { + try + { + cuttingParameters = CuttingParametersSerializer.DeserializeExplicit( + File.ReadAllText(options.CuttingParamsFile)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException + or NotSupportedException) + { + Console.Error.WriteLine($"Error: cannot read cutting settings '{options.CuttingParamsFile}': {ex.Message}"); + return 1; + } + } + var nest = LoadOrCreateNest(options); if (nest == null) return 1; var plate = nest.Plates[options.PlateIndex]; + IReadOnlyList plates = new[] { plate }; + var overlapCount = 0; - if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0) + if (options.NoFill) { - Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles."); - return 2; + Console.WriteLine($"Nest: {nest.Name}"); + overlapCount = CheckOverlaps(nest, plates, options); + } + else + { + if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0) + { + Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles."); + return 2; + } + + ApplyTemplate(plate, options); + ApplyOverrides(plate, options); + + var drawing = ResolveDrawing(nest, options); + + if (drawing == null) + return 1; + + var existingCount = plate.Parts.Count; + + if (!options.AutoNest && !options.KeepParts) + plate.Parts.Clear(); + + PrintHeader(nest, plate, drawing, existingCount, options); + + var (success, elapsed, accepted, filled) = Fill(nest, plate, drawing, options, token); + if (!accepted) + return 2; // No save or post may run after a rejected proposal. + + plates = filled; + overlapCount = CheckOverlaps(nest, plates, options); + + PrintResults(success, nest, plates, elapsed); } - ApplyTemplate(plate, options); - ApplyOverrides(plate, options); - - var drawing = ResolveDrawing(nest, options); - - if (drawing == null) - return 1; - - var existingCount = plate.Parts.Count; - - if (!options.AutoNest && !options.KeepParts) - plate.Parts.Clear(); - - PrintHeader(nest, plate, drawing, existingCount, options); - - var (success, elapsed, accepted, plates) = Fill(nest, plate, drawing, options, token); - if (!accepted) - return 2; // No save or post may run after a rejected proposal. - - var overlapCount = CheckOverlaps(nest, plates, options); - - PrintResults(success, nest, plates, elapsed); token.ThrowIfCancellationRequested(); + if (options.PlanCutting && !PlanCutting(nest, options.AllPlates ? nest.Plates : plates, + cuttingParameters, options, token)) + return 2; // Like a rejected nest: nothing is saved or posted. if (!SaveAndPost(nest, options)) return 1; @@ -265,6 +320,24 @@ static class NestConsole case "--list-posts": o.ListPosts = true; break; + case "--no-fill": + o.NoFill = true; + break; + case "--plan-cutting": + o.PlanCutting = true; + break; + case "--cutting-params" when i + 1 < args.Length: + o.CuttingParamsFile = args[++i]; + break; + case "--keep-part-order": + o.KeepPartOrder = true; + break; + case "--accept-unverified-plan": + o.AcceptUnverifiedPlan = true; + break; + case "--all-plates": + o.AllPlates = true; + break; case "--help": case "-h": PrintUsage(); @@ -311,18 +384,17 @@ static class NestConsole return stock; } + static bool IsCadFile(string path) => + path.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase); + static Nest LoadOrCreateNest(Options options) { var nestFile = options.InputFiles.FirstOrDefault(f => f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase) || f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ); - var dxfFiles = options - .InputFiles.Where(f => - f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) - || f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) - ) - .ToList(); + var dxfFiles = options.InputFiles.Where(IsCadFile).ToList(); // If we have a nest file, load it and optionally add DXFs. if (nestFile != null) @@ -678,6 +750,85 @@ static class NestConsole ); } + /// + /// Plans and applies cutting (part order, contour order, entries and lead-ins) on the + /// occupied plates in scope, all or nothing. Settings are the explicit file or the plates' + /// own saved settings; none are invented. Unverified plans apply only with per-invocation + /// acceptance, which is never consent to post. + /// + static bool PlanCutting(Nest nest, IEnumerable plates, CuttingParameters explicitParameters, + Options options, CancellationToken token) + { + var scope = plates.Where(p => p.Parts.Count > 0).ToArray(); + if (scope.Length == 0) + { + Console.Error.WriteLine("Error: no occupied plates to plan cutting for. Nothing saved."); + return false; + } + + var parameters = explicitParameters ?? SavedCuttingParameters(scope); + if (parameters == null) + { + Console.Error.WriteLine( + "Error: --plan-cutting needs confirmed cutting settings: pass --cutting-params , or plan plates that all carry the same saved settings. Nothing saved." + ); + return false; + } + + Console.WriteLine("---"); + Console.WriteLine($"Cutting settings: {(explicitParameters != null ? options.CuttingParamsFile : "saved with the plates")}"); + var batch = CuttingPlanBatch.Capture(scope, parameters, options.KeepPartOrder, + scope.Select(p => nest.Plates.IndexOf(p)).ToArray(), token); + var proposal = batch.Plan(new PlanningProgress(scope.Length), token); + token.ThrowIfCancellationRequested(); + + foreach (var line in proposal.Describe(UnitsHelper.GetShortString(nest.Units), + "Pass --cutting-params settings that select")) + Console.WriteLine(line); + + if (!(proposal.CanApply || options.AcceptUnverifiedPlan && proposal.CanApplyWithWarnings)) + { + Console.Error.WriteLine(proposal.RequiresWarningAcceptance + ? "Cutting plan not applied: it is unverified. Review the warnings above; to apply it anyway, explicitly use --accept-unverified-plan for this invocation. Nothing saved." + : "Cutting plan not applied: every plate must be ready. Nothing saved."); + return false; + } + + var commit = proposal.Apply(options.AcceptUnverifiedPlan, token); + if (commit.Status != CuttingCommitStatus.Applied) + { + Console.Error.WriteLine($"Error: cutting plan not applied ({commit.Status}): {commit.Message} Nothing saved."); + return false; + } + + Console.WriteLine($"Cutting plan applied to {scope.Length} plate(s)."); + return true; + } + + /// The plates' saved settings when every plate carries the same ones; otherwise null. + static CuttingParameters SavedCuttingParameters(IReadOnlyList plates) + { + var first = plates[0].CuttingParameters; + if (first == null) + return null; + var json = CuttingParametersSerializer.Serialize(first); + return plates.All(p => p.CuttingParameters != null + && CuttingParametersSerializer.Serialize(p.CuttingParameters) == json) ? first : null; + } + + /// Reports synchronously, so phase lines print in order with the summary. + sealed class PlanningProgress(int plateCount) : IProgress + { + public void Report(CuttingPlanProgress value) => Console.WriteLine( + (plateCount > 1 ? $"Planning plate {value.PlateNumber}: " : "Planning: ") + value.Phase switch + { + CuttingPlanPhase.Reordering => "finding a part order...", + CuttingPlanPhase.KeepingOrder => "keeping the current order...", + CuttingPlanPhase.CheckingOverlap => "checking for overlaps...", + _ => value.Phase + "...", + }); + } + static void Save(Nest nest, Options options) { if (options.NoSave) @@ -855,6 +1006,14 @@ static class NestConsole --engine With --autonest: jobs engine (default: Default; also Irregular, Rectangles, Fill, ...). Without --autonest: fill strategy (Fill (default), Strip, Vertical Remnant, Horizontal Remnant) --keep-parts Don't clear existing parts before filling + --no-fill Keep the loaded .nest as it is (no fill); plan, save or post it + --plan-cutting After filling, plan cutting (part order, contour order, entries, + lead-ins); applied only when every plate is ready (else exit 2) + --cutting-params Cutting settings JSON for --plan-cutting (default: the plates' own + saved settings, which must agree; never invented) + --keep-part-order With --plan-cutting: keep the current whole-part order + --all-plates With --plan-cutting: plan every occupied plate, not just the target + --accept-unverified-plan Explicitly apply an unverified cutting plan for this invocation --check-overlaps Run overlap detection after fill (exit code 1 if found) --no-save Skip saving output file --post Run a post processor after nesting @@ -890,6 +1049,12 @@ static class NestConsole public string PostOutput; public string PostsDir; public bool ListPosts; + public bool NoFill; + public bool PlanCutting; + public string CuttingParamsFile; + public bool KeepPartOrder; + public bool AcceptUnverifiedPlan; + public bool AllPlates; public double? RepairBendsMillimeters; public BendRepairUnits CadUnits; } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs index 579dfdbc..db72f054 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs @@ -370,7 +370,9 @@ public sealed class CuttingPlanProposal } /// Operator-facing summary: one overall line, then each plate and its findings. - public IReadOnlyList Describe(string unit) + /// How this front end changes the settings, completed by + /// "a lead-in type other than None..." in the missing lead-in advice. + public IReadOnlyList Describe(string unit, string changeSettings = "Open Cutting Settings... and select") { var lines = new List(); var blocked = Plates.Count(p => !p.CanApplyWithWarnings); @@ -393,12 +395,12 @@ public sealed class CuttingPlanProposal foreach (var plate in Plates) { lines.Add(string.Empty); - lines.AddRange(DescribePlate(plate, unit)); + lines.AddRange(DescribePlate(plate, unit, changeSettings)); } return lines; } - private static IEnumerable DescribePlate(CuttingPlanPlateResult plate, string unit) + private static IEnumerable DescribePlate(CuttingPlanPlateResult plate, string unit, string changeSettings) { var heading = $"Plate {plate.PlateNumber}: "; var result = plate.Result; @@ -444,7 +446,7 @@ public sealed class CuttingPlanProposal yield break; if (missingLead) { - yield return " Open Cutting Settings... and select a lead-in type other than None with " + yield return $" {changeSettings} a lead-in type other than None with " + "nonzero length for the affected contour (External, Internal, or Arc / Circle), then replan."; yield return " If the affected part is locked, edit its lead-ins or unlock it before replanning."; } diff --git a/OpenNest.FrontEnd.Tests/CuttingPlanFrontEndTests.cs b/OpenNest.FrontEnd.Tests/CuttingPlanFrontEndTests.cs new file mode 100644 index 00000000..0fd02fc8 --- /dev/null +++ b/OpenNest.FrontEnd.Tests/CuttingPlanFrontEndTests.cs @@ -0,0 +1,238 @@ +using System.Reflection; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; +using OpenNest.IO; +using OpenNest.Mcp; +using OpenNest.Mcp.Tools; + +namespace OpenNest.FrontEnd.Tests; + +public class CuttingPlanFrontEndTests : IDisposable +{ + private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-cut-plan-" + Guid.NewGuid()); + + public CuttingPlanFrontEndTests() => Directory.CreateDirectory(directory); + public void Dispose() => Directory.Delete(directory, true); + + [Fact] + public void Console_PlanCutting_AppliesTheReadyPlanAndSavesItsSettings() + { + var input = Write(Fixture(Square(), new Vector(1, 1), new Vector(20, 1))); + var output = Path.Combine(directory, "output.nest"); + + Assert.Equal(0, RunConsole(input, "--no-fill", "--plan-cutting", "--cutting-params", Settings(), + "--output", output)); + + var plate = new NestReader(output).Read().Plates.Single(); + Assert.NotNull(plate.CuttingParameters); + Assert.Equal(2, plate.Parts.Count); + Assert.All(plate.Parts, part => Assert.True(part.HasManualLeadIns)); + } + + [Fact] + public void Console_PlanCutting_UsesThePlatesSavedSettings() + { + var nest = Fixture(Square(), new Vector(1, 1)); + nest.Plates[0].CuttingParameters = Parameters(); + var output = Path.Combine(directory, "output.nest"); + + Assert.Equal(0, RunConsole(Write(nest), "--no-fill", "--plan-cutting", "--output", output)); + + Assert.True(new NestReader(output).Read().Plates.Single().Parts.Single().HasManualLeadIns); + } + + [Theory] + [InlineData("no settings", 2)] + [InlineData("overlap", 2)] + [InlineData("null settings", 1)] + public void Console_PlanCutting_SavesNothingWithoutSettingsOrWhenBlocked(string mode, int expected) + { + var locations = mode == "overlap" ? new[] { new Vector(1, 1), new Vector(6, 6) } : new[] { new Vector(1, 1) }; + var input = Write(Fixture(Square(), locations)); + var output = Path.Combine(directory, "output.nest"); + File.WriteAllText(output, "must survive rejection"); + var args = new List { input, "--no-fill", "--plan-cutting", "--output", output }; + if (mode != "no settings") + args.AddRange(["--cutting-params", Settings(mode == "null settings" ? "null" : null)]); + + Assert.Equal(expected, RunConsole(args.ToArray())); + + Assert.Equal("must survive rejection", File.ReadAllText(output)); + } + + [Fact] + public void Console_UnverifiedPlan_AppliesOnlyWithExplicitAcceptance() + { + var input = Write(Fixture(TouchingHoles(), new Vector(1, 1))); + var output = Path.Combine(directory, "output.nest"); + File.WriteAllText(output, "must survive rejection"); + string[] args = [input, "--no-fill", "--plan-cutting", "--cutting-params", Settings(), "--output", output]; + + Assert.Equal(2, RunConsole(args)); + Assert.Equal("must survive rejection", File.ReadAllText(output)); + + Assert.Equal(0, RunConsole([.. args, "--accept-unverified-plan"])); + Assert.True(new NestReader(output).Read().Plates.Single().Parts.Single().HasManualLeadIns); + } + + [Theory] + [InlineData("--keep-part-order")] + [InlineData("--all-plates")] + [InlineData("--accept-unverified-plan")] + [InlineData("--no-fill", "--plan-cutting", "--quantity", "2")] + [InlineData("--no-fill", "--autonest")] + public void Console_PlanningOptions_AreRefusedOutOfContext(params string[] extra) + { + var input = Write(Fixture(Square(), new Vector(1, 1))); + var output = Path.Combine(directory, "output.nest"); + + Assert.Equal(1, RunConsole([input, .. extra, "--output", output])); + + Assert.False(File.Exists(output)); + } + + [Fact] + public void Mcp_PlanThenApply_ChangesNothingUntilApplied() + { + var session = new NestSession { Nest = Fixture(Square(), new Vector(1, 1), new Vector(20, 1)) }; + var tools = new CuttingTools(session); + var plate = session.Nest.Plates[0]; + var programs = plate.Parts.Select(p => p.Program).ToArray(); + + var report = tools.PlanCutting(0, cuttingParametersPath: Settings()); + + Assert.Contains("Ready to apply", report); + Assert.Contains("Call apply_cutting_plan", report); + Assert.Equal(programs, plate.Parts.Select(p => p.Program)); + Assert.Null(plate.CuttingParameters); + + Assert.StartsWith("Applied cutting plan to plate(s) 0", tools.ApplyCuttingPlan()); + Assert.All(plate.Parts, part => Assert.True(part.HasManualLeadIns)); + Assert.NotNull(plate.CuttingParameters); + Assert.StartsWith("Error: no cutting plan", tools.ApplyCuttingPlan()); // Used at most once. + } + + [Fact] + public void Mcp_PlateChangedSincePlanning_IsStaleAndChangesNothing() + { + var session = new NestSession { Nest = Fixture(Square(), new Vector(1, 1)) }; + var tools = new CuttingTools(session); + var plate = session.Nest.Plates[0]; + Assert.Contains("Ready to apply", tools.PlanCutting(-1, cuttingParametersPath: Settings())); + + plate.Parts.Add(new Part(plate.Parts[0].BaseDrawing, new Vector(40, 40))); + + Assert.Contains("changed since planning", tools.ApplyCuttingPlan()); + Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns)); + Assert.Null(plate.CuttingParameters); + } + + [Fact] + public void Mcp_UnverifiedPlan_AppliesOnlyWithAcceptWarnings() + { + var session = new NestSession { Nest = Fixture(TouchingHoles(), new Vector(1, 1)) }; + var tools = new CuttingTools(session); + var part = session.Nest.Plates[0].Parts[0]; + + Assert.Contains("acceptWarnings=true", tools.PlanCutting(cuttingParametersPath: Settings())); + Assert.StartsWith("Error: this plan is unverified", tools.ApplyCuttingPlan()); + Assert.False(part.HasManualLeadIns); + + Assert.StartsWith("Applied", tools.ApplyCuttingPlan(acceptWarnings: true)); + Assert.True(part.HasManualLeadIns); + } + + [Fact] + public void Mcp_PlanCutting_NeedsConfirmedSettings() + { + var session = new NestSession { Nest = Fixture(Square(), new Vector(1, 1)) }; + var tools = new CuttingTools(session); + + Assert.StartsWith("Error: no confirmed cutting settings", tools.PlanCutting()); + Assert.StartsWith("Error: cannot read cutting settings", tools.PlanCutting(cuttingParametersPath: Settings("null"))); + Assert.StartsWith("Error: no cutting plan", tools.ApplyCuttingPlan()); + + session.Nest.Plates[0].CuttingParameters = Parameters(); + Assert.Contains("saved with the plates", tools.PlanCutting()); + } + + [Fact] + public void Mcp_ReplacedNest_DropsTheProposal() + { + var session = new NestSession { Nest = Fixture(Square(), new Vector(1, 1)) }; + var tools = new CuttingTools(session); + var part = session.Nest.Plates[0].Parts[0]; + Assert.Contains("Ready to apply", tools.PlanCutting(cuttingParametersPath: Settings())); + + session.Nest = Fixture(Square(), new Vector(1, 1)); + + Assert.Contains("no longer in the session", tools.ApplyCuttingPlan()); + Assert.False(part.HasManualLeadIns); + } + + private static int RunConsole(params string[] args) + { + var method = Assembly.Load("OpenNest.Console").GetType("NestConsole")! + .GetMethod("Run", BindingFlags.Static | BindingFlags.Public)!; + return (int)method.Invoke(null, new object[] { args })!; + } + + private static Nest Fixture(Program program, params Vector[] locations) + { + var nest = new Nest("fixture"); + var drawing = new Drawing("square", program); + nest.Drawings.Add(drawing); + var plate = new Plate(100, 100); + foreach (var location in locations) + plate.Parts.Add(new Part(drawing, location)); + nest.Plates.Add(plate); + return nest; + } + + private static Program Square() + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(0, 10); + program.LineTo(10, 10); + program.LineTo(10, 0); + program.LineTo(0, 0); + return program; + } + + // Two holes sharing an edge: closed contours with ambiguous material, planned only best-effort. + private static Program TouchingHoles() + { + var program = Square(); + program.MoveTo(2, 2); program.LineTo(4, 2); program.LineTo(4, 4); + program.LineTo(2, 4); program.LineTo(2, 2); + program.MoveTo(4, 2); program.LineTo(6, 2); program.LineTo(6, 4); + program.LineTo(4, 4); program.LineTo(4, 2); + return program; + } + + private static CuttingParameters Parameters() => new() + { + ExternalLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 45 }, + InternalLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 45 }, + ArcCircleLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 45 }, + ExternalLeadOut = new NoLeadOut(), + InternalLeadOut = new NoLeadOut(), + PierceClearance = 0.05, + }; + + private string Write(Nest nest) + { + var path = Path.Combine(directory, "input.nest"); + Assert.True(new NestWriter(nest).Write(path)); + return path; + } + + private string Settings(string? json = null) + { + var path = Path.Combine(directory, "settings.json"); + File.WriteAllText(path, json ?? CuttingParametersSerializer.Serialize(Parameters())); + return path; + } +} diff --git a/OpenNest.IO/CuttingParametersSerializer.cs b/OpenNest.IO/CuttingParametersSerializer.cs index 3d5c385f..11b6859b 100644 --- a/OpenNest.IO/CuttingParametersSerializer.cs +++ b/OpenNest.IO/CuttingParametersSerializer.cs @@ -33,6 +33,17 @@ public static class CuttingParametersSerializer return parameters; } + /// + /// Reads settings a caller supplied explicitly (a file or tool argument). A JSON null is + /// refused rather than read as the defaults, so missing settings are never invented. + /// + public static CuttingParameters DeserializeExplicit(string json) + { + if (JsonSerializer.Deserialize(json, JsonOptions) == null) + throw new JsonException("Cutting settings must be a JSON object."); + return Deserialize(json); + } + /// Captures an owned snapshot. Null stays null for absent plate parameters. public static CuttingParametersDto ToDto(CuttingParameters parameters) { diff --git a/OpenNest.Mcp/NestSession.cs b/OpenNest.Mcp/NestSession.cs index 99b6416e..5332c8bf 100644 --- a/OpenNest.Mcp/NestSession.cs +++ b/OpenNest.Mcp/NestSession.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using OpenNest.Engine.CuttingPlanning; namespace OpenNest.Mcp { @@ -69,6 +70,12 @@ namespace OpenNest.Mcp /// public string DefaultEngineName { get; set; } = "Default"; + /// + /// The last applicable plan_cutting proposal, awaiting apply_cutting_plan. + /// Apply re-checks every plate against its captured state, so edits since planning make it stale. + /// + internal CuttingPlanProposal CuttingProposal { get; set; } + public Plate GetPlate(int index) { if (index < 0) diff --git a/OpenNest.Mcp/Tools/CuttingTools.cs b/OpenNest.Mcp/Tools/CuttingTools.cs new file mode 100644 index 00000000..93f692fd --- /dev/null +++ b/OpenNest.Mcp/Tools/CuttingTools.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using ModelContextProtocol.Server; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.IO; + +namespace OpenNest.Mcp.Tools +{ + /// + /// Cut planning in two steps, like the desktop panel: plan_cutting reports a proposal and + /// changes nothing; apply_cutting_plan installs that exact proposal, all plates or none. + /// + [McpServerToolType] + public class CuttingTools + { + private readonly NestSession _session; + + public CuttingTools(NestSession session) + { + _session = session; + } + + [McpServerTool(Name = "plan_cutting")] + [Description( + "Plan cutting for one plate or every occupied plate: whole-part order, contour order, entries and " + + "lead-ins regenerated from confirmed cutting settings. Locked parts keep their programs. Nothing " + + "changes until apply_cutting_plan. Settings come from cuttingParametersPath, else from the plates' " + + "own saved settings, which must agree; defaults are never assumed. Part numbers in the report are " + + "1-based positions in the plate's part list." + )] + public string PlanCutting( + [Description("Index of the plate to plan, or -1 for every occupied plate")] int plateIndex = -1, + [Description("Keep the current whole-part order (contour order and entries are still planned)")] + bool keepPartOrder = false, + [Description("Absolute path to a cutting settings JSON file (the desktop's cutting settings format); omit to use the plates' saved settings")] + string cuttingParametersPath = null, + CancellationToken cancellationToken = default + ) + { + // A new attempt replaces the previous proposal, whatever its outcome. + _session.CuttingProposal = null; + + var all = _session.AllPlates(); + IEnumerable candidates; + if (plateIndex == -1) + candidates = all; + else + { + var plate = _session.GetPlate(plateIndex); + if (plate == null) + return $"Error: plate {plateIndex} not found"; + candidates = new[] { plate }; + } + + var scope = candidates.Where(p => p.Parts.Count > 0).ToArray(); + if (scope.Length == 0) + return plateIndex == -1 + ? "Error: no occupied plates to plan cutting for" + : $"Error: plate {plateIndex} has no parts to plan cutting for"; + + CuttingParameters parameters; + if (cuttingParametersPath != null) + { + try + { + parameters = CuttingParametersSerializer.DeserializeExplicit(File.ReadAllText(cuttingParametersPath)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException + or NotSupportedException or ArgumentException) + { + return $"Error: cannot read cutting settings '{cuttingParametersPath}': {ex.Message}"; + } + } + else + { + parameters = SavedCuttingParameters(scope); + if (parameters == null) + return "Error: no confirmed cutting settings. Pass cuttingParametersPath, or plan plates that all " + + "carry the same saved settings. Nothing changed."; + } + + var batch = CuttingPlanBatch.Capture(scope, parameters, keepPartOrder, + scope.Select(p => all.FindIndex(candidate => ReferenceEquals(candidate, p))).ToArray(), + cancellationToken); + var proposal = batch.Plan(token: cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + var sb = new StringBuilder(); + sb.AppendLine($"Cutting settings: {cuttingParametersPath ?? "saved with the plates"}"); + foreach (var line in proposal.Describe(UnitsHelper.GetShortString(_session.Nest?.Units ?? default), + "Pass cuttingParametersPath settings that select")) + sb.AppendLine(line); + sb.AppendLine(); + if (proposal.CanApply) + sb.AppendLine("Nothing has changed yet. Call apply_cutting_plan to apply this plan."); + else if (proposal.RequiresWarningAcceptance) + sb.AppendLine("Nothing has changed yet. Review the warnings; to apply this unverified plan, call " + + "apply_cutting_plan with acceptWarnings=true."); + else + sb.AppendLine("Nothing can be applied. Resolve the findings and call plan_cutting again."); + + if (proposal.CanApplyWithWarnings) + _session.CuttingProposal = proposal; + return sb.ToString(); + } + + [McpServerTool(Name = "apply_cutting_plan")] + [Description( + "Apply the last plan_cutting proposal to every plate in it, or to none. Refused if any planned plate " + + "changed since planning; plan again then. Accepting warnings applies only this proposal and is not " + + "consent to post CNC output." + )] + public string ApplyCuttingPlan( + [Description("Explicitly accept this unverified plan's warnings and apply it anyway")] bool acceptWarnings = false, + CancellationToken cancellationToken = default + ) + { + var proposal = _session.CuttingProposal; + if (proposal == null) + return "Error: no cutting plan to apply. Call plan_cutting first."; + + var all = _session.AllPlates(); + if (proposal.Plates.Any(planned => !all.Any(p => ReferenceEquals(p, planned.Plate)))) + { + _session.CuttingProposal = null; + return "Error: a planned plate is no longer in the session. Call plan_cutting again. Nothing changed."; + } + + if (!proposal.CanApply && !acceptWarnings) + return "Error: this plan is unverified. Review its warnings, then call apply_cutting_plan with " + + "acceptWarnings=true to apply it. Nothing changed."; + + var commit = proposal.Apply(acceptWarnings, cancellationToken); + if (commit.Status == CuttingCommitStatus.Cancelled) + return "Apply cancelled. Nothing changed."; + + // Applied or refused, a proposal is used at most once. + _session.CuttingProposal = null; + if (commit.Status != CuttingCommitStatus.Applied) + return commit.Status == CuttingCommitStatus.Stale + ? "Error: a plate changed since planning. Call plan_cutting again. Nothing changed." + : $"Error: cutting plan not applied ({commit.Status}): {commit.Message} Nothing changed."; + + var plates = string.Join(", ", proposal.Plates.Select(p => p.PlateNumber)); + return $"Applied cutting plan to plate(s) {plates}: " + + $"{proposal.Plates.Sum(p => p.RegeneratedCount)} part program(s) regenerated, " + + $"{proposal.Plates.Count(p => p.OrderChanged)} plate(s) with a new part order. " + + "Save with save_nest to keep it."; + } + + /// The plates' saved settings when every plate carries the same ones; otherwise null. + private static CuttingParameters SavedCuttingParameters(IReadOnlyList plates) + { + var first = plates[0].CuttingParameters; + if (first == null) + return null; + var json = CuttingParametersSerializer.Serialize(first); + return plates.All(p => p.CuttingParameters != null + && CuttingParametersSerializer.Serialize(p.CuttingParameters) == json) ? first : null; + } + } +} diff --git a/README.md b/README.md index bf240534..0c77ac7f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Review part spacing before cutting, especially for interlocking pairs. See [pair dotnet run --project OpenNest.Console -- part.dxf --size 60x120 # fill one plate dotnet run --project OpenNest.Console -- part1.dxf part2.dxf --size 60x120 --autonest dotnet run --project OpenNest.Console -- project.zip # re-fill a nest file +dotnet run --project OpenNest.Console -- job.nest --no-fill --plan-cutting --all-plates ``` | Option | What it does | @@ -65,6 +66,9 @@ dotnet run --project OpenNest.Console -- project.zip # | `--template ` | Read plate defaults from a nest file. | | `--output ` | Set the output nest path. | | `--check-overlaps` | Check the result for overlaps. | +| `--no-fill` | Keep a loaded nest as it is, for example to plan cutting or post it. | +| `--plan-cutting` | Plan and apply cutting (part order, contours, entries, lead-ins); see [cut planning](docs/cutting-planner.md#console-and-mcp). | +| `--cutting-params ` | Cutting settings JSON for `--plan-cutting`; otherwise the plates' saved settings are used. | | `--post ` | Run a post-processor after nesting. | | `--no-save` | Skip saving the nest. | | `--allow-invalid` | Explicitly keep a representable invalid `--autonest` result; otherwise it is rejected. | diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 749598ca..1bd3bcd8 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -5,7 +5,8 @@ programs. It can retain fixed programs or jointly choose internal contour order, entries and whole-part order using explicitly confirmed cutting parameters. It returns an owned proposal; `Apply` installs Ready plate-scoped proposals atomically after an exact freshness check. The desktop opens it from `Plate > Plan Cutting...` and -`Nest > Plan Cutting (All Plates)...` (see [Desktop workflow](#desktop-workflow)); the older +`Nest > Plan Cutting (All Plates)...` (see [Desktop workflow](#desktop-workflow)); the console +and MCP server expose the same batch (see [Console and MCP](#console-and-mcp)); the older automatic sequencing and lead-in assignment commands remain until they are retired. ## Capture before worker planning @@ -686,6 +687,32 @@ overlap check may likewise be accepted with warnings. `CanApply` and parameterle explicit review path. `CuttingPlanService.Apply` still rejects best-effort results directly. Acceptance never skips the separate pre-post checks or grants posting consent. +## Console and MCP + +Both front ends run `CuttingPlanBatch` exactly as the desktop does: capture, plan, then an +all-or-nothing Apply of the captured proposal, with the same summary text. Neither adds scrap +cut-offs; plan those in the desktop. + +Settings are never invented. They come from an explicit cutting settings JSON file (the +format `CuttingParametersSerializer` writes, as stored in `.nest` plates), or else from the +plates' own saved settings, which must be identical across the plates in scope. A JSON +`null` is refused rather than read as the defaults. Without either source, planning is refused. + +- Console: `--plan-cutting` plans the plates the run produced (the target plate, or every + sheet `--autonest` added) after filling and before saving or posting; `--no-fill` plans a + loaded `.nest` as it is, and `--all-plates` widens the scope to every occupied plate. + `--cutting-params ` supplies settings and `--keep-part-order` fixes whole-part order. + A blocked or cancelled plan exits 2 and saves or posts nothing. An unverified plan is + applied only with `--accept-unverified-plan` for that invocation; posting still runs its own + verification and needs `--acknowledge-post-risks` separately. +- MCP: `plan_cutting(plateIndex = -1, keepPartOrder, cuttingParametersPath)` reports the + proposal without changing anything and keeps it in the session when it can be applied; + `-1` plans every occupied plate. `apply_cutting_plan(acceptWarnings)` applies that exact + proposal once. It is refused when a planned plate left the session (for example after + `load_nest`) or changed since planning (Stale), and an unverified plan needs + `acceptWarnings=true` on that call. Plate numbers in the report are session plate indices; + part numbers are 1-based positions in each plate's part list. Save with `save_nest`. + ## Remaining integration boundaries The service does not establish clean-material non-overlap, scrap release by open