Files
OpenNest/OpenNest.Engine/Jobs/NestPipeline.cs
T
ajandClaude Opus 5.5 b026bd19d2 feat(common-line): place parts on common lines automatically at a chosen level
Slice 3 of common-line cutting. On costly plate (N26-295: 3/16 RA310S at about
$2,500 a sheet) pairing parts one kerf apart fits the part the spacing cannot:
the last plate's 38 parts fit on one 48x120 with Auto Nest only with pairs.

- Drawing Properties gains "Allow common line" (Drawing.AllowCommonLine, off by
  default because customer edge-quality needs vary). The nest's CommonLineUsage
  (Off / When it adds parts / Whenever available) is set in Auto Nest and seeded
  from Tools > Nest Defaults; both persist (nest.json, defaults.json).
- NestPipeline runs the ordinary solve, then, with a level on, trials where
  eligible parts are offered as kerf-gap pair compounds. Placed pairs expand into
  parts plus explicit NestJobCommonLine groups, are validated again, and
  CommonLineRanking keeps a trial only by the level: count first, then sheets;
  "when it adds parts" keeps the plain layout on a tie, "whenever" also takes a
  shorter cut or less material, never at the cost of a part. Engines, FillScore
  and their comparers are unchanged; Off asks the engine exactly what it did.
- NestLayoutCheck exempts members only through a sheet's explicit group list and
  only when the job allows common lines; materializing and committing create
  Plate.CommonLines.
- Server nesting writes common-line jobs as version 3 and replays the recorded
  trial answers, so the desktop binds the same groups.
- Fill Area honors the level through a PlateFillService overload.
- The benchmark gains --common-lines <level> --kerf <k>.

Benchmark (synthetic nests + N26-295, --parallel 1): with no flag and with
--common-lines off every engine matches the base commit; with a level on no
layout becomes invalid and three runs save a sheet at equal nested area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:38:00 -05:00

506 lines
23 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.CommonLines;
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)
{
/// <summary>Common-line groups among <see cref="Parts"/>; committing the plate adds them to
/// <see cref="Plate.CommonLines"/>. Empty when the layout has none.</summary>
public IReadOnlyList<CommonLineGroup> CommonLines { get; init; } = Array.Empty<CommonLineGroup>();
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
{
/// <summary>A bundled cutout trial's progress: its commits count proxies, not physical
/// parts, so only candidates and plans pass, with no committed counts.</summary>
internal 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 = PlanSheets(value.Plan, parts, callerDrawings, drawings, token) };
}
}
inner.Report(value);
}
}
/// <summary>
/// A plan as displayable sheets: identical sheets once with their count, parts on detached
/// drawings built from <paramref name="parts"/>' geometry and kept in <paramref name="drawings"/>
/// (one per part id), named and colored like the caller's. Every placement must name one of
/// <paramref name="parts"/>.
/// </summary>
internal static IReadOnlyList<ProposedPlate> PlanSheets(NestJobResult plan,
IReadOnlyDictionary<string, NestJobPart> parts, IReadOnlyDictionary<string, Drawing> callerDrawings,
Dictionary<string, Drawing> drawings, CancellationToken token)
{
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,
CommonLines = NestResultBinder.BindCommonLines(group.Sheet, placed),
});
}
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);
return Run(engine, engineName, job, drawingsByPartId, progress, token, cutouts);
}
/// <summary>
/// The pipeline for an already built job whose part ids map to <paramref name="drawingsByPartId"/>
/// (the benchmark builds its own jobs); identical to <see cref="Run(INestingEngine, string, NestPipelineRequest, IProgress{NestJobProgress}, CancellationToken)"/> otherwise.
/// </summary>
internal static NestPipelineResult Run(INestingEngine engine, string engineName, NestJob job,
IReadOnlyDictionary<string, Drawing> drawingsByPartId, IProgress<NestJobProgress> progress,
CancellationToken token, bool cutouts = true)
{
ArgumentNullException.ThrowIfNull(engine);
ArgumentNullException.ThrowIfNull(job);
NestJobValidator.Validate(job);
var ordinary = RunOrdinary(engine, engineName, job, drawingsByPartId, progress, token, cutouts);
return job.Options.UsesCommonLines
? WithCommonLines(engine, engineName, job, drawingsByPartId, ordinary, progress, token)
: ordinary;
}
/// <summary>
/// The common-line trials (see <see cref="CommonLinePipelinePrepass"/>), each solved after
/// the ordinary solve and kept only when <see cref="CommonLineRanking"/> prefers it at the
/// job's usage level; otherwise the ordinary result stands as it was.
/// </summary>
private static NestPipelineResult WithCommonLines(INestingEngine engine, string engineName, NestJob job,
IReadOnlyDictionary<string, Drawing> drawingsByPartId, NestPipelineResult ordinary,
IProgress<NestJobProgress> progress, CancellationToken token)
{
var best = ordinary;
var asked = new HashSet<string>(StringComparer.Ordinal);
foreach (var share in new[] { CommonLinePipelinePrepass.Share.All, CommonLinePipelinePrepass.Share.Half })
{
token.ThrowIfCancellationRequested();
var prepass = CommonLinePipelinePrepass.Prepare(job, share, token);
// Two shares that pair the same counts would ask the engine the same question.
if (prepass == null || !asked.Add(string.Join("|", Remote.NestSolveTranscript.Fingerprint(prepass.EngineJob))))
continue;
var trial = Evaluate(engine, engineName, job, prepass, drawingsByPartId, progress, token);
if (CommonLineRanking.Prefers(job.Options.CommonLineUsage, trial, best))
best = trial;
}
return ReferenceEquals(best, ordinary) ? ordinary : Committed(best, progress, token);
}
private static NestPipelineResult RunOrdinary(INestingEngine engine, string engineName, NestJob job,
IReadOnlyDictionary<string, Drawing> drawingsByPartId, IProgress<NestJobProgress> progress,
CancellationToken token, bool cutouts)
{
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);
return CreateJob(request, token);
}
/// <summary>The job the pipeline builds for <paramref name="request"/> (before any cutout
/// bundling), and the caller drawing behind each part id.</summary>
internal static (NestJob Job, Dictionary<string, Drawing> Drawings) CreateJob(
NestPipelineRequest request,
CancellationToken token
)
{
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,
IPipelinePrepass 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 = prepass.ReconciliationFailure;
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();
var bound = NestResultBinder.Bind(group.Sheet, drawingsByPartId);
plates.Add(new ProposedPlate(group.Sheet.PlateIndex, group.Sheet.Stock, bound)
{
Quantity = group.Quantity,
CommonLines = NestResultBinder.BindCommonLines(group.Sheet, bound),
});
}
// 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)
);
}
}