Files
OpenNest/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs
T
aj 83c77269a1 feat(engine): remove clamps only for parts that cannot fit with them
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.
2026-10-13 18:15:49 -04:00

128 lines
5.7 KiB
C#

using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Engine.Tests.NestingEngines;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// D8/D9 last-resort clamp removal in the whole-job pipeline: a part blocked by clamps on
/// every offered stock gets the fewest clamps removed that let it fit (C10/C11), and the
/// residual solve is merged back into one result; everything else is untouched (C10's
/// bit-identical guarantee for an unblocked job).
/// </summary>
public class ClampRemovalRecoveryTests
{
// Two clamps 15" apart on a 23"-long, 3"-wide sheet (no edge/part spacing, so the math
// below is exact): clamp-1 spans X [5.25, 6.75], clamp-2 spans X [20.25, 21.75]. A 3"-tall
// part exactly fills the sheet's Y extent, so it always sits at Y = 0 - through the clamp
// band - for whatever X it lands at.
private static ClampZone Clamp1() => new("clamp-1", new Box(5.25, 0, 1.5, 0.625));
private static ClampZone Clamp2() => new("clamp-2", new Box(20.25, 0, 1.5, 0.625));
private static NestPlateStock ClampedSheet(params ClampZone[] zones) =>
JobBuilder.Stock("sheet", width: 3, length: 23, clampZones: zones);
private static readonly RectanglesNestingEngine Engine = new();
[Fact]
public void BlockedPartRemovesOnlyTheNearestOriginCoveredClamp()
{
// A 14"-wide part: with both clamps up, no X position in [0, 9] clears both (the gap
// between them is 13.5" < 14"), so it is genuinely blocked (C10), not merely unlucky.
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Incomplete, raw.Status); // sanity: today's solve fails
var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default);
Assert.NotNull(outcome);
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
var sheet = Assert.Single(outcome.Result.Plates);
Assert.Single(sheet.Placements);
// Removing either clamp alone would free enough room; "clamp-1" is nearest the origin.
Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1");
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
var fulfillment = Assert.Single(outcome.Result.Fulfillment);
Assert.Equal(0, fulfillment.Unplaced);
// The expanded job the result validates against offers the original stock plus
// exactly the reduced-clamp variant actually used.
Assert.Equal(2, outcome.Job.Plates.Count);
}
[Fact]
public void RemovalFreesSpaceForOtherResidualDemandToo()
{
// C12: a sheet nested with a clamp removed may also take other residual parts.
var blocked = JobBuilder.Rectangle("wide", w: 14, h: 3, count: 1);
var filler = JobBuilder.Rectangle("filler", w: 2, h: 1, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { blocked, filler }, new[] { stock });
var raw = Engine.Solve(job);
var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default);
Assert.NotNull(outcome);
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
Assert.All(outcome.Result.Fulfillment, f => Assert.Equal(0, f.Unplaced));
}
[Fact]
public void AlreadyCompleteResultIsUntouched()
{
// C10: no blocked part exists, so recovery is a strict no-op (bit-identical to Slice 3).
var part = JobBuilder.Rectangle("p", w: 1, h: 1, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Complete, raw.Status);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
[Fact]
public void NoClampsOfferedNeverRecovers()
{
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
var stock = JobBuilder.Stock("sheet", width: 3, length: 10); // too short, no clamps
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.NotEqual(NestJobStatus.Complete, raw.Status);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
[Fact]
public void PartBlockedOnlyByStockExhaustionIsNeverTreatedAsClampBlocked()
{
// The part fits a fresh clamped sheet on its own; only a single-sheet cap stops the
// second copy. That is a capacity limit, not a clamp block, so nothing is removed.
var part = JobBuilder.Rectangle("p", w: 12, h: 3, count: 2);
var stock = JobBuilder.Stock("sheet", width: 3, length: 23, quantity: 1,
clampZones: new[] { Clamp1(), Clamp2() });
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Incomplete, raw.Status);
Assert.Equal(NestJobStopReason.StockExhausted, raw.StopReason);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
[Fact]
public void UnfitEvenWithoutAnyClampStaysUnplaced()
{
// Wider than the whole sheet: removing every clamp still cannot make it fit.
var part = JobBuilder.Rectangle("p", w: 30, h: 3, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Incomplete, raw.Status);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
}