From 88966d118ce6c8c02f1fcba89aa1b59bc9b39953 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 22 Sep 2026 00:30:03 -0400 Subject: [PATCH] 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. --- OpenNest.Console/Program.cs | 132 ++++++++- .../Jobs/NestingEngineRegistryTests.cs | 21 ++ .../PlateFillServiceTests.cs | 27 ++ OpenNest.Engine/Jobs/NestingEngineRegistry.cs | 16 ++ .../Jobs/Placement/PlateFillService.cs | 27 +- OpenNest.Mcp/NestSession.cs | 7 + OpenNest.Mcp/Tools/NestingTools.cs | 255 +++++++++++++----- README.md | 4 +- 8 files changed, 416 insertions(+), 73 deletions(-) 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 |