mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 10:21:08 -04:00
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>
269 lines
11 KiB
C#
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> { }
|