diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs
index 12fbbcb..06dd798 100644
--- a/OpenNest.Console/Program.cs
+++ b/OpenNest.Console/Program.cs
@@ -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);
}
+ ///
+ /// 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.
+ ///
+ static bool AutoNestJob(Plate plate, List nestItems, string engineName)
+ {
+ var engine = NestingEngineRegistry.Create(engineName);
+
+ var parts = new List(nestItems.Count);
+ var drawingsByPartId = new Dictionary(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 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 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;
diff --git a/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs b/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs
index 7505ec1..dd6929b 100644
--- a/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs
@@ -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(() => NestingEngineRegistry.Create("Mystery Engine"));
+ Assert.Throws(() => NestingEngineRegistry.Create(" "));
+ Assert.Throws(() => 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()
{
diff --git a/OpenNest.Engine.Tests/PlateFillServiceTests.cs b/OpenNest.Engine.Tests/PlateFillServiceTests.cs
index 3ab51f2..a680c62 100644
--- a/OpenNest.Engine.Tests/PlateFillServiceTests.cs
+++ b/OpenNest.Engine.Tests/PlateFillServiceTests.cs
@@ -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 { new() { Drawing = drawing, Quantity = 4 } };
+
+ Assert.Throws(() =>
+ PlateFillService.Nest("StockLadder", plate, items, null, CancellationToken.None)
+ );
+ Assert.Throws(() =>
+ 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)
diff --git a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs
index 14f174d..b44eca2 100644
--- a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs
+++ b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs
@@ -53,6 +53,22 @@ public static class NestingEngineRegistry
public static IReadOnlyList AvailableEngines => engines;
+ ///
+ /// Creates the engine registered under (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.
+ ///
+ 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 factory)
{
if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs
index 9539a2b..3be45c2 100644
--- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs
+++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs
@@ -66,9 +66,28 @@ public static class PlateFillService
List items,
IProgress progress,
CancellationToken token
+ ) => Nest(strategy, plate, items, 0, progress, token);
+
+ ///
+ /// Whole-plate fill with the strategy's orchestration (fill-vs-pack, compaction). Returns
+ /// proposed parts only; committing them to stays with the caller.
+ ///
+ /// Plate index reported with progress, as the legacy engine's
+ /// PlateNumber was by interactive multi-plate loops.
+ public static List Nest(
+ string strategy,
+ Plate plate,
+ List items,
+ int plateNumber,
+ IProgress 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);
}
///
@@ -77,7 +96,11 @@ public static class PlateFillService
/// ActiveEngineName so tolerant interactive callers keep working. Returns the canonical name;
/// unknown names throw .
///
- internal static string ResolveStrategy(string strategy, bool allowEmpty = true)
+ public static string ResolveStrategy(string strategy) => ResolveStrategy(strategy, true);
+
+ ///
+ /// False rejects null/empty instead of defaulting (explicit service calls).
+ internal static string ResolveStrategy(string strategy, bool allowEmpty)
{
if (string.IsNullOrWhiteSpace(strategy))
{
diff --git a/OpenNest.Mcp/NestSession.cs b/OpenNest.Mcp/NestSession.cs
index 2dbd993..fb47c48 100644
--- a/OpenNest.Mcp/NestSession.cs
+++ b/OpenNest.Mcp/NestSession.cs
@@ -8,6 +8,13 @@ namespace OpenNest.Mcp
public List Plates { get; } = new();
public List Drawings { get; } = new();
+ ///
+ /// Session-default engine/strategy name used by nesting tools when a call does not pass
+ /// an explicit engine argument. Instance state only — the process-global legacy
+ /// registry is never read or written.
+ ///
+ public string DefaultEngineName { get; set; } = "Default";
+
public Plate GetPlate(int index)
{
if (Nest != null && index < Nest.Plates.Count)
diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs
index 859d7f7..ecf7183 100644
--- a/OpenNest.Mcp/Tools/NestingTools.cs
+++ b/OpenNest.Mcp/Tools/NestingTools.cs
@@ -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();
-
- 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();
-
- for (var i = 0; i < names.Length; i++)
+ var jobParts = new List(parsed.items.Count);
+ var drawingsByPartId = new Dictionary(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();
}
+
+ ///
+ /// Resolves the requested fill strategy, falling back to the session default. Returns null
+ /// when the name is not a single-plate placement strategy.
+ ///
+ 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 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();
+
+ 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);
+ }
}
}
diff --git a/README.md b/README.md
index aa8e07b..4d2c645 100644
--- a/README.md
+++ b/README.md
@@ -193,7 +193,7 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
| Option | Description |
|--------|-------------|
| `--size ` | 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 ` | Select which drawing to fill with (default: first) |
| `--quantity ` | Max parts to place (default: unlimited) |
| `--spacing ` | Override part spacing |
@@ -201,7 +201,7 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
| `--output ` | Output file path (default: `-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 ` | Select a registered nesting engine |
+| `--engine ` | With `--autonest`: jobs engine (`Default`, `Strip`, `Vertical Remnant`, `Horizontal Remnant`, `StockLadder`). Without: single-plate fill strategy (the four built-ins) |
| `--post ` | Post-process the result with the named post-processor plugin |
| `--no-save` | Skip saving the output file |
| `--no-log` | Skip writing the debug log |