From ae704478afcfdc76548322e7716c47301c8028a8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 19 Sep 2026 08:15:40 -0400 Subject: [PATCH] feat(engine): add NestingEngineRegistry for whole-job INestingEngine plugin discovery --- .../Jobs/NestingEngineRegistryTests.cs | 49 ++++++++++ OpenNest.Engine/Jobs/NestingEngineInfo.cs | 18 ++++ OpenNest.Engine/Jobs/NestingEngineRegistry.cs | 94 +++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs create mode 100644 OpenNest.Engine/Jobs/NestingEngineInfo.cs create mode 100644 OpenNest.Engine/Jobs/NestingEngineRegistry.cs diff --git a/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs b/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs new file mode 100644 index 0000000..0766bca --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs @@ -0,0 +1,49 @@ +using Xunit; + +namespace OpenNest.Engine.Tests.Jobs; + +public class NestingEngineRegistryTests +{ + [Fact] + public void BuiltInStrategiesAreRegistered() + { + var names = NestingEngineRegistry.AvailableEngines.Select(e => e.Name).ToList(); + + Assert.Contains("Default", names); + Assert.Contains("Strip", names); + Assert.Contains("Vertical Remnant", names); + Assert.Contains("Horizontal Remnant", names); + } + + [Fact] + public void EachBuiltInFactoryProducesAWorkingEngine() + { + foreach (var info in NestingEngineRegistry.AvailableEngines) + { + var engine = info.Factory(); + var result = engine.Solve(FiniteStockJobTests.Job(1)); + + Assert.Equal(NestJobStatus.Complete, result.Status); + } + } + + [Fact] + public void DuplicateNameIsSkipped() + { + var before = NestingEngineRegistry.AvailableEngines.Count; + + NestingEngineRegistry.Register("Default", "duplicate", () => new FixedStrategyNestingEngine("Default")); + + Assert.Equal(before, NestingEngineRegistry.AvailableEngines.Count); + } + + [Fact] + public void LoadPluginsAgainstMissingDirectoryIsANoOp() + { + var before = NestingEngineRegistry.AvailableEngines.Count; + + NestingEngineRegistry.LoadPlugins(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); + + Assert.Equal(before, NestingEngineRegistry.AvailableEngines.Count); + } +} diff --git a/OpenNest.Engine/Jobs/NestingEngineInfo.cs b/OpenNest.Engine/Jobs/NestingEngineInfo.cs new file mode 100644 index 0000000..532893c --- /dev/null +++ b/OpenNest.Engine/Jobs/NestingEngineInfo.cs @@ -0,0 +1,18 @@ +using System; + +namespace OpenNest; + +/// Display metadata plus a fresh-instance factory for one registered whole-job engine. +public class NestingEngineInfo +{ + public NestingEngineInfo(string name, string description, Func factory) + { + Name = name; + Description = description; + Factory = factory; + } + + public string Name { get; } + public string Description { get; } + public Func Factory { get; } +} diff --git a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs new file mode 100644 index 0000000..2403c5a --- /dev/null +++ b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace OpenNest; + +/// +/// Registry of whole-job INestingEngine implementations, parallel to NestEngineRegistry (which is for +/// the legacy single-plate NestEngineBase). The four production strategies are exposed here through +/// FixedStrategyNestingEngine so they compete on equal footing with model-submitted engines. Unlike +/// NestEngineRegistry, this has no ActiveEngineName/global-selection concept — callers choose an engine +/// explicitly from AvailableEngines. +/// +public static class NestingEngineRegistry +{ + private static readonly List engines = new(); + + static NestingEngineRegistry() + { + Register("Default", "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)", + () => new FixedStrategyNestingEngine("Default")); + + Register("Strip", "Strip-based nesting for mixed-drawing layouts", + () => new FixedStrategyNestingEngine("Strip")); + + Register("Vertical Remnant", "Optimizes for largest right-side vertical drop", + () => new FixedStrategyNestingEngine("Vertical Remnant")); + + Register("Horizontal Remnant", "Optimizes for largest top-side horizontal drop", + () => new FixedStrategyNestingEngine("Horizontal Remnant")); + } + + public static IReadOnlyList AvailableEngines => engines; + + public static void Register(string name, string description, Func factory) + { + if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + Debug.WriteLine($"[NestingEngineRegistry] Duplicate engine '{name}' skipped"); + return; + } + + engines.Add(new NestingEngineInfo(name, description, factory)); + } + + /// Scans *.dll in directory for non-abstract INestingEngine types with a public + /// parameterless constructor, registering each under its CLR type name. Mirrors + /// NestEngineRegistry.LoadPlugins's per-assembly/per-type isolation: one bad plugin never + /// prevents the rest from loading. + public static void LoadPlugins(string directory) + { + if (!Directory.Exists(directory)) + return; + + foreach (var dll in Directory.GetFiles(directory, "*.dll")) + { + try + { + var assembly = Assembly.LoadFrom(dll); + + foreach (var type in assembly.GetTypes()) + { + if (type.IsAbstract || !typeof(INestingEngine).IsAssignableFrom(type)) + continue; + + var ctor = type.GetConstructor(Type.EmptyTypes); + + if (ctor == null) + { + Debug.WriteLine($"[NestingEngineRegistry] Skipping {type.Name}: no parameterless constructor"); + continue; + } + + try + { + Register(type.Name, string.Empty, () => (INestingEngine)ctor.Invoke(null)); + Debug.WriteLine($"[NestingEngineRegistry] Loaded plugin engine: {type.Name}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[NestingEngineRegistry] Failed to register {type.Name}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NestingEngineRegistry] Failed to load assembly {Path.GetFileName(dll)}: {ex.Message}"); + } + } + } +}