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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-19 20:54:28 -04:00
1 parent cf32d1cab5
commit 391a27b2f9
8 files changed
+660 -36

No files matched your search

+196 -31
View File
@@ -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<Plate> 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
);
}
/// <summary>
/// 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.
/// </summary>
static bool PlanCutting(Nest nest, IEnumerable<Plate> 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 <settings.json>, 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;
}
/// <summary>The plates' saved settings when every plate carries the same ones; otherwise null.</summary>
static CuttingParameters SavedCuttingParameters(IReadOnlyList<Plate> 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;
}
/// <summary>Reports synchronously, so phase lines print in order with the summary.</summary>
sealed class PlanningProgress(int plateCount) : IProgress<CuttingPlanProgress>
{
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 <name> 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 <path> 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 <name> 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;
}
@@ -370,7 +370,9 @@ public sealed class CuttingPlanProposal
}
/// <summary>Operator-facing summary: one overall line, then each plate and its findings.</summary>
public IReadOnlyList<string> Describe(string unit)
/// <param name="changeSettings">How this front end changes the settings, completed by
/// "a lead-in type other than None..." in the missing lead-in advice.</param>
public IReadOnlyList<string> Describe(string unit, string changeSettings = "Open Cutting Settings... and select")
{
var lines = new List<string>();
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<string> DescribePlate(CuttingPlanPlateResult plate, string unit)
private static IEnumerable<string> 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.";
}
@@ -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<string> { 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;
}
}
@@ -33,6 +33,17 @@ public static class CuttingParametersSerializer
return parameters;
}
/// <summary>
/// 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.
/// </summary>
public static CuttingParameters DeserializeExplicit(string json)
{
if (JsonSerializer.Deserialize<CuttingParametersDto>(json, JsonOptions) == null)
throw new JsonException("Cutting settings must be a JSON object.");
return Deserialize(json);
}
/// <summary>Captures an owned snapshot. Null stays null for absent plate parameters.</summary>
public static CuttingParametersDto ToDto(CuttingParameters parameters)
{
+7
View File
@@ -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
/// </summary>
public string DefaultEngineName { get; set; } = "Default";
/// <summary>
/// The last applicable <c>plan_cutting</c> proposal, awaiting <c>apply_cutting_plan</c>.
/// Apply re-checks every plate against its captured state, so edits since planning make it stale.
/// </summary>
internal CuttingPlanProposal CuttingProposal { get; set; }
public Plate GetPlate(int index)
{
if (index < 0)
+170
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
[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<Plate> 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.";
}
/// <summary>The plates' saved settings when every plate carries the same ones; otherwise null.</summary>
private static CuttingParameters SavedCuttingParameters(IReadOnlyList<Plate> 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;
}
}
}
+4
View File
@@ -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 <path>` | Read plate defaults from a nest file. |
| `--output <path>` | 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 <path>` | Cutting settings JSON for `--plan-cutting`; otherwise the plates' saved settings are used. |
| `--post <name>` | 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. |
+28 -1
View File
@@ -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 <path>` 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