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

95 lines
4.3 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest.Engine.Jobs;
/// <summary>
/// A pipeline pre-pass that offers the engine a transformed job (compound proxies standing in
/// for several parts) and expands the engine's answer back to the original job's parts, which
/// are then validated again. Cutout bundles and common-line pairs are the two kinds.
/// </summary>
internal interface IPipelinePrepass
{
/// <summary>The job the engine solves.</summary>
NestJob EngineJob { get; }
/// <summary>The violation reported when the engine's answer cannot be expanded.</summary>
string ReconciliationFailure { get; }
/// <summary>Expands an answer to <see cref="EngineJob"/> into one for <paramref name="original"/>.</summary>
bool TryExpand(NestJob original, NestJobResult result, CancellationToken token,
out NestJobResult? expanded, out string failure);
/// <summary>Copies per original requirement that an unplaced proxy left unplaced as a frame's insert.</summary>
IReadOnlyDictionary<string, int> ReleasedInserts(NestJobResult engineResult);
}
/// <summary>The accounting every pre-pass checks before trusting an engine's answer.</summary>
internal static class PipelinePrepassAccounting
{
/// <summary>
/// True when <paramref name="result"/> is self-consistent for <paramref name="engineJob"/>:
/// consecutive sheet indices, known stock and parts, unique instance indices within quantity,
/// and fulfillment, stock usage and status rows that match the placements.
/// </summary>
internal static bool Matches(NestJob engineJob, NestJobResult result, CancellationToken token)
{
var parts = engineJob.Parts.ToDictionary(p => p.Id);
var counts = parts.ToDictionary(p => p.Key, _ => 0);
var indices = parts.ToDictionary(p => p.Key, _ => new HashSet<int>());
var stockById = engineJob.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal);
var used = stockById.ToDictionary(s => s.Key, _ => 0, StringComparer.Ordinal);
var expectedIndex = 0;
foreach (var sheet in result.Plates)
{
token.ThrowIfCancellationRequested();
if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0
|| !used.ContainsKey(sheet.StockId))
return false;
used[sheet.StockId]++;
foreach (var pose in sheet.Placements)
{
if (pose.PartId == null || !parts.ContainsKey(pose.PartId)
|| pose.InstanceIndex < 0 || !indices[pose.PartId].Add(pose.InstanceIndex)
|| ++counts[pose.PartId] > parts[pose.PartId].Quantity)
return false;
}
}
foreach (var row in indices)
{
token.ThrowIfCancellationRequested();
if (row.Value.Any(index => index >= counts[row.Key]))
return false;
}
if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count)
return false;
var seenParts = new HashSet<string>(StringComparer.Ordinal);
foreach (var row in result.Fulfillment)
{
token.ThrowIfCancellationRequested();
if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part)
|| !seenParts.Add(row.PartId)
|| row.Requested != part.Quantity || row.Placed != counts[row.PartId]
|| row.Unplaced != part.Quantity - counts[row.PartId])
return false;
}
var seenStock = new HashSet<string>(StringComparer.Ordinal);
foreach (var row in result.StockUsage)
{
token.ThrowIfCancellationRequested();
if (row.StockId == null || !stockById.TryGetValue(row.StockId, out var stock)
|| !seenStock.Add(row.StockId)
|| row.Used != used[row.StockId]
|| row.Remaining != stock.Quantity - row.Used)
return false;
}
var complete = parts.Values.All(p => counts[p.Id] == p.Quantity);
return result.Status == (complete ? NestJobStatus.Complete : NestJobStatus.Incomplete)
&& (!complete || result.StopReason == NestJobStopReason.Completed)
&& (complete || result.StopReason != NestJobStopReason.Completed);
}
}