Files
OpenNest/OpenNest.Engine/Jobs/NestPipeline.cs
T
ajandClaude Opus 5.5 cd2bea054b feat(desktop): show Auto Nest as a sheet strip with a Keep/Discard review
Auto Nest no longer draws parts on the plate view before they are kept:
its result is a set of sheets, possibly of several sizes, not parts on the
current plate. Ghost parts stay with interactive Fill and remnant tools.

- NestJobStage.PlanImproved carries an engine's best whole-job plan so far;
  Irregular reports one whenever a variant or the tail re-plan beats it.
- NestPipeline adds PlanSheets: the plan on detached drawings built from
  the job geometry (with the caller's names and colors), so previews never
  touch caller drawings or quantities.
- SheetStripControl draws every sheet side by side at one scale, repeats
  once with their count, captioned with size, parts and utilization.
- The progress window shows "Best plan so far"; a review window shows the
  final sheets, totals, cost summary and unplaced parts before commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:56:38 -04:00

425 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Cutouts;
namespace OpenNest.Engine.Jobs;
/// <summary>
/// One automatic-nesting request: the named engine, the caller's items (drawing, quantity,
/// priority, rotation), the stock it may use, and optional job options. Items with a
/// nonpositive quantity are skipped.
/// </summary>
public sealed record NestPipelineRequest(
string EngineName,
IReadOnlyList<NestItem> Items,
IReadOnlyList<NestPlateStock> Stock,
NestJobOptions Options = null
);
/// <summary>A proposed layout: new parts bound to caller drawings. PlateIndex is the first
/// raw physical occurrence; Quantity is the total physical copies, including the first.</summary>
public sealed record ProposedPlate(int PlateIndex, NestPlateStock Stock, IReadOnlyList<Part> Parts)
{
private int quantity = 1;
public int Quantity
{
get => quantity;
init => quantity = value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(Quantity));
}
}
/// <summary>
/// The engine's raw result, its independent validation, and the proposed plates. Nothing
/// has been committed; the caller decides what to do with an invalid result.
/// </summary>
public sealed class NestPipelineResult
{
internal NestPipelineResult(
string engineName,
NestJob job,
NestJobResult raw,
IReadOnlyList<ProposedPlate> plates,
IReadOnlyList<string> violations,
bool canKeep,
TimeSpan solveTime,
TimeSpan validationTime,
IReadOnlyDictionary<string, Drawing> drawingsByPartId,
IReadOnlyList<NestUnplacedPart> unplaced
)
{
EngineName = engineName;
Job = job;
Raw = raw;
Plates = plates;
Violations = violations;
CanKeep = canKeep;
SolveTime = solveTime;
ValidationTime = validationTime;
DrawingsByPartId = drawingsByPartId;
Unplaced = unplaced;
}
public string EngineName { get; }
public NestJob Job { get; }
public NestJobResult Raw { get; }
public IReadOnlyList<ProposedPlate> Plates { get; }
public IReadOnlyList<string> Violations { get; }
public bool IsValid => Violations.Count == 0;
/// <summary>True when every placement can be represented, even if layout rules fail.
/// False for unknown/null requirement IDs or nonfinite poses; Plates is then empty.</summary>
public bool CanKeep { get; }
public NestJobStatus Status => Raw.Status;
public NestJobStopReason StopReason => Raw.StopReason;
public TimeSpan SolveTime { get; }
public TimeSpan ValidationTime { get; }
internal IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
/// <summary>Every requested copy this proposal leaves unplaced, by requirement and reason,
/// counted from the returned placements. Empty when the proposal places every copy; when it
/// cannot be kept, all demand is listed as <see cref="NestUnplacedReason.ProposalNotKeepable"/>.</summary>
public IReadOnlyList<NestUnplacedPart> Unplaced { get; }
/// <summary>Why a bundled cutout trial was refused before this ordinary solve replaced it;
/// empty when no trial ran or the trial was not refused. Never part of <see cref="Violations"/>:
/// the refused trial has no bindable plates.</summary>
public IReadOnlyList<string> RefusedCutoutTrial { get; private init; } = Array.Empty<string>();
internal NestPipelineResult WithRefusedCutoutTrial(IReadOnlyList<string> refusals) =>
new(EngineName, Job, Raw, Plates, Violations, CanKeep, SolveTime, ValidationTime,
DrawingsByPartId, Unplaced)
{ RefusedCutoutTrial = refusals };
}
/// <summary>
/// The single automatic-nesting path shared by every front end: build the job, solve it
/// with the named engine, validate the result with the benchmark's rules, then bind
/// placements to the caller's drawings. The pipeline never mutates caller items, drawings,
/// or plates, and it does not care which engine is selected.
/// </summary>
public static class NestPipeline
{
private sealed class PreviewProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value)
{
if (value != null && value.Stage is NestJobStage.EvaluatingCandidate or NestJobStage.PlanImproved)
inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 });
}
}
/// <summary>
/// Fills <see cref="NestJobProgress.PlanSheets"/> on an engine's plan reports. Parts use
/// detached drawings built from the solved job's geometry (bundled cutout frames included),
/// one per part, so a preview never touches the caller's drawings or their quantities.
/// </summary>
private sealed class PlanProgress(IProgress<NestJobProgress> inner, NestJob engineJob,
IReadOnlyDictionary<string, Drawing> callerDrawings, CancellationToken token)
: IProgress<NestJobProgress>
{
private readonly Dictionary<string, NestJobPart> parts =
engineJob.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
private readonly Dictionary<string, Drawing> drawings = new(StringComparer.Ordinal);
public void Report(NestJobProgress value)
{
if (value?.Stage == NestJobStage.PlanImproved && value.Plan != null && value.PlanSheets == null)
{
lock (drawings)
{
// A plan naming unknown parts has nothing to draw; validation reports it later.
if (value.Plan.Plates.Any(s => s.Placements.Any(p => p.PartId == null || !parts.ContainsKey(p.PartId))))
return;
value = value with { PlanSheets = Sheets(value.Plan) };
}
}
inner.Report(value);
}
private IReadOnlyList<ProposedPlate> Sheets(NestJobResult plan)
{
var sheets = new List<ProposedPlate>();
foreach (var group in RepeatedPlateGrouping.Create(plan.Plates, true, token))
{
var placed = new List<Part>(group.Sheet.Placements.Count);
foreach (var pose in group.Sheet.Placements)
{
if (!drawings.TryGetValue(pose.PartId, out var drawing))
{
drawing = DrawingJobMapper.CreateDrawing(parts[pose.PartId]);
// Borrow the caller's name and color for display; bundled frames keep their own.
if (callerDrawings.TryGetValue(pose.PartId, out var caller))
{
drawing.Name = caller.Name;
drawing.Color = caller.Color;
}
drawings[pose.PartId] = drawing;
}
// Same pose mapping as NestResultMaterializer: rotate in the snapshot frame, then move.
var part = new Part(drawing);
part.Rotate(pose.Rotation);
part.Location = new Geometry.Vector(pose.X, pose.Y);
part.UpdateBounds();
placed.Add(part);
}
sheets.Add(new ProposedPlate(group.Sheet.PlateIndex, group.Sheet.Stock, placed) { Quantity = group.Quantity });
}
return sheets;
}
}
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
/// Engine cancellation propagates unchanged.</summary>
public static NestPipelineResult Run(
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(request);
var engine = NestingEngineRegistry.Create(request.EngineName);
return Run(engine, request.EngineName, request, progress, token);
}
/// <summary>Runs a caller-supplied engine instance through the same validation and binding.
/// Parts that fit other parts' enclosed cutouts are first bundled into their frames
/// (engine-agnostic); see <see cref="CutoutPipelinePrepass"/>. The engine then solves once.
/// A bundled trial that fails physical validation, or leaves demand unplaced, is compared
/// with an ordinary solve of the same job and the better valid proposal is returned.</summary>
public static NestPipelineResult Run(
INestingEngine engine,
string engineName,
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
) => Run(engine, engineName, request, progress, token, cutouts: true);
/// <summary>The ordinary solve alone, with no cutout pre-pass: a characterization baseline
/// for tests and benchmarks, not a front-end setting.</summary>
internal static NestPipelineResult RunWithoutCutouts(
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
) => Run(NestingEngineRegistry.Create(request.EngineName), request.EngineName, request,
progress, token, cutouts: false);
/// <summary>The bundled cutout trial alone, without the ordinary-solve comparison, so tests
/// can exercise its validation boundary. Jobs without bundles run the ordinary solve.</summary>
internal static NestPipelineResult RunCutoutTrial(
INestingEngine engine,
string engineName,
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
)
{
var (job, drawingsByPartId) = CreateJob(engine, request, token);
var prepass = CutoutPipelinePrepass.Prepare(job, token);
var result = Evaluate(engine, engineName, job, prepass, drawingsByPartId, progress, token);
return prepass != null && result.CanKeep && result.IsValid
? Committed(result, progress, token) : result;
}
internal static NestPipelineResult RunCutoutTrial(
NestPipelineRequest request,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
) => RunCutoutTrial(NestingEngineRegistry.Create(request.EngineName),
request.EngineName, request, progress, token);
private static NestPipelineResult Run(
INestingEngine engine,
string engineName,
NestPipelineRequest request,
IProgress<NestJobProgress> progress,
CancellationToken token,
bool cutouts
)
{
var (job, drawingsByPartId) = CreateJob(engine, request, token);
var prepass = cutouts ? CutoutPipelinePrepass.Prepare(job, token) : null;
if (prepass == null)
return Evaluate(engine, engineName, job, null, drawingsByPartId, progress, token);
var trial = Evaluate(engine, engineName, job, prepass, drawingsByPartId, progress, token);
var trialKeepable = trial.CanKeep && trial.IsValid;
if (trialKeepable && trial.Status == NestJobStatus.Complete)
return Committed(trial, progress, token);
// Unplaced frames return their reserved inserts to unplaced demand, and an untrusted
// composite is never bindable; the ordinary solve is the established baseline.
var plain = Evaluate(engine, engineName, job, null, drawingsByPartId, progress, token);
if (!trialKeepable)
return plain.WithRefusedCutoutTrial(trial.Violations);
if (plain.CanKeep && plain.IsValid && Placed(plain) > Placed(trial))
return plain;
return Committed(trial, progress, token);
}
private static (NestJob Job, Dictionary<string, Drawing> Drawings) CreateJob(
INestingEngine engine,
NestPipelineRequest request,
CancellationToken token
)
{
ArgumentNullException.ThrowIfNull(engine);
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.Items);
ArgumentNullException.ThrowIfNull(request.Stock);
token.ThrowIfCancellationRequested();
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
var parts = new List<NestJobPart>();
for (var i = 0; i < request.Items.Count; i++)
{
var item = request.Items[i];
if (item == null || item.Quantity <= 0)
continue;
var partId = $"part-{i}";
parts.Add(DrawingJobMapper.FromItem(partId, item));
drawingsByPartId[partId] = item.Drawing;
}
var job = new NestJob(parts, request.Stock, request.Options);
NestJobValidator.Validate(job);
return (job, drawingsByPartId);
}
private static int Placed(NestPipelineResult result) =>
result.Raw.Plates.Sum(p => p.Placements.Count);
// Trial progress hid the engine's proxy commit counts; report validated physical ones.
private static NestPipelineResult Committed(NestPipelineResult result,
IProgress<NestJobProgress> progress, CancellationToken token)
{
var count = 0;
for (var i = 0; i < result.Raw.Plates.Count; i++)
{
count += result.Raw.Plates[i].Placements.Count;
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
result.Raw.Plates[i].StockId, i, i + 1, count));
}
token.ThrowIfCancellationRequested();
return result;
}
private static NestPipelineResult Evaluate(
INestingEngine engine,
string engineName,
NestJob job,
CutoutPipelinePrepass prepass,
IReadOnlyDictionary<string, Drawing> drawingsByPartId,
IProgress<NestJobProgress> progress,
CancellationToken token
)
{
var engineJob = prepass?.EngineJob ?? job;
IReadOnlyDictionary<string, int> released = new Dictionary<string, int>();
if (prepass != null)
NestJobValidator.Validate(engineJob);
var clock = Stopwatch.StartNew();
var engineProgress = progress == null ? null
: new PlanProgress(prepass == null ? progress : new PreviewProgress(progress), engineJob, drawingsByPartId, token);
var raw =
engine.Solve(engineJob, engineProgress, token)
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
var solveTime = clock.Elapsed;
token.ThrowIfCancellationRequested();
// D8/C10: only the ordinary solve offers last-resort clamp removal; an incomplete
// cutout trial is compared with that solve instead. No-op whenever nothing is blocked.
if (prepass == null)
{
var recovered = ClampRemovalRecovery.TryRecover(job, raw, engine, token);
if (recovered != null)
{
job = recovered.Job;
engineJob = recovered.Job;
raw = recovered.Result;
}
}
token.ThrowIfCancellationRequested();
clock.Restart();
var names = drawingsByPartId.ToDictionary(
kv => kv.Key,
kv => kv.Value.Name ?? kv.Key,
StringComparer.Ordinal
);
var checkedNames = prepass == null ? names : engineJob.Parts.ToDictionary(p => p.Id,
p => names.GetValueOrDefault(p.Id, p.Id), StringComparer.Ordinal);
var violations = NestLayoutCheck.Violations(engineJob, raw, checkedNames, out var canKeep).ToList();
if (prepass != null)
{
// A compound may be physically invalid even when its envelope is legal.
// Refuse every unverified trial, including with explicit invalid-result consent.
var failure = "Cutout engine output failed reconciliation";
if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure))
{
released = prepass.ReleasedInserts(raw);
raw = expanded;
violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep));
canKeep = physicalKeep && violations.Count == 0;
}
else
{
if (violations.Count == 0)
violations.Add(failure);
canKeep = false;
}
}
var freshness = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId);
violations.AddRange(freshness);
if (freshness.Count > 0)
canKeep = false;
var validationTime = clock.Elapsed;
if (canKeep && violations.Count == 0)
_ = NestJobCost.Evaluate(job, raw);
// Invalid-but-representable output retains its physical review correspondence.
var consolidate = violations.Count == 0;
var plates = new List<ProposedPlate>();
if (canKeep)
foreach (var group in RepeatedPlateGrouping.Create(raw.Plates, consolidate, token))
{
token.ThrowIfCancellationRequested();
plates.Add(new ProposedPlate(group.Sheet.PlateIndex, group.Sheet.Stock,
NestResultBinder.Bind(group.Sheet, drawingsByPartId))
{ Quantity = group.Quantity });
}
// Binding clones the caller's current Program; a concurrent edit during binding
// must not return parts whose bytes differ from the already validated snapshot.
if (canKeep)
{
var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId).ToList();
changes.AddRange(NestPipelineDrawingFreshness.BoundChanges(job, raw, plates, drawingsByPartId, consolidate, token));
if (changes.Count > 0)
{
violations.AddRange(changes);
plates.Clear();
canKeep = false;
}
}
token.ThrowIfCancellationRequested();
return new NestPipelineResult(
engineName,
job,
raw,
plates.AsReadOnly(),
violations,
canKeep,
solveTime,
validationTime,
drawingsByPartId,
NestUnplacedParts.Classify(job, raw, canKeep, drawingsByPartId, released)
);
}
}