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

166 lines
7.2 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest.Engine.Jobs.CommonLines;
/// <summary>
/// The common-line trial <see cref="NestPipeline"/> runs beside the ordinary solve when the
/// job's <see cref="NestJobOptions.CommonLineUsage"/> is on: every part that allows common lines
/// is offered as kerf-gap pairs (<see cref="CommonLinePair"/>) plus its odd copy, so any engine
/// places them without knowing about common lines. Placed pairs expand into their two copies and
/// one explicit <see cref="NestJobCommonLine"/> group each; the expanded layout is validated again.
/// </summary>
internal sealed class CommonLinePipelinePrepass : IPipelinePrepass
{
/// <summary>Every pair proxy's part id starts with this; ordinary parts never do.</summary>
internal const string ProxyPrefix = "__common-line-pair-";
/// <summary>How many of a part's copies a trial offers as pairs.</summary>
internal enum Share
{
/// <summary>As many pairs as the quantity allows.</summary>
All,
/// <summary>About half the copies paired, the rest single: rows that fit an odd count.</summary>
Half,
}
private sealed record Proxy(string PartId, CommonLinePair Pair);
private readonly IReadOnlyDictionary<string, Proxy> proxies;
private readonly double kerf;
private CommonLinePipelinePrepass(NestJob engineJob, IReadOnlyDictionary<string, Proxy> proxies, double kerf)
{
EngineJob = engineJob;
this.proxies = proxies;
this.kerf = kerf;
}
public NestJob EngineJob { get; }
public string ReconciliationFailure => "Common-line engine output failed reconciliation";
/// <summary>True for a job holding pair proxies: a common-line trial's engine job.</summary>
internal static bool HasProxies(NestJob job) =>
job.Parts.Any(p => p.Id.StartsWith(ProxyPrefix, StringComparison.Ordinal));
/// <summary>
/// The trial job, or null when the job does not use common lines or no part can pair
/// (none allows it, has two copies, may turn half a turn and has a long enough straight edge).
/// </summary>
internal static CommonLinePipelinePrepass? Prepare(NestJob original, Share share, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(original);
token.ThrowIfCancellationRequested();
if (!original.Options.UsesCommonLines || original.Plates.Count == 0)
return null;
var kerf = original.Options.CommonLineKerf;
// A pair a kerf apart only saves anything where the part spacing is wider than the kerf.
if (original.Plates.All(stock => !(kerf < stock.PartSpacing)))
return null;
var ids = original.Parts.Select(p => p.Id).ToHashSet(StringComparer.Ordinal);
var proxies = new Dictionary<string, Proxy>(StringComparer.Ordinal);
var parts = new List<NestJobPart>();
foreach (var part in original.Parts)
{
token.ThrowIfCancellationRequested();
var pairs = Pairs(part, share);
var pair = pairs > 0 && CanTurnHalf(part.Rotation) ? CommonLinePair.Build(part.Geometry, kerf) : null;
if (pair == null)
{
parts.Add(part);
continue;
}
var proxyId = ProxyPrefix + part.Id;
if (ids.Contains(proxyId))
throw new InvalidOperationException("Common-line proxy ID collides with a requirement.");
proxies.Add(proxyId, new Proxy(part.Id, pair));
// The pair first: it is the larger piece, and engines keep input order among equals.
parts.Add(new NestJobPart(proxyId, pair.Geometry, pairs, part.Priority, part.Rotation));
var singles = part.Quantity - 2 * pairs;
if (singles > 0)
parts.Add(new NestJobPart(part.Id, part.Geometry, singles, part.Priority, part.Rotation)
{
AllowCommonLine = part.AllowCommonLine,
});
}
if (proxies.Count == 0)
return null;
var engineJob = new NestJob(parts, original.Plates, original.Options);
try
{
NestJobValidator.Validate(engineJob);
}
catch (ArgumentException)
{
return null; // A trial the engine could not be asked is no trial; the ordinary solve stands.
}
return new CommonLinePipelinePrepass(engineJob, proxies, kerf);
}
private static int Pairs(NestJobPart part, Share share)
{
if (!part.AllowCommonLine || part.Quantity < 2)
return 0;
return share == Share.All ? part.Quantity / 2 : part.Quantity / 4;
}
// The second copy turns half a turn from the first, so the part must allow both angles.
private static bool CanTurnHalf(RotationPolicy rotation) =>
rotation.Kind == RotationPolicyKind.Automatic || rotation.Allow180Equivalent;
public IReadOnlyDictionary<string, int> ReleasedInserts(NestJobResult engineResult) =>
new Dictionary<string, int>(StringComparer.Ordinal);
public bool TryExpand(NestJob original, NestJobResult result, CancellationToken token,
out NestJobResult? expanded, out string failure)
{
expanded = null;
failure = "Invalid common-line engine accounting";
if (!PipelinePrepassAccounting.Matches(EngineJob, result, token))
return false;
var builder = new NestJobResultBuilder(original);
foreach (var sheet in result.Plates)
{
token.ThrowIfCancellationRequested();
if (sheet.CommonLines.Count > 0)
return false; // Engines place pairs as parts; they never return groups of their own here.
var poses = new List<(string PartId, double X, double Y, double Rotation)>();
var groups = new List<NestJobCommonLine>();
foreach (var pose in sheet.Placements)
{
if (!proxies.TryGetValue(pose.PartId, out var proxy))
{
poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation));
continue;
}
var sine = System.Math.Sin(pose.Rotation);
var cosine = System.Math.Cos(pose.Rotation);
var pair = proxy.Pair;
groups.Add(new NestJobCommonLine(kerf, new[] { poses.Count, poses.Count + 1 }));
poses.Add((proxy.PartId, pose.X, pose.Y, pose.Rotation));
poses.Add((proxy.PartId,
pose.X + pair.SecondX * cosine - pair.SecondY * sine,
pose.Y + pair.SecondX * sine + pair.SecondY * cosine,
pose.Rotation + CommonLinePair.SecondRotation));
}
var stock = original.Plates.Single(s => s.Id == sheet.StockId);
try
{
builder.AddSheet(stock, poses, groups);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return false;
}
}
token.ThrowIfCancellationRequested();
expanded = builder.Build(result.StopReason);
return true;
}
}