diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs index 7037f46..9539a2b 100644 --- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs +++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs @@ -33,8 +33,7 @@ public static class PlateFillService CancellationToken token ) { - var filler = CreateFiller(strategy, plate); - return filler.Fill(item, workArea, progress, token); + return RequireFiller(strategy, plate).Fill(item, workArea, progress, token); } public static List FillGroup( @@ -46,8 +45,7 @@ public static class PlateFillService CancellationToken token ) { - var filler = CreateFiller(strategy, plate); - return filler.Fill(groupParts, workArea, progress, token); + return RequireFiller(strategy, plate).Fill(groupParts, workArea, progress, token); } public static List PackArea( @@ -59,33 +57,71 @@ public static class PlateFillService CancellationToken token ) { - var filler = CreateFiller(strategy, plate); - return filler.PackArea(box, items, progress, token); + return RequireFiller(strategy, plate).PackArea(box, items, progress, token); } - private static PlateFillerBase CreateFiller(string strategy, Plate plate) + public static List Nest( + string strategy, + Plate plate, + List items, + IProgress progress, + CancellationToken token + ) { - ArgumentNullException.ThrowIfNull(strategy); - ArgumentNullException.ThrowIfNull(plate); + return RequireFiller(strategy, plate).Nest(items, progress, token); + } + + /// + /// Resolves a caller-supplied strategy name: null or empty means "Default"; otherwise the name + /// must match a built-in strategy, matched case-insensitively like the legacy registry's + /// ActiveEngineName so tolerant interactive callers keep working. Returns the canonical name; + /// unknown names throw . + /// + internal static string ResolveStrategy(string strategy, bool allowEmpty = true) + { + if (string.IsNullOrWhiteSpace(strategy)) + { + if (allowEmpty) + return "Default"; + throw new NotSupportedException( + $"Unknown placement strategy: '{strategy}'. Known strategies: {string.Join(", ", BuiltInStrategies)}." + ); + } - // OrdinalIgnoreCase mirrors the legacy registry's ActiveEngineName matching so the - // interactive callers keep their tolerant name handling while moving off global state. foreach (var candidate in BuiltInStrategies) { if (candidate.Equals(strategy, StringComparison.OrdinalIgnoreCase)) - { - return candidate switch - { - "Default" => new DefaultPlateFiller(plate), - "Strip" => new StripPlateFiller(plate), - "Vertical Remnant" => new RemnantPlateFiller(plate, RemnantFillPolicy.Vertical), - _ => new RemnantPlateFiller(plate, RemnantFillPolicy.Horizontal), - }; - } + return candidate; } throw new NotSupportedException( $"Unknown placement strategy: {strategy}. Known strategies: {string.Join(", ", BuiltInStrategies)}." ); } + + /// + /// Builds the filler for an optional strategy (null/empty = Default). Internal so the + /// engine-side multi-plate orchestrators share one resolution/rejection contract. + /// + internal static PlateFillerBase CreateFiller(string strategy, Plate plate) + { + ArgumentNullException.ThrowIfNull(plate); + return ResolveStrategy(strategy) switch + { + "Default" => new DefaultPlateFiller(plate), + "Strip" => new StripPlateFiller(plate), + "Vertical Remnant" => new RemnantPlateFiller(plate, RemnantFillPolicy.Vertical), + _ => new RemnantPlateFiller(plate, RemnantFillPolicy.Horizontal), + }; + } + + /// Public operations require an explicit strategy name (null is an argument error). + private static PlateFillerBase RequireFiller(string strategy, Plate plate) + { + ArgumentNullException.ThrowIfNull(strategy); + // An explicit empty string is an unknown strategy, not the orchestrator's + // null-means-Default defaulting; only the orchestrator boundary may default. + ResolveStrategy(strategy, allowEmpty: false); + return CreateFiller(strategy, plate); + } } diff --git a/OpenNest.Engine/MultiPlateNester.cs b/OpenNest.Engine/MultiPlateNester.cs index 01dc266..7114275 100644 --- a/OpenNest.Engine/MultiPlateNester.cs +++ b/OpenNest.Engine/MultiPlateNester.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using OpenNest.Engine.Fill; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Engine.Jobs.Placement.Fillers; using OpenNest.Geometry; using OpenNest.Math; @@ -26,6 +28,7 @@ namespace OpenNest.Engine private readonly IProgress _progress; private readonly CancellationToken _token; private readonly MultiPlateNestOptions _options; + private readonly string _strategy; private bool HasPlateOptions => _plateOptions != null && _plateOptions.Count > 0; @@ -45,8 +48,12 @@ namespace OpenNest.Engine _platePool = InitializePlatePool(existingPlates); _progress = progress; _token = token; + _strategy = PlateFillService.ResolveStrategy(options.Strategy); } + private PlateFillerBase CreateFiller(Plate plate) => + PlateFillService.CreateFiller(_strategy, plate); + // --- Static Utility Methods --- public static bool FitsBounds(Box container, Box part) @@ -226,7 +233,7 @@ namespace OpenNest.Engine private int FillAndPlace(PlateResult pr, Box zone, NestItem item) { - var engine = NestEngineRegistry.Create(pr.Plate); + var engine = CreateFiller(pr.Plate); var clonedItem = CloneItem(item); var parts = engine.Fill(clonedItem, zone, _progress, _token); @@ -381,7 +388,7 @@ namespace OpenNest.Engine if (remnants.Count == 0) break; - var engine = NestEngineRegistry.Create(pr.Plate); + var engine = CreateFiller(pr.Plate); foreach (var remnant in remnants) { @@ -430,7 +437,7 @@ namespace OpenNest.Engine if (remnants.Count == 0) break; - var engine = NestEngineRegistry.Create(plate); + var engine = CreateFiller(plate); foreach (var remnant in remnants) { @@ -643,7 +650,7 @@ namespace OpenNest.Engine upgradeOption, remnants => { - var engine = NestEngineRegistry.Create(target.Plate); + var engine = CreateFiller(target.Plate); var tempItems = donorParts .GroupBy(p => p.BaseDrawing) .Select(g => new NestItem diff --git a/OpenNest.Engine/MultiPlateResult.cs b/OpenNest.Engine/MultiPlateResult.cs index f1086a2..59ccb6e 100644 --- a/OpenNest.Engine/MultiPlateResult.cs +++ b/OpenNest.Engine/MultiPlateResult.cs @@ -10,6 +10,14 @@ namespace OpenNest.Engine public PartSortOrder SortOrder { get; set; } = PartSortOrder.BoundingBoxArea; public double MinRemnantSize { get; set; } = 12.0; public bool AllowPlateCreation { get; set; } = true; + + /// + /// Explicit placement strategy for every single-plate fill/pack this run performs + /// ("Default", "Strip", "Vertical Remnant", "Horizontal Remnant"). Null or empty means + /// "Default"; unknown names are rejected up front. This replaces reading the + /// process-global engine registry. + /// + public string Strategy { get; set; } } public class MultiPlateResult diff --git a/OpenNest.Engine/PlateOptimizer.cs b/OpenNest.Engine/PlateOptimizer.cs index 4796b09..47347c2 100644 --- a/OpenNest.Engine/PlateOptimizer.cs +++ b/OpenNest.Engine/PlateOptimizer.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using OpenNest.Engine; using OpenNest.Engine.BestFit; +using OpenNest.Engine.Jobs.Placement; using OpenNest.Geometry; using OpenNest.Math; @@ -18,7 +19,8 @@ namespace OpenNest.Engine double salvageRate, Plate templatePlate, IProgress progress = null, - CancellationToken token = default + CancellationToken token = default, + string strategy = null ) { if ( @@ -29,6 +31,10 @@ namespace OpenNest.Engine ) return null; + // Explicit strategy at the top-level boundary (null/empty = Default, unknown = throw); + // the size search never consults the process-global engine registry. + var resolvedStrategy = PlateFillService.ResolveStrategy(strategy); + // Find the minimum dimension needed to fit the largest part, // skipping items that are too large for every plate option. var minPartWidth = 0.0; @@ -94,7 +100,8 @@ namespace OpenNest.Engine salvageRate, templatePlate, progress, - token + token, + resolvedStrategy ); if (result == null) continue; @@ -149,7 +156,8 @@ namespace OpenNest.Engine double salvageRate, Plate templatePlate, IProgress progress, - CancellationToken token + CancellationToken token, + string strategy ) { // Create a temporary plate with candidate size + settings from template. @@ -178,8 +186,7 @@ namespace OpenNest.Engine }) .ToList(); - var engine = NestEngineRegistry.Create(tempPlate); - var parts = engine.Nest(clonedItems, progress, token); + var parts = PlateFillService.Nest(strategy, tempPlate, clonedItems, progress, token); if (parts == null || parts.Count == 0) return null; diff --git a/OpenNest.Tests/Engine/ExplicitPlacementStrategyTests.cs b/OpenNest.Tests/Engine/ExplicitPlacementStrategyTests.cs new file mode 100644 index 0000000..42c13a4 --- /dev/null +++ b/OpenNest.Tests/Engine/ExplicitPlacementStrategyTests.cs @@ -0,0 +1,155 @@ +using OpenNest.CNC; +using OpenNest.Engine; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Engine; + +/// +/// Phase 3.2: MultiPlateNester and PlateOptimizer take an explicit placement strategy at their +/// top-level call boundary instead of consulting the process-global NestEngineRegistry. +/// +public class ExplicitPlacementStrategyTests +{ + private static Drawing MakeRectDrawing(double w, double h, string name = "rect") + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(w, 0))); + pgm.Codes.Add(new LinearMove(new Vector(w, h))); + pgm.Codes.Add(new LinearMove(new Vector(0, h))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + return new Drawing(name, pgm); + } + + private static NestItem MakeItem(string name, double w, double h, int qty) => + new() { Drawing = MakeRectDrawing(w, h, name), Quantity = qty }; + + private static MultiPlateNestOptions MakeOptions() + { + var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 }; + template.EdgeSpacing = new Spacing(); + return new MultiPlateNestOptions { Template = template }; + } + + [Fact] + public void MultiPlateNester_UnsetStrategy_MatchesExplicitDefault() + { + var unsetOptions = MakeOptions(); + var explicitOptions = MakeOptions(); + explicitOptions.Strategy = "Default"; + Assert.Null(unsetOptions.Strategy); + + var unsetResult = MultiPlateNester.Nest( + new List { MakeItem("a", 20, 10, 3), MakeItem("b", 12, 8, 4) }, + unsetOptions + ); + var explicitResult = MultiPlateNester.Nest( + new List { MakeItem("a", 20, 10, 3), MakeItem("b", 12, 8, 4) }, + explicitOptions + ); + + Assert.NotEmpty(unsetResult.Plates); + Assert.Equal( + unsetResult.Plates.Sum(p => p.Parts.Count), + explicitResult.Plates.Sum(p => p.Parts.Count) + ); + } + + [Theory] + [InlineData("Mystery Engine")] + [InlineData("StockLadder")] + public void MultiPlateNester_RejectsUnknownStrategy(string strategy) + { + var options = MakeOptions(); + options.Strategy = strategy; + + Assert.Throws(() => + MultiPlateNester.Nest(new List { MakeItem("a", 20, 10, 1) }, options) + ); + } + + [Fact] + public void MultiPlateNester_HonorsExplicitStripStrategy() + { + var options = MakeOptions(); + options.Strategy = "Strip"; + + var result = MultiPlateNester.Nest( + new List { MakeItem("a", 20, 10, 3), MakeItem("b", 12, 8, 4) }, + options + ); + + Assert.NotEmpty(result.Plates); + Assert.Equal(0, result.UnplacedItems.Count); + } + + [Fact] + public void PlateOptimizer_UnsetStrategy_MatchesExplicitDefault() + { + var options = new List + { + new() { Width = 20, Length = 20, Cost = 100 }, + new() { Width = 40, Length = 40, Cost = 400 }, + }; + var templatePlate = new Plate(40, 40) { PartSpacing = 0 }; + var items = new List { MakeItem("rect", 10, 10, 1) }; + + var defaulted = PlateOptimizer.Optimize(items, options, 0.0, templatePlate); + var explicitDefault = PlateOptimizer.Optimize( + items, + options, + 0.0, + templatePlate, + strategy: "Default" + ); + + Assert.NotNull(defaulted); + Assert.NotNull(explicitDefault); + Assert.Equal(defaulted.ChosenSize.Width, explicitDefault.ChosenSize.Width); + Assert.Equal(defaulted.Parts.Count, explicitDefault.Parts.Count); + } + + [Theory] + [InlineData("Mystery Engine")] + [InlineData("StockLadder")] + public void PlateOptimizer_RejectsUnknownStrategy(string strategy) + { + var options = new List { new() { Width = 20, Length = 20, Cost = 100 } }; + var templatePlate = new Plate(40, 40) { PartSpacing = 0 }; + var items = new List { MakeItem("rect", 10, 10, 1) }; + + Assert.Throws(() => + PlateOptimizer.Optimize(items, options, 0.0, templatePlate, strategy: strategy) + ); + } + + [Fact] + public void PlateFillService_Nest_ResolvesNamedStrategyAndRejectsUnknown() + { + var plate = new Plate(new Size(60, 80)); + var drawing = MakeRectDrawing(6, 4, "part"); + + var parts = PlateFillService.Nest( + "Vertical Remnant", + plate, + new List { new() { Drawing = drawing, Quantity = 4 } }, + null, + CancellationToken.None + ); + + Assert.NotEmpty(parts); + Assert.All(parts, part => Assert.Same(drawing, part.BaseDrawing)); + Assert.Empty(plate.Parts); + + Assert.Throws(() => + PlateFillService.Nest( + "Mystery Engine", + plate, + new List { new() { Drawing = drawing, Quantity = 1 } }, + null, + CancellationToken.None + ) + ); + } +}