refactor(engine): separate plate placement from job allocation

This commit is contained in:
aj
2026-09-18 08:38:23 -04:00
parent ad69023c17
commit 02141f6ca5
5 changed files with 448 additions and 5 deletions
@@ -0,0 +1,26 @@
using System;
namespace OpenNest;
/// <summary>
/// Bridges legacy <see cref="IProgress{NestProgress}"/> reporting into job progress while a candidate
/// trial is being evaluated. Used by both the legacy adapter and the migrated built-in nesters so the
/// stage/context mapping has one implementation.
/// </summary>
internal static class CandidateProgressBridge
{
internal static IProgress<NestProgress> Create(IProgress<NestJobProgress> progress, string stockId)
{
if (progress == null) return null;
return new LegacyToJob(progress, stockId);
}
private sealed class LegacyToJob(IProgress<NestJobProgress> progress, string stockId) : IProgress<NestProgress>
{
public void Report(NestProgress value)
{
ArgumentNullException.ThrowIfNull(value);
progress.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stockId, -1, 0, 0, value));
}
}
}
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest;
/// <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.
/// </summary>
/// <remarks>
/// A 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,
/// 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 Dictionary<string, Drawing> drawingsById = new(StringComparer.Ordinal);
private readonly Dictionary<Drawing, string> idByDrawing = new(ReferenceEqualityComparer.Instance);
public DefaultPlateNester() : this(static plate => new DefaultNestEngine(plate))
{
}
/// <param name="engineFactory">Injectable for tests; defaults to <see cref="DefaultNestEngine"/>.</param>
public DefaultPlateNester(Func<Plate, DefaultNestEngine> engineFactory)
{
this.engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
}
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>(request.Parts.Count);
foreach (var requirement in request.Parts)
{
if (!drawingsById.TryGetValue(requirement.Id, out var drawing))
{
drawing = DrawingJobMapper.CreateDrawing(requirement);
drawingsById.Add(requirement.Id, drawing);
idByDrawing.Add(drawing, requirement.Id);
}
// Quantity is the request's remaining demand; the engine may mutate this per-trial item,
// and that mutation is deliberately discarded — placement counts come from the result.
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("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("Engine returned null placements.");
var placements = new List<NestJobPlacement>(parts.Count);
foreach (var part in parts)
{
if (part?.BaseDrawing == null || !idByDrawing.TryGetValue(part.BaseDrawing, out var id))
throw new InvalidOperationException("Placement does not reference a known requirement drawing.");
placements.Add(new NestJobPlacement(id, 0, part.Location.X, part.Location.Y, part.Rotation));
}
return new PlateCandidate(placements);
}
}
@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest;
/// <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.
/// </summary>
/// <remarks>
/// 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
/// 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 Dictionary<string, Drawing> drawingsById = new(StringComparer.Ordinal);
private readonly Dictionary<Drawing, string> idByDrawing = new(ReferenceEqualityComparer.Instance);
public StripPlateNester() : this(static plate => new StripNestEngine(plate))
{
}
/// <param name="engineFactory">Injectable for tests; defaults to <see cref="StripNestEngine"/>.</param>
public StripPlateNester(Func<Plate, StripNestEngine> engineFactory)
{
this.engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
}
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>(request.Parts.Count);
foreach (var requirement in request.Parts)
{
if (!drawingsById.TryGetValue(requirement.Id, out var drawing))
{
drawing = DrawingJobMapper.CreateDrawing(requirement);
drawingsById.Add(requirement.Id, drawing);
idByDrawing.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("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("Engine returned null placements.");
var placements = new List<NestJobPlacement>(parts.Count);
foreach (var part in parts)
{
if (part?.BaseDrawing == null || !idByDrawing.TryGetValue(part.BaseDrawing, out var id))
throw new InvalidOperationException("Placement does not reference a known requirement drawing.");
placements.Add(new NestJobPlacement(id, 0, part.Location.X, part.Location.Y, part.Rotation));
}
return new PlateCandidate(placements);
}
}
+6 -5
View File
@@ -2,9 +2,10 @@ using System;
namespace OpenNest;
/// <summary>
/// Instance-scoped strategy resolution for the whole-job runner. The built-in strategies map
/// to private engine factories; 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. 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.
/// </summary>
public static class PlateNesterFactory
{
@@ -13,8 +14,8 @@ public static class PlateNesterFactory
ArgumentNullException.ThrowIfNull(strategy);
return strategy switch
{
"Default" => new LegacyPlateNesterAdapter(plate => new DefaultNestEngine(plate)),
"Strip" => new LegacyPlateNesterAdapter(plate => new StripNestEngine(plate)),
"Default" => new DefaultPlateNester(),
"Strip" => new StripPlateNester(),
"Vertical Remnant" => new LegacyPlateNesterAdapter(plate => new VerticalRemnantEngine(plate)),
"Horizontal Remnant" => new LegacyPlateNesterAdapter(plate => new HorizontalRemnantEngine(plate)),
_ => throw new NotSupportedException($"Unknown placement strategy: {strategy}.")