Files
OpenNest/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs
T
ajandClaude Opus 5.5 00acaeaaf1 feat(engine): report best whole-job plans from Rectangles, Circles and Default
Desktop Auto Nest's "Best plan so far" strip now fills in for every built-in
whole-job engine except FlatBar, not only Irregular.

- Rectangles makes one sheet-by-sheet run, so it reports the finished job as
  its one plan, then replays its plate commits (token checked as before).
- Circles reports when a stock-preference run or a tail re-plan beats its
  best result so far.
- Default scores each candidate plan as it ranks results (valid, fewest
  unplaced, lowest cost, candidate order) and reports it only when it beats
  every plan already shown, so a later candidate never shows a worse plan.
  The chosen result is always the last plan reported.
- Shared ReportPlan/SameLayout helpers on CandidateProgress; Irregular uses
  them too. Reporting never changes the search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:56:38 -04:00

269 lines
11 KiB
C#

using OpenNest.Engine.Jobs;
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)));
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 Overlapping(NestJob job) => Layout(job, Sheet((0, 0), (1, 0)));
private static NestJobResult OnePlaced(NestJob job) => Layout(job, Sheet((0, 0)));
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 = OneSheetHigher(job);
Assert.Same(low, Choose(job, Returns(_ => low), Returns(_ => high)));
Assert.Same(high, Choose(job, Returns(_ => high), Returns(_ => low)));
}
[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> { }