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

156 lines
5.9 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using OpenNest.Engine.Jobs.CommonLines;
using OpenNest.Engine.Jobs.Cutouts;
namespace OpenNest.Engine.Jobs.Remote;
/// <summary>
/// The nest server worker's latest progress report for a running job: the stage of the solve
/// in progress, without its plan (plans travel as <see cref="RemoteNestPlan"/>). Committed
/// counts follow <see cref="NestJobProgress"/>: authoritative only after a commit.
/// </summary>
public sealed class RemoteNestProgress
{
public const int CurrentVersion = 1;
public int Version { get; set; } = CurrentVersion;
public NestJobStage Stage { get; set; }
public string StockId { get; set; } = "";
public int PlateIndex { get; set; }
public int CommittedPlates { get; set; }
public int CommittedParts { get; set; }
public static RemoteNestProgress From(NestJobProgress progress)
{
ArgumentNullException.ThrowIfNull(progress);
return new RemoteNestProgress
{
Stage = progress.Stage,
StockId = progress.StockId ?? "",
PlateIndex = progress.PlateIndex,
CommittedPlates = progress.CommittedPlates,
CommittedParts = progress.CommittedParts,
};
}
public NestJobProgress ToProgress() => new(Stage, StockId, PlateIndex, CommittedPlates, CommittedParts);
public string ToJson() => JsonSerializer.Serialize(this, NestSolveTranscript.Options);
/// <summary>The report, or null when <paramref name="json"/> is empty or not one this build reads.</summary>
public static RemoteNestProgress FromJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
var progress = JsonSerializer.Deserialize<RemoteNestProgress>(json, NestSolveTranscript.Options);
return progress?.Version == CurrentVersion ? progress : null;
}
catch (JsonException)
{
return null;
}
}
}
/// <summary>
/// The engine's best whole-job plan so far on the worker (<see cref="NestJobStage.PlanImproved"/>),
/// as raw placements. The desktop draws it with <see cref="RemoteNestPlanPreview"/>; like a local
/// plan it is unvalidated and never kept.
/// </summary>
public sealed class RemoteNestPlan
{
public const int CurrentVersion = 1;
public int Version { get; set; } = CurrentVersion;
public NestSolveTranscript.ResultJson Plan { get; set; }
public static RemoteNestPlan From(NestJobResult plan)
{
ArgumentNullException.ThrowIfNull(plan);
return new RemoteNestPlan { Plan = NestSolveTranscript.ResultJson.From(plan) };
}
public string ToJson() => JsonSerializer.Serialize(this, NestSolveTranscript.Options);
/// <summary>The plan, or null when <paramref name="json"/> is not one this build reads.</summary>
public static RemoteNestPlan FromJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
var plan = JsonSerializer.Deserialize<RemoteNestPlan>(json, NestSolveTranscript.Options);
return plan?.Version == CurrentVersion && plan.Plan?.Plates != null ? plan : null;
}
catch (JsonException)
{
return null;
}
}
}
/// <summary>
/// Draws the worker's best plan so far on the desktop, the way a local run's
/// <see cref="NestJobProgress.PlanSheets"/> are drawn: the request's job is rebuilt here (the
/// same part ids the worker solved), and parts go on detached drawings built from its geometry,
/// so nothing touches the caller's drawings or quantities. A bundled cutout trial's frame proxies
/// are bundled here too, once, the first time a plan names one. Not thread-safe: use one
/// preview from one thread at a time.
/// </summary>
public sealed class RemoteNestPlanPreview
{
private readonly NestJob job;
private readonly IReadOnlyDictionary<string, Drawing> callerDrawings;
private readonly Dictionary<string, NestJobPart> parts;
private readonly Dictionary<string, Drawing> drawings = new(StringComparer.Ordinal);
private bool bundled;
public RemoteNestPlanPreview(NestPipelineRequest request, CancellationToken token = default)
{
(job, var byPart) = NestPipeline.CreateJob(request, token);
callerDrawings = byPart;
parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
}
/// <summary>The plan as sheets, or null when <paramref name="planJson"/> is unreadable or
/// names parts this request does not have.</summary>
public IReadOnlyList<ProposedPlate> Sheets(string planJson, CancellationToken token = default)
{
if (RemoteNestPlan.FromJson(planJson)?.Plan is not { } plan)
return null;
if (!Known(plan) && !bundled)
{
bundled = true;
if (CutoutPipelinePrepass.Prepare(job, token)?.EngineJob is { } engineJob)
{
foreach (var part in engineJob.Parts)
parts.TryAdd(part.Id, part);
}
// A common-line trial's pair proxies, from either share (same geometry per part).
foreach (var share in new[] { CommonLinePipelinePrepass.Share.All, CommonLinePipelinePrepass.Share.Half })
if (CommonLinePipelinePrepass.Prepare(job, share, token)?.EngineJob is { } pairJob)
foreach (var part in pairJob.Parts)
parts.TryAdd(part.Id, part);
}
if (!Known(plan))
return null;
try
{
return NestPipeline.PlanSheets(plan.ToResult(job), parts, callerDrawings, drawings, token);
}
catch (FormatException)
{
return null;
}
}
private bool Known(NestSolveTranscript.ResultJson plan) =>
plan.Plates.All(s => s?.Placements != null && s.Placements.All(p => p.PartId != null && parts.ContainsKey(p.PartId)));
}