refactor(engine): route console and MCP nesting through named job engines
Console --engine now names a jobs engine for --autonest (solved once through NestingEngineRegistry.Create and committed onto the plate) or a built-in fill strategy for single-plate fill through the public PlateFillService; unknown names exit with the valid choices instead of consulting the process-global legacy registry. MCP nesting tools take an explicit engine argument per call with the session default, never read process-global active-engine state, and reject whole-job engine names on single-plate fill tools. NestingEngineRegistry gains an explicit Create (name) resolution; PlateFillService gains a public ResolveStrategy and a plate-number Nest overload used by interactive callers.
This commit is contained in:
+124
-8
@@ -11,6 +11,9 @@ using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.IO.Bending;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
|
||||
return NestConsole.Run(args);
|
||||
|
||||
@@ -45,6 +48,36 @@ static class NestConsole
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Validate --engine up front: autonest names a jobs engine, plain fill names a
|
||||
// single-plate placement strategy. Unknown names exit with the valid choices.
|
||||
if (options.AutoNest)
|
||||
{
|
||||
var isJobsEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||
e.Name.Equals(options.Engine, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
if (!isJobsEngine)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Error: unknown engine '{options.Engine}'. Jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
PlateFillService.ResolveStrategy(options.Engine);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Error: unknown engine '{options.Engine}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (jobs engines such as StockLadder require --autonest)"
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.InputFiles.Count == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
@@ -156,7 +189,7 @@ static class NestConsole
|
||||
o.AutoNest = true;
|
||||
break;
|
||||
case "--engine" when i + 1 < args.Length:
|
||||
NestEngineRegistry.ActiveEngineName = args[++i];
|
||||
o.Engine = args[++i];
|
||||
break;
|
||||
case "--post" when i + 1 < args.Length:
|
||||
o.PostName = args[++i];
|
||||
@@ -397,22 +430,98 @@ static class NestConsole
|
||||
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
|
||||
);
|
||||
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var nestParts = engine.Nest(nestItems, null, CancellationToken.None);
|
||||
plate.Parts.AddRange(nestParts);
|
||||
success = nestParts.Count > 0;
|
||||
success = AutoNestJob(plate, nestItems, options.Engine);
|
||||
}
|
||||
else
|
||||
{
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
// Single-plate fill: explicit placement strategy through the public service;
|
||||
// the process-global engine registry is never consulted.
|
||||
var strategy = ResolveFillStrategy(options.Engine);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
|
||||
success = engine.Fill(item);
|
||||
var parts = PlateFillService.FillItem(
|
||||
strategy,
|
||||
plate,
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
if (parts.Count > 0)
|
||||
plate.Parts.AddRange(parts);
|
||||
success = parts.Count > 0;
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
return (success, sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Solves the drawings as one whole job against this single plate using the named jobs
|
||||
/// engine, then commits the returned placements onto the plate. Placements are mapped back
|
||||
/// onto the caller's original drawings (same pose semantics as NestResultMaterializer), so
|
||||
/// the saved nest keeps its existing drawing identities.
|
||||
/// </summary>
|
||||
static bool AutoNestJob(Plate plate, List<NestItem> nestItems, string engineName)
|
||||
{
|
||||
var engine = NestingEngineRegistry.Create(engineName);
|
||||
|
||||
var parts = new List<NestJobPart>(nestItems.Count);
|
||||
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < nestItems.Count; i++)
|
||||
{
|
||||
var partId = $"part-{i}";
|
||||
parts.Add(DrawingJobMapper.FromItem(partId, nestItems[i]));
|
||||
drawingsByPartId[partId] = nestItems[i].Drawing;
|
||||
}
|
||||
|
||||
// One physical sheet: this plate, this solve — the runner owns stock accounting.
|
||||
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1);
|
||||
var job = new NestJob(parts, [stock]);
|
||||
|
||||
var result = engine.Solve(job, null, CancellationToken.None);
|
||||
|
||||
var committed = 0;
|
||||
foreach (var plateResult in result.Plates)
|
||||
{
|
||||
foreach (var pose in plateResult.Placements)
|
||||
{
|
||||
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
|
||||
continue;
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
plate.Parts.Add(part);
|
||||
committed++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
|
||||
return committed > 0;
|
||||
}
|
||||
|
||||
static string ResolveFillStrategy(string engineName)
|
||||
{
|
||||
try
|
||||
{
|
||||
return PlateFillService.ResolveStrategy(engineName);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
isJobEngine
|
||||
? $"Error: engine '{engineName}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
|
||||
: $"Error: unknown engine '{engineName}'. Engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
|
||||
);
|
||||
Environment.Exit(1);
|
||||
throw; // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
static int CheckOverlaps(Plate plate, Options options)
|
||||
{
|
||||
if (!options.CheckOverlaps || plate.Parts.Count == 0)
|
||||
@@ -598,7 +707,13 @@ static class NestConsole
|
||||
" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --autonest Use mixed-part autonesting (engine Nest) instead of linear fill"
|
||||
" --autonest Whole-job nesting via the jobs engine (--engine) instead of single-plate fill"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...)."
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --keep-parts Don't clear existing parts before filling"
|
||||
@@ -631,6 +746,7 @@ static class NestConsole
|
||||
public bool NoSave;
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public string Engine = "Default";
|
||||
public string TemplateFile;
|
||||
public string PostName;
|
||||
public string PostOutput;
|
||||
|
||||
@@ -42,6 +42,27 @@ public class NestingEngineRegistryTests
|
||||
Assert.Equal(before, NestingEngineRegistry.AvailableEngines.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateResolvesKnownNamesCaseInsensitivelyAndRejectsUnknown()
|
||||
{
|
||||
Assert.NotNull(NestingEngineRegistry.Create("default"));
|
||||
Assert.NotNull(NestingEngineRegistry.Create("Vertical Remnant"));
|
||||
Assert.NotNull(NestingEngineRegistry.Create("stockladder"));
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => NestingEngineRegistry.Create("Mystery Engine"));
|
||||
Assert.Throws<ArgumentException>(() => NestingEngineRegistry.Create(" "));
|
||||
Assert.Throws<ArgumentNullException>(() => NestingEngineRegistry.Create(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProducesAWorkingEngine()
|
||||
{
|
||||
var engine = NestingEngineRegistry.Create("Strip");
|
||||
var result = engine.Solve(FiniteStockJobTests.Job(1));
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadPluginsAgainstMissingDirectoryIsANoOp()
|
||||
{
|
||||
|
||||
@@ -102,6 +102,33 @@ public class PlateFillServiceTests
|
||||
Assert.All(parts, part => Assert.Same(drawing, part.BaseDrawing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nest_RejectsUnknownStrategy_AndReportsPlateNumberInDetail()
|
||||
{
|
||||
var plate = new Plate(new Size(60, 80));
|
||||
// Big enough (240 area x4 >= 10% of the 4800-area plate) to route through the fill
|
||||
// pipeline, which is where progress detail is emitted.
|
||||
var drawing = new Drawing("part", TestDrawingFactory.Rectangle(20, 12));
|
||||
var items = new List<NestItem> { new() { Drawing = drawing, Quantity = 4 } };
|
||||
|
||||
Assert.Throws<NotSupportedException>(() =>
|
||||
PlateFillService.Nest("StockLadder", plate, items, null, CancellationToken.None)
|
||||
);
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
PlateFillService.Nest(null!, plate, items, null, CancellationToken.None)
|
||||
);
|
||||
|
||||
// The plateNumber overload reports the caller's plate index like the legacy
|
||||
// engine's PlateNumber did for interactive multi-plate loops.
|
||||
var progress = new CapturingProgress();
|
||||
var parts = PlateFillService.Nest("Default", plate, items, 3, progress, CancellationToken.None);
|
||||
|
||||
Assert.NotEmpty(parts);
|
||||
Assert.NotEmpty(progress.Reports);
|
||||
Assert.All(progress.Reports, report => Assert.Equal(3, report.PlateNumber));
|
||||
Assert.Empty(plate.Parts); // proposed parts only; committing stays with the caller
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(StrategiesData))]
|
||||
public void FillItem_ReturnsNoParts_WhenTokenIsAlreadyCancelled(string strategy)
|
||||
|
||||
@@ -53,6 +53,22 @@ public static class NestingEngineRegistry
|
||||
|
||||
public static IReadOnlyList<NestingEngineInfo> AvailableEngines => engines;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the engine registered under <paramref name="name"/> (case-insensitive). The caller's
|
||||
/// explicit choice is the whole selection mechanism: unlike the legacy registry there is no
|
||||
/// process-global active name to consult or mutate. Unknown names throw.
|
||||
/// </summary>
|
||||
public static INestingEngine Create(string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
var info = engines.FirstOrDefault(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (info == null)
|
||||
throw new NotSupportedException(
|
||||
$"Unknown nesting engine: {name}. Available: {string.Join(", ", engines.Select(e => e.Name))}."
|
||||
);
|
||||
return info.Factory();
|
||||
}
|
||||
|
||||
public static void Register(string name, string description, Func<INestingEngine> factory)
|
||||
{
|
||||
if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
|
||||
@@ -66,9 +66,28 @@ public static class PlateFillService
|
||||
List<NestItem> items,
|
||||
IProgress<NestProgress> progress,
|
||||
CancellationToken token
|
||||
) => Nest(strategy, plate, items, 0, progress, token);
|
||||
|
||||
/// <summary>
|
||||
/// Whole-plate fill with the strategy's orchestration (fill-vs-pack, compaction). Returns
|
||||
/// proposed parts only; committing them to <paramref name="plate"/> stays with the caller.
|
||||
/// </summary>
|
||||
/// <param name="plateNumber">Plate index reported with progress, as the legacy engine's
|
||||
/// <c>PlateNumber</c> was by interactive multi-plate loops.</param>
|
||||
public static List<Part> Nest(
|
||||
string strategy,
|
||||
Plate plate,
|
||||
List<NestItem> items,
|
||||
int plateNumber,
|
||||
IProgress<NestProgress> progress,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
return RequireFiller(strategy, plate).Nest(items, progress, token);
|
||||
ArgumentNullException.ThrowIfNull(strategy);
|
||||
ResolveStrategy(strategy, allowEmpty: false);
|
||||
var filler = CreateFiller(strategy, plate);
|
||||
filler.PlateNumber = plateNumber;
|
||||
return filler.Nest(items, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -77,7 +96,11 @@ public static class PlateFillService
|
||||
/// ActiveEngineName so tolerant interactive callers keep working. Returns the canonical name;
|
||||
/// unknown names throw <see cref="NotSupportedException"/>.
|
||||
/// </summary>
|
||||
internal static string ResolveStrategy(string strategy, bool allowEmpty = true)
|
||||
public static string ResolveStrategy(string strategy) => ResolveStrategy(strategy, true);
|
||||
|
||||
/// <inheritdoc cref="ResolveStrategy(string)"/>
|
||||
/// <param name="allowEmpty">False rejects null/empty instead of defaulting (explicit service calls).</param>
|
||||
internal static string ResolveStrategy(string strategy, bool allowEmpty)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(strategy))
|
||||
{
|
||||
|
||||
@@ -8,6 +8,13 @@ namespace OpenNest.Mcp
|
||||
public List<Plate> Plates { get; } = new();
|
||||
public List<Drawing> Drawings { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Session-default engine/strategy name used by nesting tools when a call does not pass
|
||||
/// an explicit <c>engine</c> argument. Instance state only — the process-global legacy
|
||||
/// registry is never read or written.
|
||||
/// </summary>
|
||||
public string DefaultEngineName { get; set; } = "Default";
|
||||
|
||||
public Plate GetPlate(int index)
|
||||
{
|
||||
if (Nest != null && index < Nest.Plates.Count)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
@@ -5,6 +6,9 @@ using System.Text;
|
||||
using System.Threading;
|
||||
using ModelContextProtocol.Server;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Engine;
|
||||
|
||||
@@ -27,7 +31,9 @@ namespace OpenNest.Mcp.Tools
|
||||
public string FillPlate(
|
||||
[Description("Index of the plate to fill")] int plateIndex,
|
||||
[Description("Name of the drawing to fill with")] string drawingName,
|
||||
[Description("Maximum quantity to place (0 = unlimited)")] int quantity = 0
|
||||
[Description("Maximum quantity to place (0 = unlimited)")] int quantity = 0,
|
||||
[Description("Placement strategy: Default, Strip, Vertical Remnant, Horizontal Remnant")]
|
||||
string engine = null
|
||||
)
|
||||
{
|
||||
var plate = _session.GetPlate(plateIndex);
|
||||
@@ -38,17 +44,29 @@ namespace OpenNest.Mcp.Tools
|
||||
if (drawing == null)
|
||||
return $"Error: drawing '{drawingName}' not found";
|
||||
|
||||
var strategy = ResolveStrategy(engine);
|
||||
if (strategy == null)
|
||||
return EngineError(engine);
|
||||
|
||||
var countBefore = plate.Parts.Count;
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
||||
var success = engine.Fill(item);
|
||||
var parts = PlateFillService.FillItem(
|
||||
strategy,
|
||||
plate,
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
plate.Parts.AddRange(parts);
|
||||
var success = parts.Count > 0;
|
||||
|
||||
var countAfter = plate.Parts.Count;
|
||||
var added = countAfter - countBefore;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(
|
||||
$"Fill plate {plateIndex} with '{drawingName}': {(success ? "success" : "failed")}"
|
||||
$"Fill plate {plateIndex} with '{drawingName}' ({strategy}): {(success ? "success" : "failed")}"
|
||||
);
|
||||
sb.AppendLine($" Parts added: {added}");
|
||||
sb.AppendLine($" Total parts: {countAfter}");
|
||||
@@ -66,7 +84,9 @@ namespace OpenNest.Mcp.Tools
|
||||
[Description("Y origin of the area")] double y,
|
||||
[Description("Width of the area")] double width,
|
||||
[Description("Length of the area")] double length,
|
||||
[Description("Maximum quantity to place (0 = unlimited)")] int quantity = 0
|
||||
[Description("Maximum quantity to place (0 = unlimited)")] int quantity = 0,
|
||||
[Description("Placement strategy: Default, Strip, Vertical Remnant, Horizontal Remnant")]
|
||||
string engine = null
|
||||
)
|
||||
{
|
||||
var plate = _session.GetPlate(plateIndex);
|
||||
@@ -77,18 +97,30 @@ namespace OpenNest.Mcp.Tools
|
||||
if (drawing == null)
|
||||
return $"Error: drawing '{drawingName}' not found";
|
||||
|
||||
var strategy = ResolveStrategy(engine);
|
||||
if (strategy == null)
|
||||
return EngineError(engine);
|
||||
|
||||
var countBefore = plate.Parts.Count;
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
||||
var area = new Box(x, y, width, length);
|
||||
var success = engine.Fill(item, area);
|
||||
var parts = PlateFillService.FillItem(
|
||||
strategy,
|
||||
plate,
|
||||
item,
|
||||
area,
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
plate.Parts.AddRange(parts);
|
||||
var success = parts.Count > 0;
|
||||
|
||||
var countAfter = plate.Parts.Count;
|
||||
var added = countAfter - countBefore;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(
|
||||
$"Fill area ({x:F1},{y:F1} {width:F1}x{length:F1}) on plate {plateIndex} with '{drawingName}': {(success ? "success" : "failed")}"
|
||||
$"Fill area ({x:F1},{y:F1} {width:F1}x{length:F1}) on plate {plateIndex} with '{drawingName}' ({strategy}): {(success ? "success" : "failed")}"
|
||||
);
|
||||
sb.AppendLine($" Parts added: {added}");
|
||||
sb.AppendLine($" Total parts: {countAfter}");
|
||||
@@ -102,7 +134,9 @@ namespace OpenNest.Mcp.Tools
|
||||
public string FillRemnants(
|
||||
[Description("Index of the plate")] int plateIndex,
|
||||
[Description("Name of the drawing to fill with")] string drawingName,
|
||||
[Description("Maximum quantity per remnant (0 = unlimited)")] int quantity = 0
|
||||
[Description("Maximum quantity per remnant (0 = unlimited)")] int quantity = 0,
|
||||
[Description("Placement strategy: Default, Strip, Vertical Remnant, Horizontal Remnant")]
|
||||
string engine = null
|
||||
)
|
||||
{
|
||||
var plate = _session.GetPlate(plateIndex);
|
||||
@@ -113,6 +147,10 @@ namespace OpenNest.Mcp.Tools
|
||||
if (drawing == null)
|
||||
return $"Error: drawing '{drawingName}' not found";
|
||||
|
||||
var strategy = ResolveStrategy(engine);
|
||||
if (strategy == null)
|
||||
return EngineError(engine);
|
||||
|
||||
var finder = RemnantFinder.FromPlate(plate);
|
||||
var remnants = finder.FindRemnants();
|
||||
|
||||
@@ -123,19 +161,26 @@ namespace OpenNest.Mcp.Tools
|
||||
sb.AppendLine($"Found {remnants.Count} remnant area(s) on plate {plateIndex}");
|
||||
|
||||
var totalAdded = 0;
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
|
||||
for (var i = 0; i < remnants.Count; i++)
|
||||
{
|
||||
var remnant = remnants[i];
|
||||
var countBefore = plate.Parts.Count;
|
||||
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
||||
var success = engine.Fill(item, remnant);
|
||||
var parts = PlateFillService.FillItem(
|
||||
strategy,
|
||||
plate,
|
||||
item,
|
||||
remnant,
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
plate.Parts.AddRange(parts);
|
||||
var added = plate.Parts.Count - countBefore;
|
||||
totalAdded += added;
|
||||
|
||||
sb.AppendLine(
|
||||
$" Remnant {i}: ({remnant.X:F1},{remnant.Y:F1} {remnant.Width:F1}x{remnant.Length:F1}) -> {added} parts {(success ? "" : "(no fit)")}"
|
||||
$" Remnant {i}: ({remnant.X:F1},{remnant.Y:F1} {remnant.Width:F1}x{remnant.Length:F1}) -> {added} parts {(added > 0 ? "" : "(no fit)")}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,7 +197,9 @@ namespace OpenNest.Mcp.Tools
|
||||
public string PackPlate(
|
||||
[Description("Index of the plate")] int plateIndex,
|
||||
[Description("Comma-separated drawing names")] string drawingNames,
|
||||
[Description("Comma-separated quantities for each drawing")] string quantities
|
||||
[Description("Comma-separated quantities for each drawing")] string quantities,
|
||||
[Description("Placement strategy: Default, Strip, Vertical Remnant, Horizontal Remnant")]
|
||||
string engine = null
|
||||
)
|
||||
{
|
||||
var plate = _session.GetPlate(plateIndex);
|
||||
@@ -165,38 +212,32 @@ namespace OpenNest.Mcp.Tools
|
||||
if (string.IsNullOrWhiteSpace(quantities))
|
||||
return "Error: quantities is required";
|
||||
|
||||
var names = drawingNames.Split(',').Select(n => n.Trim()).ToArray();
|
||||
var qtyStrings = quantities.Split(',').Select(q => q.Trim()).ToArray();
|
||||
var qtys = new int[qtyStrings.Length];
|
||||
var strategy = ResolveStrategy(engine);
|
||||
if (strategy == null)
|
||||
return EngineError(engine);
|
||||
|
||||
for (var i = 0; i < qtyStrings.Length; i++)
|
||||
{
|
||||
if (!int.TryParse(qtyStrings[i], out qtys[i]))
|
||||
return $"Error: '{qtyStrings[i]}' is not a valid quantity";
|
||||
}
|
||||
var parsed = ParseItems(drawingNames, quantities);
|
||||
if (parsed.error != null)
|
||||
return parsed.error;
|
||||
|
||||
if (names.Length != qtys.Length)
|
||||
return $"Error: drawing names count ({names.Length}) does not match quantities count ({qtys.Length})";
|
||||
|
||||
var items = new List<NestItem>();
|
||||
|
||||
for (var i = 0; i < names.Length; i++)
|
||||
{
|
||||
var drawing = _session.GetDrawing(names[i]);
|
||||
if (drawing == null)
|
||||
return $"Error: drawing '{names[i]}' not found";
|
||||
|
||||
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] });
|
||||
}
|
||||
var items = parsed.items;
|
||||
|
||||
var countBefore = plate.Parts.Count;
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var success = engine.Pack(items);
|
||||
var parts = PlateFillService.PackArea(
|
||||
strategy,
|
||||
plate,
|
||||
plate.WorkArea(),
|
||||
items,
|
||||
null,
|
||||
CancellationToken.None
|
||||
);
|
||||
plate.Parts.AddRange(parts);
|
||||
var success = parts.Count > 0;
|
||||
var countAfter = plate.Parts.Count;
|
||||
var added = countAfter - countBefore;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Pack plate {plateIndex}: {(success ? "success" : "failed")}");
|
||||
sb.AppendLine($"Pack plate {plateIndex} ({strategy}): {(success ? "success" : "failed")}");
|
||||
sb.AppendLine($" Parts added: {added}");
|
||||
sb.AppendLine($" Total parts: {countAfter}");
|
||||
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
||||
@@ -211,12 +252,14 @@ namespace OpenNest.Mcp.Tools
|
||||
|
||||
[McpServerTool(Name = "autonest_plate")]
|
||||
[Description(
|
||||
"Mixed-part autonesting. Fills the plate with multiple different drawings using iterative per-drawing fills with remainder-strip packing."
|
||||
"Mixed-part autonesting. Solves the drawings as one whole job against the plate using the named jobs engine and commits the resulting placements onto the plate."
|
||||
)]
|
||||
public string AutoNestPlate(
|
||||
[Description("Index of the plate")] int plateIndex,
|
||||
[Description("Comma-separated drawing names")] string drawingNames,
|
||||
[Description("Comma-separated quantities for each drawing")] string quantities
|
||||
[Description("Comma-separated quantities for each drawing")] string quantities,
|
||||
[Description("Jobs engine name (Default, Strip, Vertical Remnant, Horizontal Remnant, StockLadder)")]
|
||||
string engine = null
|
||||
)
|
||||
{
|
||||
var plate = _session.GetPlate(plateIndex);
|
||||
@@ -229,38 +272,61 @@ namespace OpenNest.Mcp.Tools
|
||||
if (string.IsNullOrWhiteSpace(quantities))
|
||||
return "Error: quantities is required";
|
||||
|
||||
var names = drawingNames.Split(',').Select(n => n.Trim()).ToArray();
|
||||
var qtyStrings = quantities.Split(',').Select(q => q.Trim()).ToArray();
|
||||
var qtys = new int[qtyStrings.Length];
|
||||
var engineName = string.IsNullOrWhiteSpace(engine)
|
||||
? _session.DefaultEngineName
|
||||
: engine.Trim();
|
||||
|
||||
for (var i = 0; i < qtyStrings.Length; i++)
|
||||
var parsed = ParseItems(drawingNames, quantities);
|
||||
if (parsed.error != null)
|
||||
return parsed.error;
|
||||
|
||||
if (parsed.items.Any(item => item.Quantity <= 0))
|
||||
return "Error: autonest quantities must be positive";
|
||||
|
||||
INestingEngine nestingEngine;
|
||||
try
|
||||
{
|
||||
if (!int.TryParse(qtyStrings[i], out qtys[i]))
|
||||
return $"Error: '{qtyStrings[i]}' is not a valid quantity";
|
||||
nestingEngine = NestingEngineRegistry.Create(engineName);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return UnknownEngineMessage(engineName);
|
||||
}
|
||||
|
||||
if (names.Length != qtys.Length)
|
||||
return $"Error: drawing names count ({names.Length}) does not match quantities count ({qtys.Length})";
|
||||
|
||||
var items = new List<NestItem>();
|
||||
|
||||
for (var i = 0; i < names.Length; i++)
|
||||
var jobParts = new List<NestJobPart>(parsed.items.Count);
|
||||
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < parsed.items.Count; i++)
|
||||
{
|
||||
var drawing = _session.GetDrawing(names[i]);
|
||||
if (drawing == null)
|
||||
return $"Error: drawing '{names[i]}' not found";
|
||||
|
||||
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] });
|
||||
var partId = $"part-{i}";
|
||||
jobParts.Add(DrawingJobMapper.FromItem(partId, parsed.items[i]));
|
||||
drawingsByPartId[partId] = parsed.items[i].Drawing;
|
||||
}
|
||||
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var nestParts = engine.Nest(items, null, CancellationToken.None);
|
||||
plate.Parts.AddRange(nestParts);
|
||||
var totalPlaced = nestParts.Count;
|
||||
// One physical sheet: this plate, this solve — the runner owns stock accounting.
|
||||
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1);
|
||||
var job = new NestJob(jobParts, [stock]);
|
||||
|
||||
var result = nestingEngine.Solve(job, null, CancellationToken.None);
|
||||
|
||||
var totalPlaced = 0;
|
||||
foreach (var plateResult in result.Plates)
|
||||
{
|
||||
foreach (var pose in plateResult.Placements)
|
||||
{
|
||||
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
|
||||
continue;
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
plate.Parts.Add(part);
|
||||
totalPlaced++;
|
||||
}
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(
|
||||
$"AutoNest plate {plateIndex} ({engine.Name} engine): {(totalPlaced > 0 ? "success" : "no parts placed")}"
|
||||
$"AutoNest plate {plateIndex} ({engineName} engine): {(totalPlaced > 0 ? "success" : "no parts placed")}"
|
||||
);
|
||||
sb.AppendLine($" Parts placed: {totalPlaced}");
|
||||
sb.AppendLine($" Total parts: {plate.Parts.Count}");
|
||||
@@ -272,5 +338,72 @@ namespace OpenNest.Mcp.Tools
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the requested fill strategy, falling back to the session default. Returns null
|
||||
/// when the name is not a single-plate placement strategy.
|
||||
/// </summary>
|
||||
private string ResolveStrategy(string engine)
|
||||
{
|
||||
var requested = string.IsNullOrWhiteSpace(engine)
|
||||
? _session.DefaultEngineName
|
||||
: engine.Trim();
|
||||
|
||||
try
|
||||
{
|
||||
return PlateFillService.ResolveStrategy(requested);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string EngineError(string engine) =>
|
||||
UnknownEngineMessage(
|
||||
string.IsNullOrWhiteSpace(engine) ? _session.DefaultEngineName : engine.Trim()
|
||||
);
|
||||
|
||||
private static string UnknownEngineMessage(string engineName)
|
||||
{
|
||||
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
|
||||
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
return isJobEngine
|
||||
? $"Error: engine '{engineName}' is a whole-job engine; this tool supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use autonest_plate for whole-job engines."
|
||||
: $"Error: unknown engine '{engineName}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)}; jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}";
|
||||
}
|
||||
|
||||
private (List<NestItem> items, string error) ParseItems(string drawingNames, string quantities)
|
||||
{
|
||||
var names = drawingNames.Split(',').Select(n => n.Trim()).ToArray();
|
||||
var qtyStrings = quantities.Split(',').Select(q => q.Trim()).ToArray();
|
||||
var qtys = new int[qtyStrings.Length];
|
||||
|
||||
for (var i = 0; i < qtyStrings.Length; i++)
|
||||
{
|
||||
if (!int.TryParse(qtyStrings[i], out qtys[i]))
|
||||
return (null, $"Error: '{qtyStrings[i]}' is not a valid quantity");
|
||||
}
|
||||
|
||||
if (names.Length != qtys.Length)
|
||||
return (
|
||||
null,
|
||||
$"Error: drawing names count ({names.Length}) does not match quantities count ({qtys.Length})"
|
||||
);
|
||||
|
||||
var items = new List<NestItem>();
|
||||
|
||||
for (var i = 0; i < names.Length; i++)
|
||||
{
|
||||
var drawing = _session.GetDrawing(names[i]);
|
||||
if (drawing == null)
|
||||
return (null, $"Error: drawing '{names[i]}' not found");
|
||||
|
||||
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] });
|
||||
}
|
||||
|
||||
return (items, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--size <WxL>` | Plate size (e.g. `60x120`). Required for DXF-only mode. |
|
||||
| `--autonest` | Use mixed-part nesting instead of linear fill (experimental) |
|
||||
| `--autonest` | Whole-job nesting via the jobs engine named by `--engine` (default: Default) instead of single-plate fill |
|
||||
| `--drawing <name>` | Select which drawing to fill with (default: first) |
|
||||
| `--quantity <n>` | Max parts to place (default: unlimited) |
|
||||
| `--spacing <value>` | Override part spacing |
|
||||
@@ -201,7 +201,7 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
|
||||
| `--output <path>` | Output file path (default: `<input>-result.zip`) |
|
||||
| `--keep-parts` | Keep existing parts instead of clearing before fill |
|
||||
| `--check-overlaps` | Run overlap detection after fill (exits with code 1 if found) |
|
||||
| `--engine <name>` | Select a registered nesting engine |
|
||||
| `--engine <name>` | With `--autonest`: jobs engine (`Default`, `Strip`, `Vertical Remnant`, `Horizontal Remnant`, `StockLadder`). Without: single-plate fill strategy (the four built-ins) |
|
||||
| `--post <name>` | Post-process the result with the named post-processor plugin |
|
||||
| `--no-save` | Skip saving the output file |
|
||||
| `--no-log` | Skip writing the debug log |
|
||||
|
||||
Reference in New Issue
Block a user