Files
OpenNest/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs
T
aj 561f5be06c feat(engine): choose which edge strips count as offcut
Equal-cost plans break ties by the largest edge offcut, from any of the
four edges. A caller that wants the leftover off one way, such as a
shop that keeps only end drops, or a benchmark scoring the used sheet
height, had no way to say so. On the converted Nestbolt jobs the
extended Irregular search then found plans whose larger offcut is a
strip down the side, which Nestbolt scores as worse: b-blaz1-strip
3.601 -> 3.867, b-shirts-strip 2.674 -> 2.995.

NestJobOptions.OffcutCut (a CutOffAxis, default null) counts only the
strips one cut along that axis frees, for salvage credit and the
compactness tie-break alike: Horizontal the full-width bottom and top
strips, Vertical the full-length sides. PlateRemnant.LargestOffcut takes
the axis; NestJobCost, the time limit and clamp-removal recovery pass
it on. Unset, nothing changes.

Benchmark manifests accept "offcutCut": "horizontal" or "vertical".
With horizontal, one CPU, 20 s limit, Default:
  b-blaz1-strip  3.934 -> 3.333
  b-shirts-strip 2.995 -> 2.610
  m-box-deep     1.918 -> 1.276 (Nestbolt 1.251)

Manifests accept only the words horizontal or vertical (any case); a
list such as "horizontal,vertical" is rejected. PlateRemnant rejects an
undefined axis. Fixed strategies keep their own no-salvage tie-break
and ignore the option; desktop, Console and MCP do not offer it yet.

Mutation reds: 14/14 (offcut-mutations.py).
2026-10-31 13:29:50 -04:00

169 lines
7.4 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));
// The machine rail those zones resolve from: only rail-backed clamps can be removed.
private static readonly ClampRail Rail = new(1.5, 0.625, new[] { 6.0, 21.0 });
private static NestPlateStock ClampedSheet(params ClampZone[] zones) =>
RailSheet(null, zones);
private static NestPlateStock RailSheet(int? quantity, params ClampZone[] zones) =>
new("sheet", new Size(3, 23), quantity, 0, default, 1, null, zones) { ClampRail = Rail };
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 ResidualSolvesKeepTheJobsSalvageAndOffcutSettings()
{
// The residual job is rebuilt for the reduced-clamp stock; it must score offcuts the
// way the caller's job does.
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
var job = new NestJob(new[] { part }, new[] { ClampedSheet(Clamp1(), Clamp2()) },
new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1,
offcutCut: CutOffAxis.Vertical));
var seen = new List<NestJobOptions>();
var engine = new Recording(seen);
var outcome = ClampRemovalRecovery.TryRecover(job, Engine.Solve(job), engine, default);
Assert.NotNull(outcome);
// Probe solves use their own unsalvaged options; every solve with the job's salvage
// settings is a residual solve and must carry its offcut cut too.
var residual = seen.Where(o => o.SalvageRate == 0.5).ToList();
Assert.NotEmpty(residual);
Assert.All(residual, o =>
{
Assert.Equal(1, o.MinimumSalvageDimension);
Assert.Equal(CutOffAxis.Vertical, o.OffcutCut);
});
}
private sealed class Recording(List<NestJobOptions> seen) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
seen.Add(job.Options);
return Engine.Solve(job, progress, token);
}
}
[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 = RailSheet(1, 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));
}
}