Files
OpenNest/OpenNest.Engine/Jobs/NestJobResultBuilder.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

108 lines
4.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Jobs;
/// <summary>Assembles committed sheets and quantity accounting without choosing or validating poses.</summary>
public sealed class NestJobResultBuilder
{
private readonly NestJob job;
private readonly IProgress<NestJobProgress> progress;
private readonly Dictionary<string, NestJobPart> parts;
private readonly Dictionary<string, NestPlateStock> stocks;
private readonly Dictionary<string, int> placed;
private readonly Dictionary<string, int> used;
private readonly List<NestJobPlateResult> sheets = new();
private int committedParts;
/// <summary>Creates an empty result builder for the job and optional progress reporter.</summary>
public NestJobResultBuilder(NestJob job, IProgress<NestJobProgress> progress = null)
{
ArgumentNullException.ThrowIfNull(job);
this.job = job;
this.progress = progress;
parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
stocks = job.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal);
placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal);
used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
}
/// <summary>
/// Commits a sheet in call order, assigning zero-based instance indices per part across sheets.
/// Reports PlateCommitted and returns the new zero-based PlateIndex. Stock must belong to the job.
/// Unknown parts, overproduction, and exhausted stock are rejected before accounting changes.
/// </summary>
public int AddSheet(
NestPlateStock stock,
IEnumerable<(string PartId, double X, double Y, double Rotation)> placements,
IEnumerable<NestJobCommonLine> commonLines = null
)
{
ArgumentNullException.ThrowIfNull(placements);
RequireStock(stock);
if (stock.Quantity.HasValue && used[stock.Id] >= stock.Quantity.Value)
throw new InvalidOperationException("Stock is exhausted.");
var counts = new Dictionary<string, int>(placed, StringComparer.Ordinal);
var poses = new List<NestJobPlacement>();
foreach (var pose in placements)
{
if (pose.PartId == null || !parts.TryGetValue(pose.PartId, out var part))
throw new ArgumentException("Unknown part ID.", nameof(placements));
var index = counts[pose.PartId];
if (index >= part.Quantity)
throw new InvalidOperationException("Placement exceeds the requested quantity.");
poses.Add(new NestJobPlacement(pose.PartId, index, pose.X, pose.Y, pose.Rotation));
counts[pose.PartId]++;
}
var plateIndex = sheets.Count;
sheets.Add(new NestJobPlateResult(plateIndex, stock, poses, commonLines));
foreach (var count in counts)
placed[count.Key] = count.Value;
used[stock.Id]++;
committedParts += poses.Count;
progress?.Report(new NestJobProgress(
NestJobStage.PlateCommitted, stock.Id, plateIndex, sheets.Count, committedParts));
return plateIndex;
}
/// <summary>Number of physical sheets committed from this job's stock.</summary>
public int SheetsUsed(NestPlateStock stock)
{
RequireStock(stock);
return used[stock.Id];
}
/// <summary>Number of committed instances of the requirement ID.</summary>
public int Placed(string partId) => placed[partId];
/// <summary>True when every requested instance has been committed.</summary>
public bool IsComplete => job.Parts.All(p => placed[p.Id] == p.Quantity);
/// <summary>
/// Returns a detached accounting snapshot in commit/input order. Complete jobs use Completed;
/// otherwise the result is Incomplete and uses the supplied stop reason.
/// </summary>
public NestJobResult Build(NestJobStopReason stopReason)
{
var complete = IsComplete;
return new NestJobResult(
complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
complete ? NestJobStopReason.Completed : stopReason,
sheets,
job.Parts.Select(p => new PartFulfillment(
p.Id, p.Quantity, placed[p.Id], p.Quantity - placed[p.Id])),
job.Plates.Select(s => new StockUsage(s.Id, used[s.Id], s.Quantity - used[s.Id]))
);
}
private void RequireStock(NestPlateStock stock)
{
ArgumentNullException.ThrowIfNull(stock);
if (!stocks.TryGetValue(stock.Id, out var owned) || !ReferenceEquals(stock, owned))
throw new ArgumentException("Stock must belong to the job.", nameof(stock));
}
}