refactor(engine): make jobs plate nesters filler-backed

This commit is contained in:
aj
2026-09-21 18:08:25 -04:00
parent 073ead9b79
commit eb8fbec1aa
12 changed files with 401 additions and 345 deletions
@@ -1,74 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest.Engine.Jobs.Adapters;
/// <summary>
/// A fresh private legacy plate/drawing/item graph for each call. Only returned poses cross the boundary;
/// legacy quantity mutations are deliberately ignored. Does not certify geometric safety or rotation compliance.
/// </summary>
public sealed class LegacyPlateNesterAdapter : IPlateNester
{
private readonly Func<Plate, NestEngineBase> engineFactory;
public LegacyPlateNesterAdapter(Func<Plate, NestEngineBase> engineFactory)
{
ArgumentNullException.ThrowIfNull(engineFactory);
this.engineFactory = engineFactory;
}
/// <summary>Convenience overload delegating to <see cref="PlateNesterFactory"/> so strategy
/// resolution has a single source of truth; rejects unknown keys. Never reads or changes the
/// process-global NestEngineRegistry.</summary>
public static IPlateNester Create(string strategy) => PlateNesterFactory.Create(strategy);
public PlateCandidate Place(
PlatePlacementRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(request);
token.ThrowIfCancellationRequested();
var plate = DrawingJobMapper.CreatePlate(request.Stock);
var items = new List<NestItem>();
var identities = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
foreach (var requirement in request.Parts)
{
var drawing = DrawingJobMapper.CreateDrawing(requirement);
identities.Add(drawing, requirement.Id);
items.Add(
new NestItem
{
Drawing = drawing,
Quantity = requirement.Quantity,
Priority = requirement.Priority,
StepAngle = DrawingJobMapper.LegacyStep(requirement.Rotation),
RotationStart = requirement.Rotation.Start,
RotationEnd = requirement.Rotation.End,
}
);
}
var engine =
engineFactory(plate)
?? throw new InvalidOperationException("Legacy engine factory returned null.");
var legacyProgress = CandidateProgressBridge.Create(progress, request.Stock.Id);
var parts = engine.Nest(items, legacyProgress, token);
token.ThrowIfCancellationRequested();
if (parts == null)
throw new InvalidOperationException("Legacy engine returned null placements.");
var placements = new List<NestJobPlacement>();
foreach (var part in parts)
{
if (part?.BaseDrawing == null || !identities.TryGetValue(part.BaseDrawing, out var id))
throw new InvalidOperationException(
"Legacy placement does not reference a private requirement drawing."
);
placements.Add(
new NestJobPlacement(id, 0, part.Location.X, part.Location.Y, part.Rotation)
);
}
return new PlateCandidate(placements);
}
}
@@ -3,36 +3,38 @@ using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement.Fillers;
namespace OpenNest.Engine.Jobs.Placement;
/// <summary>
/// Migrated built-in placement strategy for the whole-job runner. Reuses <see cref="DefaultNestEngine"/>
/// fill/pack geometry but owns its own run-scoped bookkeeping: remaining demand is read from the
/// request and placement counts are derived from returned placements, so the engine's private
/// <see cref="NestItem.Quantity"/> mutations never feed back into job accounting.
/// Built-in placement strategy for the whole-job runner. Fills each candidate trial with a
/// <see cref="DefaultPlateFiller"/> on a fresh private plate and owns its own run-scoped
/// bookkeeping: remaining demand is read from the request and placement counts are derived from
/// returned placements, so the filler's private <see cref="NestItem.Quantity"/> mutations never
/// feed back into job accounting.
/// </summary>
/// <remarks>
/// The identity/progress boundary mechanics live in <see cref="CandidatePlacementContext"/>: one
/// private <see cref="Drawing"/> per requirement is created once per solve and reused across every
/// candidate trial (the runner reuses one <see cref="IPlateNester"/> instance per job). This is safe
/// because the engines mutate <see cref="NestItem.Quantity"/> (per-trial) and canonical-frame copies,
/// because the fillers mutate <see cref="NestItem.Quantity"/> (per-trial) and canonical-frame copies,
/// never the shared <see cref="Drawing"/> or its <c>Quantity</c>. Identity is by Drawing reference,
/// never by name. Each trial still gets a fresh private <see cref="Plate"/>.
/// </remarks>
public sealed class DefaultPlateNester : IPlateNester
{
private readonly Func<Plate, DefaultNestEngine> engineFactory;
private readonly Func<Plate, DefaultPlateFiller> fillerFactory;
private readonly OrderedPlateNester restrictedRotationNester = new();
private readonly CandidatePlacementContext context = new();
public DefaultPlateNester()
: this(static plate => new DefaultNestEngine(plate)) { }
: this(static plate => new DefaultPlateFiller(plate)) { }
/// <param name="engineFactory">Injectable for tests; defaults to <see cref="DefaultNestEngine"/>.</param>
public DefaultPlateNester(Func<Plate, DefaultNestEngine> engineFactory)
/// <param name="fillerFactory">Injectable for tests; defaults to <see cref="DefaultPlateFiller"/>.</param>
internal DefaultPlateNester(Func<Plate, DefaultPlateFiller> fillerFactory)
{
this.engineFactory =
engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
this.fillerFactory =
fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory));
}
public PlateCandidate Place(
@@ -44,26 +46,26 @@ public sealed class DefaultPlateNester : IPlateNester
ArgumentNullException.ThrowIfNull(request);
token.ThrowIfCancellationRequested();
// The legacy engine cannot express a locked or bounded rotation (start == end == 0 reads
// as "unconstrained") and its Pairs/RectBestFit strategies rotate freely, so it can return
// poses the requirement's RotationPolicy forbids. Restricted requirements go to the
// The Default fill pipeline cannot express a locked or bounded rotation (start == end == 0
// reads as "unconstrained") and its Pairs/RectBestFit strategies rotate freely, so it can
// return poses the requirement's RotationPolicy forbids. Restricted requirements go to the
// policy-aware ordered nester, which only proposes allowed angles and validates each pose.
if (request.Parts.Any(part => part.Rotation.Kind != RotationPolicyKind.Automatic))
return restrictedRotationNester.Place(request, progress, token);
var plate = DrawingJobMapper.CreatePlate(request.Stock);
// Quantity is the request's remaining demand; the engine may mutate these per-trial items,
// Quantity is the request's remaining demand; the filler may mutate these per-trial items,
// and that mutation is deliberately discarded — placement counts come from the result.
var items = context.CreateItems(request.Parts);
var engine =
engineFactory(plate)
?? throw new InvalidOperationException("Engine factory returned null.");
var filler =
fillerFactory(plate)
?? throw new InvalidOperationException("Filler factory returned null.");
var legacyProgress = CandidateProgressBridge.Create(progress, request.Stock.Id);
var parts = engine.Nest(items, legacyProgress, token);
var parts = filler.Nest(items, legacyProgress, token);
token.ThrowIfCancellationRequested();
if (parts == null)
throw new InvalidOperationException("Engine returned null placements.");
throw new InvalidOperationException("Filler returned null placements.");
return new PlateCandidate(context.MapPlacements(parts));
}
@@ -0,0 +1,68 @@
using System;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement.Fillers;
namespace OpenNest.Engine.Jobs.Placement;
/// <summary>
/// Built-in remnant placement strategy for the whole-job runner. Fills one candidate per trial with
/// a <see cref="RemnantPlateFiller"/> for the vertical or horizontal remnant policy, on a fresh
/// private plate, using the same run-scoped identity, progress, and accounting boundaries as
/// <see cref="DefaultPlateNester"/>.
/// </summary>
/// <remarks>
/// The remnant fillers share the Default pipeline's automatic-rotation limitation, so non-automatic
/// requirements route through <see cref="OrderedPlateNester"/> exactly as they do for Default.
/// </remarks>
public sealed class RemnantPlateNester : IPlateNester
{
private readonly Func<Plate, RemnantPlateFiller> fillerFactory;
private readonly OrderedPlateNester restrictedRotationNester = new();
private readonly CandidatePlacementContext context = new();
internal RemnantPlateNester(Func<Plate, RemnantPlateFiller> fillerFactory)
{
this.fillerFactory =
fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory));
}
/// <summary>Vertical-remnant policy: minimize X-extent, prefer horizontal placement.</summary>
internal static RemnantPlateNester Vertical() =>
new(plate => new RemnantPlateFiller(plate, RemnantFillPolicy.Vertical));
/// <summary>Horizontal-remnant policy: minimize Y-extent, prefer vertical placement.</summary>
internal static RemnantPlateNester Horizontal() =>
new(plate => new RemnantPlateFiller(plate, RemnantFillPolicy.Horizontal));
public PlateCandidate Place(
PlatePlacementRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(request);
token.ThrowIfCancellationRequested();
// Same safety rule as DefaultPlateNester: the remnant fillers inherit the Default pipeline's
// automatic-rotation limitation, so any non-automatic requirement goes to the policy-aware
// ordered nester.
if (request.Parts.Any(part => part.Rotation.Kind != RotationPolicyKind.Automatic))
return restrictedRotationNester.Place(request, progress, token);
var plate = DrawingJobMapper.CreatePlate(request.Stock);
var items = context.CreateItems(request.Parts);
var filler =
fillerFactory(plate)
?? throw new InvalidOperationException("Filler factory returned null.");
var candidateProgress = CandidateProgressBridge.Create(progress, request.Stock.Id);
var parts = filler.Nest(items, candidateProgress, token);
token.ThrowIfCancellationRequested();
if (parts == null)
throw new InvalidOperationException("Filler returned null placements.");
return new PlateCandidate(context.MapPlacements(parts));
}
}
@@ -2,32 +2,34 @@ using System;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement.Fillers;
namespace OpenNest.Engine.Jobs.Placement;
/// <summary>
/// Migrated built-in placement strategy for the whole-job runner. Reuses <see cref="StripNestEngine"/>
/// iterative shrink-fill/pack geometry with the same run-scoped bookkeeping as <see cref="DefaultPlateNester"/>:
/// remaining demand is read from the request and placement counts are derived from returned placements.
/// Built-in placement strategy for the whole-job runner. Runs the <see cref="StripPlateFiller"/>
/// iterative shrink-fill/pack geometry with the same run-scoped bookkeeping as
/// <see cref="DefaultPlateNester"/>: remaining demand is read from the request and placement counts
/// are derived from returned placements.
/// </summary>
/// <remarks>
/// The identity/progress boundary mechanics live in <see cref="CandidatePlacementContext"/>: a private
/// <see cref="Drawing"/> per requirement is created once per solve and reused across trials
/// (safe: the engine mutates per-trial <see cref="NestItem.Quantity"/> and canonical copies, never the
/// (safe: the filler mutates per-trial <see cref="NestItem.Quantity"/> and canonical copies, never the
/// shared Drawing). Identity is by Drawing reference. Each trial gets a fresh private <see cref="Plate"/>.
/// </remarks>
public sealed class StripPlateNester : IPlateNester
{
private readonly Func<Plate, StripNestEngine> engineFactory;
private readonly Func<Plate, StripPlateFiller> fillerFactory;
private readonly CandidatePlacementContext context = new();
public StripPlateNester()
: this(static plate => new StripNestEngine(plate)) { }
: this(static plate => new StripPlateFiller(plate)) { }
/// <param name="engineFactory">Injectable for tests; defaults to <see cref="StripNestEngine"/>.</param>
public StripPlateNester(Func<Plate, StripNestEngine> engineFactory)
/// <param name="fillerFactory">Injectable for tests; defaults to <see cref="StripPlateFiller"/>.</param>
internal StripPlateNester(Func<Plate, StripPlateFiller> fillerFactory)
{
this.engineFactory =
engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
this.fillerFactory =
fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory));
}
public PlateCandidate Place(
@@ -42,14 +44,14 @@ public sealed class StripPlateNester : IPlateNester
var plate = DrawingJobMapper.CreatePlate(request.Stock);
var items = context.CreateItems(request.Parts);
var engine =
engineFactory(plate)
?? throw new InvalidOperationException("Engine factory returned null.");
var filler =
fillerFactory(plate)
?? throw new InvalidOperationException("Filler factory returned null.");
var legacyProgress = CandidateProgressBridge.Create(progress, request.Stock.Id);
var parts = engine.Nest(items, legacyProgress, token);
var parts = filler.Nest(items, legacyProgress, token);
token.ThrowIfCancellationRequested();
if (parts == null)
throw new InvalidOperationException("Engine returned null placements.");
throw new InvalidOperationException("Filler returned null placements.");
return new PlateCandidate(context.MapPlacements(parts));
}
+6 -11
View File
@@ -1,14 +1,13 @@
using System;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
namespace OpenNest.Engine.Jobs;
/// <summary>
/// Instance-scoped strategy resolution for the whole-job runner. Default and Strip resolve to the
/// migrated built-in plate nesters; the remnant strategies still use the legacy adapter during
/// rollout. The process-global NestEngineRegistry (including plugin registrations and
/// ActiveEngineName) is neither read nor modified. Unknown keys reject.
/// Instance-scoped strategy resolution for the whole-job runner. All four built-in strategies
/// resolve directly to filler-backed plate nesters. The process-global NestEngineRegistry
/// (including plugin registrations and ActiveEngineName) is neither read nor modified.
/// Unknown keys reject.
/// </summary>
public static class PlateNesterFactory
{
@@ -19,12 +18,8 @@ public static class PlateNesterFactory
{
"Default" => new DefaultPlateNester(),
"Strip" => new StripPlateNester(),
"Vertical Remnant" => new LegacyPlateNesterAdapter(plate => new VerticalRemnantEngine(
plate
)),
"Horizontal Remnant" => new LegacyPlateNesterAdapter(
plate => new HorizontalRemnantEngine(plate)
),
"Vertical Remnant" => RemnantPlateNester.Vertical(),
"Horizontal Remnant" => RemnantPlateNester.Horizontal(),
_ => throw new NotSupportedException($"Unknown placement strategy: {strategy}."),
};
}