mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-02 13:41:37 -05:00
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).
385 lines
16 KiB
C#
385 lines
16 KiB
C#
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Engine.NestingEngines;
|
|
using OpenNest.Engine.NestingEngines.Default;
|
|
using OpenNest.Engine.NestingEngines.Irregular;
|
|
using OpenNest.Engine.NestingEngines.Rectangles;
|
|
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
|
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
|
|
|
namespace OpenNest.Engine.Tests.NestingEngines;
|
|
|
|
public class DefaultNestingEngineTests
|
|
{
|
|
// Two 4x4 squares on 10x10 sheets with no spacing: either both on one sheet (cost 100),
|
|
// or one per sheet (cost 200), or one placed and one unplaced (100 + a 100 penalty).
|
|
private static NestJob SquaresJob() =>
|
|
Job([Part("p", Rectangle(4, 4), 2, RotationPolicy.Fixed(0))], [Stock("s", 10, 10)]);
|
|
|
|
private static NestJobResult Layout(NestJob job, params (double X, double Y)[][] sheets)
|
|
{
|
|
var builder = new NestJobResultBuilder(job);
|
|
foreach (var sheet in sheets)
|
|
builder.AddSheet(job.Plates[0], sheet.Select(p => ("p", p.X, p.Y, 0.0)));
|
|
return builder.Build(builder.IsComplete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound);
|
|
}
|
|
|
|
private static (double X, double Y)[] Sheet(params (double X, double Y)[] poses) => poses;
|
|
|
|
private static NestJobResult OneSheet(NestJob job) => Layout(job, Sheet((0, 0), (5, 0)));
|
|
// Same 10 x 6 free strip as OneSheet, below the parts instead of above them.
|
|
private static NestJobResult OneSheetAtTop(NestJob job) => Layout(job, Sheet((0, 6), (5, 6)));
|
|
// Only a 10 x 5 strip left below the parts.
|
|
private static NestJobResult OneSheetHigher(NestJob job) => Layout(job, Sheet((0, 5), (5, 5)));
|
|
private static NestJobResult TwoSheets(NestJob job) => Layout(job, Sheet((0, 0)), Sheet((0, 0)));
|
|
private static NestJobResult Raised(NestJob job, double y) => Layout(job, Sheet((0, y), (5, y)));
|
|
private static NestJobResult Overlapping(NestJob job) => Layout(job, Sheet((0, 0), (1, 0)));
|
|
private static NestJobResult OnePlaced(NestJob job) => Layout(job, Sheet((0, 0)));
|
|
|
|
[Fact]
|
|
public void BuiltInDefaultRunsIrregularLast()
|
|
{
|
|
// Rectangles is the cheap, always-valid fallback: under a limit it must have reported
|
|
// its plan before the slower Irregular search starts, and Irregular, which may search
|
|
// until cancelled, must not keep Circles from running. Candidates stay in tie-break
|
|
// order Irregular, Rectangles, Circles.
|
|
Assert.Equal(new[] { 1, 2, 0 }, new DefaultNestingEngine().RunOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultsRunOrderNeverChangesTheChoice()
|
|
{
|
|
// Equal layouts keep tie-break (candidate) order whichever candidate runs first.
|
|
var job = SquaresJob();
|
|
var first = OneSheet(job);
|
|
var second = OneSheet(job);
|
|
Func<INestingEngine>[] candidates =
|
|
[
|
|
() => new Stub((_, _, _) => first),
|
|
() => new Stub((_, _, _) => second),
|
|
];
|
|
|
|
Assert.Same(first, new DefaultNestingEngine(candidates, new[] { 0, 1 }).Solve(job));
|
|
Assert.Same(first, new DefaultNestingEngine(candidates, new[] { 1, 0 }).Solve(job));
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultRejectsARunOrderThatIsNotAPermutation()
|
|
{
|
|
Func<INestingEngine>[] candidates =
|
|
[
|
|
() => new Stub((_, _, _) => null!),
|
|
() => new Stub((_, _, _) => null!),
|
|
];
|
|
|
|
Assert.Throws<ArgumentException>(() => new DefaultNestingEngine(candidates, new[] { 0, 0 }));
|
|
Assert.Throws<ArgumentException>(() => new DefaultNestingEngine(candidates, new[] { 0 }));
|
|
Assert.Throws<ArgumentException>(() => new DefaultNestingEngine(candidates, new[] { 0, 2 }));
|
|
}
|
|
|
|
[Fact]
|
|
public void WhenEveryCandidateThrowsTheFirstInTieBreakOrderIsRethrown()
|
|
{
|
|
Func<INestingEngine>[] candidates =
|
|
[
|
|
() => new Stub((_, _, _) => throw new InvalidOperationException("first")),
|
|
() => new Stub((_, _, _) => throw new InvalidOperationException("second")),
|
|
];
|
|
|
|
var error = Assert.Throws<InvalidOperationException>(() =>
|
|
new DefaultNestingEngine(candidates, new[] { 1, 0 }).Solve(SquaresJob())
|
|
);
|
|
|
|
Assert.Equal("first", error.Message);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null, 0.0)]
|
|
[InlineData(CutOffAxis.Horizontal, 1.0)]
|
|
public void TheOffcutCutDecidesWhichEqualCostLayoutWins(CutOffAxis? cut, double expectedY)
|
|
{
|
|
// Stacked squares leave a 6 x 10 strip at the side and only 10 x 1 on top; a raised row
|
|
// leaves 10 x 5 on top. Counting every strip picks the stack; counting only full-width
|
|
// strips (a horizontal cut) picks the row.
|
|
var plain = SquaresJob();
|
|
var job = new NestJob(plain.Parts, plain.Plates, new NestJobOptions(offcutCut: cut));
|
|
|
|
var result = Choose(job,
|
|
Returns(j => Layout(j, Sheet((0, 0), (0, 5)))), Returns(j => Raised(j, 1)));
|
|
|
|
Assert.Equal(expectedY, result.Plates[0].Placements[0].Y);
|
|
}
|
|
|
|
[Fact]
|
|
public void NearTiesAreChosenInTieBreakOrderWhateverTheRunOrder()
|
|
{
|
|
// Offcuts within the comparison tolerance tie, so these three are not transitively
|
|
// ordered: neighbours tie, but the lowest layout beats the highest outright. Choosing
|
|
// in run order would then depend on the order; choosing in tie-break order must not.
|
|
var job = SquaresJob();
|
|
var heights = new[] { 8e-9, 4e-9, 0 };
|
|
var scores = heights.Select(y => CandidateScore.Of(job, Raised(job, y), 0)).ToArray();
|
|
Assert.False(scores[0].IsBetterThan(scores[1]) || scores[1].IsBetterThan(scores[0]));
|
|
Assert.False(scores[1].IsBetterThan(scores[2]) || scores[2].IsBetterThan(scores[1]));
|
|
Assert.True(scores[2].IsBetterThan(scores[0]));
|
|
|
|
var candidates = heights.Select(y => Returns(j => Raised(j, y))).ToArray();
|
|
var orders = new[]
|
|
{
|
|
new[] { 0, 1, 2 }, new[] { 0, 2, 1 }, new[] { 1, 0, 2 },
|
|
new[] { 1, 2, 0 }, new[] { 2, 0, 1 }, new[] { 2, 1, 0 },
|
|
};
|
|
var picks = orders
|
|
.Select(order => new DefaultNestingEngine(candidates, order).Solve(job).Plates[0].Placements[0].Y)
|
|
.ToList();
|
|
Assert.All(picks, y => Assert.Equal(picks[0], y));
|
|
}
|
|
|
|
private sealed class Stub(Func<NestJob, IProgress<NestJobProgress>?, CancellationToken, NestJobResult> solve) : INestingEngine
|
|
{
|
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null, CancellationToken token = default) =>
|
|
solve(job, progress, token);
|
|
}
|
|
|
|
private static Func<INestingEngine> Returns(Func<NestJob, NestJobResult> layout) =>
|
|
() => new Stub((job, _, _) => layout(job));
|
|
|
|
private static NestJobResult Choose(NestJob job, params Func<INestingEngine>[] candidates) =>
|
|
new DefaultNestingEngine(candidates).Solve(job);
|
|
|
|
[Fact]
|
|
public void CheaperValidLayoutWinsInEitherOrder()
|
|
{
|
|
var job = SquaresJob();
|
|
var cheap = OneSheet(job);
|
|
var dear = TwoSheets(job);
|
|
|
|
Assert.Same(cheap, Choose(job, Returns(_ => dear), Returns(_ => cheap)));
|
|
Assert.Same(cheap, Choose(job, Returns(_ => cheap), Returns(_ => dear)));
|
|
}
|
|
|
|
[Fact]
|
|
public void InvalidLayoutLosesToAValidOneEvenWhenSmaller()
|
|
{
|
|
var job = SquaresJob();
|
|
var invalid = Overlapping(job);
|
|
var valid = TwoSheets(job);
|
|
Assert.NotEmpty(NestLayoutCheck.Violations(job, invalid));
|
|
|
|
Assert.Same(valid, Choose(job, Returns(_ => invalid), Returns(_ => valid)));
|
|
Assert.Same(valid, Choose(job, Returns(_ => valid), Returns(_ => invalid)));
|
|
}
|
|
|
|
[Fact]
|
|
public void PlacingEveryPartWinsAtEqualCost()
|
|
{
|
|
var job = SquaresJob();
|
|
var complete = TwoSheets(job);
|
|
var partial = OnePlaced(job);
|
|
Assert.Equal(NestJobCost.Evaluate(job, complete), NestJobCost.Evaluate(job, partial), 9);
|
|
|
|
Assert.Same(complete, Choose(job, Returns(_ => partial), Returns(_ => complete)));
|
|
Assert.Same(complete, Choose(job, Returns(_ => complete), Returns(_ => partial)));
|
|
}
|
|
|
|
[Fact]
|
|
public void EqualLayoutsKeepCandidateOrder()
|
|
{
|
|
var job = SquaresJob();
|
|
var low = OneSheet(job);
|
|
var high = OneSheetAtTop(job);
|
|
|
|
Assert.Same(low, Choose(job, Returns(_ => low), Returns(_ => high)));
|
|
Assert.Same(high, Choose(job, Returns(_ => high), Returns(_ => low)));
|
|
}
|
|
|
|
[Fact]
|
|
public void LargerOffcutWinsAtEqualCostInEitherOrder()
|
|
{
|
|
var job = SquaresJob();
|
|
var compact = OneSheet(job);
|
|
var loose = OneSheetHigher(job);
|
|
Assert.Equal(NestJobCost.Evaluate(job, compact), NestJobCost.Evaluate(job, loose));
|
|
|
|
Assert.Same(compact, Choose(job, Returns(_ => loose), Returns(_ => compact)));
|
|
Assert.Same(compact, Choose(job, Returns(_ => compact), Returns(_ => loose)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ACrashingCandidateIsSkipped()
|
|
{
|
|
var job = SquaresJob();
|
|
var valid = TwoSheets(job);
|
|
|
|
var result = Choose(job, () => new Stub((_, _, _) => throw new InvalidOperationException("boom")), Returns(_ => valid));
|
|
|
|
Assert.Same(valid, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ACandidateWithoutAResultIsSkipped()
|
|
{
|
|
var job = SquaresJob();
|
|
var valid = TwoSheets(job);
|
|
|
|
Assert.Same(valid, Choose(job, () => new Stub((_, _, _) => null!), Returns(_ => valid)));
|
|
}
|
|
|
|
[Fact]
|
|
public void WhenEveryCandidateCrashesTheFirstFailureIsRethrown()
|
|
{
|
|
var job = SquaresJob();
|
|
|
|
var error = Assert.Throws<InvalidOperationException>(() => Choose(job,
|
|
() => new Stub((_, _, _) => throw new InvalidOperationException("first")),
|
|
() => new Stub((_, _, _) => throw new InvalidOperationException("second"))));
|
|
|
|
Assert.Equal("first", error.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void CancellationStopsTheSearch()
|
|
{
|
|
var job = SquaresJob();
|
|
using var cancellation = new CancellationTokenSource();
|
|
var laterRan = false;
|
|
|
|
Assert.ThrowsAny<OperationCanceledException>(() => new DefaultNestingEngine(new Func<INestingEngine>[]
|
|
{
|
|
() => new Stub((_, _, token) => { cancellation.Cancel(); token.ThrowIfCancellationRequested(); return OneSheet(job); }),
|
|
() => { laterRan = true; return new Stub((j, _, _) => OneSheet(j)); },
|
|
}).Solve(job, token: cancellation.Token));
|
|
Assert.False(laterRan);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnlyTheChosenLayoutCommitsAreReported()
|
|
{
|
|
var job = SquaresJob();
|
|
Func<INestingEngine> Reporting(Func<NestJob, NestJobResult> layout) => () => new Stub((j, progress, _) =>
|
|
{
|
|
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, "s", 0, 0, 0));
|
|
var result = layout(j);
|
|
foreach (var plate in result.Plates)
|
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, "s", plate.PlateIndex, 99, 99));
|
|
return result;
|
|
});
|
|
var reports = new List<NestJobProgress>();
|
|
|
|
var result = new DefaultNestingEngine(new[] { Reporting(TwoSheets), Reporting(OneSheet) })
|
|
.Solve(job, new Capture(reports.Add));
|
|
|
|
Assert.Single(result.Plates);
|
|
Assert.Equal(2, reports.Count(r => r.Stage == NestJobStage.EvaluatingCandidate));
|
|
var commit = Assert.Single(reports, r => r.Stage == NestJobStage.PlateCommitted);
|
|
Assert.Equal((0, 1, 2), (commit.PlateIndex, commit.CommittedPlates, commit.CommittedParts));
|
|
Assert.Equal(NestJobStage.PlateCommitted, reports[^1].Stage);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInCandidatesReturnTheCheaperOfIrregularAndRectangles()
|
|
{
|
|
var job = Job([Part("disc", Disc(2), 6), Part("ell", LShape(9, 7, 3), 4), Part("plate", Rectangle(12, 5), 3)],
|
|
[Stock("a", 24, 30, 0.25), Stock("b", 30, 40, 0.25)]);
|
|
|
|
var result = new DefaultNestingEngine().Solve(job);
|
|
|
|
LayoutAssert.Valid(job, result);
|
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
|
var best = new INestingEngine[] { new IrregularNestingEngine(), new RectanglesNestingEngine() }
|
|
.Select(engine => engine.Solve(job))
|
|
.Where(r => NestLayoutCheck.Violations(job, r).Count == 0 && r.Status == NestJobStatus.Complete)
|
|
.Min(r => NestJobCost.Evaluate(job, r));
|
|
Assert.Equal(best, NestJobCost.Evaluate(job, result), 9);
|
|
}
|
|
|
|
/// <summary>A candidate that reports each given plan, then returns its layout.</summary>
|
|
private static Func<INestingEngine> Plans(Func<NestJob, NestJobResult> layout, params Func<NestJob, NestJobResult>[] plans) =>
|
|
() => new Stub((j, progress, _) =>
|
|
{
|
|
foreach (var plan in plans)
|
|
progress?.Report(new NestJobProgress(NestJobStage.PlanImproved, string.Empty, -1, 0, 0, Plan: plan(j)));
|
|
return layout(j);
|
|
});
|
|
|
|
private static List<NestJobResult> ReportedPlans(NestJob job, out NestJobResult result, params Func<INestingEngine>[] candidates)
|
|
{
|
|
var reports = new List<NestJobProgress>();
|
|
result = new DefaultNestingEngine(candidates).Solve(job, new Capture(reports.Add));
|
|
return reports.Where(r => r.Stage == NestJobStage.PlanImproved).Select(r => r.Plan).ToList();
|
|
}
|
|
|
|
[Fact]
|
|
public void ALaterCandidatesWorsePlanIsNotShownAfterABetterOne()
|
|
{
|
|
var job = SquaresJob();
|
|
var cheap = OneSheet(job);
|
|
var dear = TwoSheets(job);
|
|
|
|
var plans = ReportedPlans(job, out var result, Plans(_ => cheap, _ => cheap), Plans(_ => dear, _ => dear));
|
|
|
|
Assert.Same(cheap, result);
|
|
Assert.Same(cheap, Assert.Single(plans));
|
|
}
|
|
|
|
[Fact]
|
|
public void ABetterPlanFromALaterCandidateReplacesTheShownOne()
|
|
{
|
|
var job = SquaresJob();
|
|
var cheap = OneSheet(job);
|
|
var dear = TwoSheets(job);
|
|
var partial = OnePlaced(job);
|
|
|
|
var plans = ReportedPlans(job, out var result,
|
|
Plans(_ => dear, _ => partial, _ => dear), Plans(_ => cheap, _ => dear, _ => cheap), Plans(_ => dear, _ => dear));
|
|
|
|
Assert.Same(cheap, result);
|
|
// Fewest unplaced first, then cost: a plan equal to the shown one is not shown again.
|
|
Assert.Equal(new[] { partial, dear, cheap }, plans);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheChosenResultIsTheLastPlanEvenWhenAnUnreturnedPlanScoredBetter()
|
|
{
|
|
var job = SquaresJob();
|
|
var cheap = OneSheet(job);
|
|
var invalid = Overlapping(job);
|
|
var dear = TwoSheets(job);
|
|
|
|
// The first candidate shows a cheap valid plan but returns an invalid layout; the second
|
|
// reports no plans, and its valid result is chosen.
|
|
var plans = ReportedPlans(job, out var result, Plans(_ => invalid, _ => cheap), Returns(_ => dear));
|
|
|
|
Assert.Same(dear, result);
|
|
Assert.Equal(new[] { cheap, dear }, plans);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResultsOfCandidatesWithoutPlansAreShownWhenBetter()
|
|
{
|
|
var job = SquaresJob();
|
|
var cheap = OneSheet(job);
|
|
var dear = TwoSheets(job);
|
|
|
|
var plans = ReportedPlans(job, out var result, Returns(_ => dear), Returns(_ => cheap), Returns(_ => dear));
|
|
|
|
Assert.Same(cheap, result);
|
|
Assert.Equal(new[] { dear, cheap }, plans);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInCandidatesReportTheirBestPlanEndingWithTheReturnedLayout()
|
|
{
|
|
var job = Job([Part("disc", Disc(2), 6), Part("ell", LShape(9, 7, 3), 4), Part("plate", Rectangle(12, 5), 3)],
|
|
[Stock("a", 24, 30, 0.25), Stock("b", 30, 40, 0.25)]);
|
|
|
|
PlanAssert.ReportedAndEndWithTheResult(() => new DefaultNestingEngine(), job);
|
|
}
|
|
|
|
private sealed class Capture(Action<NestJobProgress> action) : IProgress<NestJobProgress>
|
|
{
|
|
public void Report(NestJobProgress value) => action(value);
|
|
}
|
|
}
|
|
|
|
public sealed class DefaultContractTests : EngineContractTests<DefaultNestingEngine> { }
|