Files
OpenNest/OpenNest.Engine/Jobs/NestingEngineRegistry.cs
T
ajandClaude Opus 5.5 54ad7ee84d feat(server): nest Auto Nest jobs on the server's worker
Large nests tie up the workstation that runs them. In Database mode, Auto Nest
can now queue the job on the nest server, where a worker container solves one
job at a time under its own CPU and memory limits. The job has no time limit,
so a throttled worker finds the same nest a fast one does, only later.

- Server: /api/nest-jobs queue (schema v15). The worker holds a job on a
  heartbeat lease. When the lease lapses, the job goes back to the queue,
  or fails after three attempts. Only one job runs at a time. Packages and
  results are opaque to the HTTP server, which stays off Engine and IO.
- Worker: OpenNest.Server.Worker in the same image (OPENNEST_ROLE=worker),
  reaching the server over HTTP only. It records each engine answer with
  RecordingNestingEngine and gives its job back on shutdown.
- Desktop: "Nest on the server" in the Auto Nest dialog, for built-in
  engines only. The client replays the recorded answers with
  RemoteNestSubmission, so validation, the review and Keep run locally
  against the client's own drawings, exactly as after a local run. A
  drawing edited after sending refuses the result. If the server cannot
  take the job, the user may nest locally instead.
- compose.server.yaml adds the throttled worker service. The Dockerfile,
  .dockerignore, CI paths and the deploy script now include the worker's
  projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:18:09 -05:00

219 lines
8.2 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using OpenNest.Engine.NestingEngines.Circles;
using OpenNest.Engine.NestingEngines.Default;
using OpenNest.Engine.NestingEngines.FlatBar;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Engine.NestingEngines.Rectangles;
namespace OpenNest.Engine.Jobs;
/// <summary>
/// Registry of whole-job <see cref="INestingEngine"/> implementations. "Default" chooses among the
/// built-in engines per job; the four fill strategies are exposed through
/// <see cref="FixedStrategyNestingEngine"/>. Callers choose an
/// engine explicitly from <see cref="AvailableEngines"/>; there is no process-global active
/// selection. Plug-ins loaded from an Engines/ folder register under their CLR type name.
/// </summary>
public static class NestingEngineRegistry
{
private static readonly List<NestingEngineInfo> engines = new();
// Names registered by this assembly, before any plug-in could load.
private static readonly HashSet<string> builtInNames;
/// <summary>
/// Registry names used by earlier releases, mapped to the engine that replaced them, so saved
/// selections and scripts keep working. Consulted only when no engine has the requested name.
/// </summary>
private static readonly Dictionary<string, string> RenamedEngines = new(StringComparer.OrdinalIgnoreCase)
{
["Opus55NestingEngine"] = "Irregular",
["RectanglesNestingEngine"] = "Rectangles",
// Retired: Default chooses stock at least as well on every benchmark job.
["StockLadder"] = "Default",
["Fill"] = "Lattice",
};
static NestingEngineRegistry()
{
// Listed first: the engine front ends use when the caller names none.
Register(
"Default",
"Any job: tries Rectangles, Circles and Irregular and keeps the best",
() => new DefaultNestingEngine()
);
Register(
"Rectangles",
"Packs each part as a box; best for plain rectangular parts",
() => new RectanglesNestingEngine()
);
Register(
"Irregular",
"Fits irregular shapes into each other's gaps and notches",
() => new IrregularNestingEngine()
);
Register(
"Circles",
"Round parts such as blanks, washers and flanges",
() => new CirclesNestingEngine()
);
Register(
"FlatBar",
"One row of parts along a flat bar",
() => new FlatBarNestingEngine()
);
Register(
"Lattice",
"Repeats parts in tight rows and pairs; best for many copies of a few parts",
() => new FixedStrategyNestingEngine("Lattice")
);
Register(
"Strip",
"Fills one strip at a time, part by part; each uses the space the last one left",
() => new FixedStrategyNestingEngine("Strip")
);
Register(
"Vertical Remnant",
"Repeats parts in rows and pairs, keeping the largest drop on the right side",
() => new FixedStrategyNestingEngine("Vertical Remnant")
);
Register(
"Horizontal Remnant",
"Repeats parts in rows and pairs, keeping the largest drop on the top",
() => new FixedStrategyNestingEngine("Horizontal Remnant")
);
builtInNames = new HashSet<string>(engines.Select(e => e.Name), StringComparer.OrdinalIgnoreCase);
}
public static IReadOnlyList<NestingEngineInfo> AvailableEngines => engines;
/// <summary>
/// Registered name for <paramref name="name"/>: an exact (case-insensitive) match, else the
/// engine a renamed legacy name now maps to, else null. Hosts use this to restore a saved
/// selection made under an old name.
/// </summary>
public static string? ResolveName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
return null;
var trimmed = name.Trim();
var info = engines.FirstOrDefault(e => e.Name.Equals(trimmed, StringComparison.OrdinalIgnoreCase));
if (info != null)
return info.Name;
return RenamedEngines.TryGetValue(trimmed, out var renamed)
&& engines.FirstOrDefault(e => e.Name.Equals(renamed, StringComparison.OrdinalIgnoreCase)) is { } target
? target.Name
: null;
}
/// <summary>
/// Whether <paramref name="name"/> (or the engine a renamed legacy name maps to) ships with
/// OpenNest rather than coming from a plug-in. Only built-in engines run on the nest server,
/// which loads no plug-ins.
/// </summary>
public static bool IsBuiltIn(string? name) =>
ResolveName(name) is { } resolved && builtInNames.Contains(resolved);
/// <summary>
/// Creates the engine registered under <paramref name="name"/> (case-insensitive, renamed legacy
/// names accepted). The caller's explicit choice is the whole selection mechanism; unknown names throw.
/// </summary>
public static INestingEngine Create(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var resolved = ResolveName(name);
var info = resolved == null ? null : engines.First(e => e.Name == resolved);
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)))
{
Debug.WriteLine($"[NestingEngineRegistry] Duplicate engine '{name}' skipped");
return;
}
// A leftover plug-in under a renamed engine's old name would shadow its built-in replacement.
if (RenamedEngines.ContainsKey(name))
{
Debug.WriteLine($"[NestingEngineRegistry] '{name}' skipped: replaced by built-in '{RenamedEngines[name]}'");
return;
}
engines.Add(new NestingEngineInfo(name, description, factory));
}
/// <summary>Scans *.dll in directory for non-abstract INestingEngine types with a public
/// parameterless constructor, registering each under its CLR type name. Per-assembly and per-type
/// isolation ensures one bad plugin never prevents the rest from loading.</summary>
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}"
);
}
}
}
}