mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 22:01:06 -04:00
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:
1 parent
cf32d1cab5
commit
391a27b2f9
8 files changed
+660
-36
No files matched your search
+196
-31
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user