mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-19 14:21:37 -04:00
Slice 3b task 2 (D8/C10-C12). New OpenNest.Engine.Jobs.ClampRemovalRecovery: after the ordinary clamped solve leaves demand unmet, probes (via the caller's own selected engine, quantity one, one fresh empty sheet) whether each unplaced part fits on any offered stock with every clamp in place; only a part that fits nowhere is 'blocked' (C10). For a blocked part, searches ascending run size then ascending start index over each stock's own clamp zones (already rail-position order) for the smallest contiguous run whose removal lets it fit alone - fewest clamps first, nearest the origin on ties (C11). Builds reduced-clamp stock variants for every resolved (part, stock), re-solves the whole residual demand (so freed space can also take other unmet parts, C12) with original stock carried forward minus phase 1's usage (a finite pool is never offered both full and reduced, so it is never double counted) and merges the result back: phase 2 placements get fresh instance indices continuing from phase 1's, plates are reindexed, and fulfillment/stock usage are recomputed. Returns null (no-op) whenever the job is already complete, no stock has any clamp, no remaining part is genuinely blocked, or even every clamp removed cannot fit a blocked part - so an ordinary job is bit-identical to Slice 3 (C10's last sentence). NestPipeline.RunCore calls it once, right after the main solve, only on the non-cutout-preview path; a recovery replaces 'raw' and expands 'job' (adding exactly the reduced-clamp variants used) before the existing validation/cost/grouping/binding code runs unchanged. NestPipelineCommit.SyncClampZones now derives which rail clamps (if any) are missing from a committed sheet's solved zones and, when the rail recreates exactly that reduced set, reattaches the rail with Plate.SetClampRail(rail, removedClamps) - so a recovered sheet keeps following the rail, minus its removal, through later resizes (C13), using Slice 3b task 1's plumbing. Tests: 7 new (OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs, ClampRemovalCommitTests.cs) covering the minimal-removal + nearest- origin tie-break, freed space taking other residual demand (C12), a no-op on an already-complete job, no-op with no clamps offered, a part blocked only by stock exhaustion never being treated as clamp- blocked, a part unfit even clamp-free staying unplaced, and the full Auto-Nest-to-commit path keeping Plate.RemovedClamps/ClampRail/ ClampRemovalMinutes correct through a resize. Mutation checks (disabling the FitsAnywhere gate; reversing the ascending-start tie- break) turn the relevant assertions red, confirming real coverage. Gates: Release OpenNest.Tests 3884 passed/31 skipped, OpenNest.IO.Tests 105 passed, OpenNest.Engine.Tests 878 passed/2 skipped; desktop cross-build (-p:EnableWindowsTargeting=true) clean; scoped dotnet format --verify-no-changes clean. Desktop surface (removed-clamp display, Auto Nest summary, cut-time minutes, Clamps dialog removal-minutes field) is Slice 3b task 3, not yet done.
281 lines
11 KiB
C#
281 lines
11 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.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)
|
|
{
|
|
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
|
|
)
|
|
{
|
|
EngineName = engineName;
|
|
Job = job;
|
|
Raw = raw;
|
|
Plates = plates;
|
|
Violations = violations;
|
|
CanKeep = canKeep;
|
|
SolveTime = solveTime;
|
|
ValidationTime = validationTime;
|
|
DrawingsByPartId = drawingsByPartId;
|
|
}
|
|
|
|
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>
|
|
/// 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
|
|
{
|
|
private sealed class PreviewProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
|
|
{
|
|
public void Report(NestJobProgress value)
|
|
{
|
|
if (value != null && value.Stage == NestJobStage.EvaluatingCandidate)
|
|
inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 });
|
|
}
|
|
}
|
|
|
|
/// <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.</summary>
|
|
public static NestPipelineResult Run(
|
|
INestingEngine engine,
|
|
string engineName,
|
|
NestPipelineRequest request,
|
|
IProgress<NestJobProgress> progress = null,
|
|
CancellationToken token = default
|
|
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false);
|
|
|
|
/// <summary>Internal test-only integration trial. Normal Run never prepares cutouts;
|
|
/// this entry point must not be called by production front ends before cutting/post gates.</summary>
|
|
internal static NestPipelineResult RunCutoutPreview(
|
|
INestingEngine engine,
|
|
string engineName,
|
|
NestPipelineRequest request,
|
|
IProgress<NestJobProgress> progress = null,
|
|
CancellationToken token = default
|
|
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true);
|
|
|
|
internal static NestPipelineResult RunCutoutPreview(
|
|
NestPipelineRequest request,
|
|
IProgress<NestJobProgress> progress = null,
|
|
CancellationToken token = default
|
|
) => RunCutoutPreview(NestingEngineRegistry.Create(request.EngineName),
|
|
request.EngineName, request, progress, token);
|
|
|
|
private static NestPipelineResult RunCore(
|
|
INestingEngine engine,
|
|
string engineName,
|
|
NestPipelineRequest request,
|
|
IProgress<NestJobProgress> progress,
|
|
CancellationToken token,
|
|
bool cutoutPreview
|
|
)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(engine);
|
|
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);
|
|
var prepass = cutoutPreview ? CutoutPipelinePrepass.Prepare(job, token) : null;
|
|
var engineJob = prepass?.EngineJob ?? job;
|
|
if (prepass != null)
|
|
NestJobValidator.Validate(engineJob);
|
|
|
|
var clock = Stopwatch.StartNew();
|
|
var raw =
|
|
engine.Solve(engineJob, prepass == null || progress == null
|
|
? progress : new PreviewProgress(progress), token)
|
|
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
|
|
var solveTime = clock.Elapsed;
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
// D8/C10: only the main (non-cutout-preview) path offers last-resort clamp removal.
|
|
// No-op whenever nothing is blocked, so an ordinary solve is unaffected (C10).
|
|
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 = "Cutout engine output failed reconciliation";
|
|
if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure))
|
|
{
|
|
raw = expanded;
|
|
violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep));
|
|
if (raw.Status != NestJobStatus.Complete)
|
|
violations.Add("Cutout engine did not complete the transformed job; unplaced frames' reserved inserts remain unplaced in the original job");
|
|
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();
|
|
plates.Add(new ProposedPlate(group.Sheet.PlateIndex, group.Sheet.Stock,
|
|
NestResultBinder.Bind(group.Sheet, drawingsByPartId))
|
|
{ Quantity = group.Quantity });
|
|
}
|
|
|
|
// 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();
|
|
if (prepass != null && canKeep)
|
|
{
|
|
var count = 0;
|
|
for (var i = 0; i < raw.Plates.Count; i++)
|
|
{
|
|
count += raw.Plates[i].Placements.Count;
|
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
|
|
raw.Plates[i].StockId, i, i + 1, count));
|
|
}
|
|
}
|
|
token.ThrowIfCancellationRequested();
|
|
return new NestPipelineResult(
|
|
engineName,
|
|
job,
|
|
raw,
|
|
plates.AsReadOnly(),
|
|
violations,
|
|
canKeep,
|
|
solveTime,
|
|
validationTime,
|
|
drawingsByPartId
|
|
);
|
|
}
|
|
}
|