mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 02:15:07 -05:00
170 lines
7.5 KiB
C#
170 lines
7.5 KiB
C#
using OpenNest.Clamps;
|
|
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));
|
|
}
|
|
}
|