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>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 23:56:38 -04:00
1 parent cd2bea054b
commit 00acaeaaf1
11 files changed
+275 -13

No files matched your search

@@ -2,6 +2,7 @@ using System;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines;
using OpenNest.Engine.NestingEngines.Circles;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
@@ -103,6 +104,19 @@ public class CirclesNestingEngineTests
Assert.All(result.Plates.SelectMany(p => p.Placements), p => Assert.Equal(0.3, p.Rotation, 12));
}
[Fact]
public void ReportsEachBetterRunOrTailEndingWithTheReturnedLayout()
{
var job = Job([Part("large", Disc(4), 7), Part("small", Disc(1.5), 25)],
[Stock("a", 24, 30, spacing: 0.25), Stock("b", 40, 40, spacing: 0.25)]);
var plans = PlanAssert.ReportedAndEndWithTheResult(() => new CirclesNestingEngine(), job);
// Only improvements are reported: each plan beats the one before it.
var scores = plans.Select(p => CandidateScore.Of(job, p, 0)).ToList();
Assert.All(scores.Zip(scores.Skip(1)), s => Assert.True(s.Second.IsBetterThan(s.First)));
}
/// <summary>A polygonal gear: alternating tip and root radii.</summary>
private static Program Gear(int teeth, double tip, double root) =>
Polyline(Enumerable.Range(0, 2 * teeth).Select(i =>
@@ -176,6 +176,89 @@ public class DefaultNestingEngineTests
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);
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>Checks an engine's whole-job plan reports (<see cref="NestJobStage.PlanImproved"/>).</summary>
internal static class PlanAssert
{
/// <summary>
/// Solves with and without a progress sink and asserts that plans are reported as proposals
/// with no committed counts, before any commit; that the last plan is the returned layout;
/// and that reporting does not change the layout. Returns the reported plans in order.
/// </summary>
public static IReadOnlyList<NestJobResult> ReportedAndEndWithTheResult(Func<INestingEngine> engine, NestJob job)
{
var reports = new List<NestJobProgress>();
var result = engine().Solve(job, new Capture(reports.Add));
var silent = engine().Solve(job);
var plans = reports.Where(r => r.Stage == NestJobStage.PlanImproved).ToList();
Assert.NotEmpty(plans);
Assert.All(plans, p => Assert.Equal((0, 0), (p.CommittedPlates, p.CommittedParts)));
Assert.All(plans, p => Assert.NotNull(p.Plan));
var firstCommit = reports.FindIndex(r => r.Stage == NestJobStage.PlateCommitted);
if (firstCommit >= 0)
Assert.True(reports.FindLastIndex(r => r.Stage == NestJobStage.PlanImproved) < firstCommit);
Assert.Equal(Layout(result), Layout(plans[^1].Plan));
Assert.Equal(Layout(silent), Layout(result));
return plans.Select(p => p.Plan).ToList();
}
public static IEnumerable<(int Plate, string StockId, NestJobPlacement Pose)> Layout(NestJobResult result) =>
result.Plates.SelectMany(p => p.Placements.Select(pose => (p.PlateIndex, p.StockId, pose)));
private sealed class Capture(Action<NestJobProgress> report) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value) => report(value);
}
}
@@ -182,6 +182,18 @@ public class RectanglesNestingEngineTests
Assert.Empty(result.Plates);
Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced);
}
[Fact]
public void ReportsTheFinishedJobAsItsPlanBeforeCommitting()
{
var job = Job(new[] { Part("rect", Rectangle(10, 5), 30), Part("ell", LShape(12, 8, 4), 6) },
new[] { Stock("small", 30, 30, spacing: 0.25), Stock("large", 48, 60, spacing: 0.25) });
var plans = PlanAssert.ReportedAndEndWithTheResult(() => new RectanglesNestingEngine(), job);
// One sheet-by-sheet run makes one whole-job plan.
Assert.Single(plans);
}
}
public sealed class RectanglesContractTests : EngineContractTests<RectanglesNestingEngine> { }
@@ -1,5 +1,7 @@
#nullable enable
using System;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.NestingEngines;
@@ -14,16 +16,34 @@ internal sealed class CandidateProgress(IProgress<NestJobProgress> inner) : IPro
inner.Report(value);
}
public static void ReportCommits(NestJobResult result, IProgress<NestJobProgress>? progress)
/// <summary>Reports the plate commits of <paramref name="result"/> in order. The token is
/// observed before each commit and after the last, as while building a result.</summary>
public static void ReportCommits(NestJobResult result, IProgress<NestJobProgress>? progress,
CancellationToken token = default)
{
if (progress == null)
return;
var parts = 0;
for (var i = 0; i < result.Plates.Count; i++)
{
token.ThrowIfCancellationRequested();
var plate = result.Plates[i];
parts += plate.Placements.Count;
progress.Report(new NestJobProgress(NestJobStage.PlateCommitted, plate.StockId, plate.PlateIndex, i + 1, parts));
}
token.ThrowIfCancellationRequested();
}
/// <summary>Reports a whole-job plan as <see cref="NestJobStage.PlanImproved"/>: a proposal
/// with no committed counts. Reporting never changes the search.</summary>
public static void ReportPlan(NestJobResult plan, IProgress<NestJobProgress>? progress) =>
progress?.Report(new NestJobProgress(NestJobStage.PlanImproved, string.Empty, -1, 0, 0, Plan: plan));
/// <summary>True when both results place the same parts at the same poses on the same stock,
/// sheet by sheet.</summary>
public static bool SameLayout(NestJobResult a, NestJobResult b) =>
ReferenceEquals(a, b)
|| (a.Plates.Count == b.Plates.Count
&& a.Plates.Zip(b.Plates).All(p => p.First.StockId == p.Second.StockId
&& p.First.Placements.SequenceEqual(p.Second.Placements)));
}
@@ -25,7 +25,8 @@ namespace OpenNest.Engine.NestingEngines.Circles;
/// estimated whole-job cost wins. The whole job runs once with that free choice and once
/// preferring each offered stock, and the best result by <see cref="CandidateScore"/> is kept;
/// its last sheet, and its last two sheets together, are then re-packed onto each single stock
/// and replaced when that lowers the whole job's cost.
/// and replaced when that lowers the whole job's cost. Each run or tail that beats the best so
/// far is reported as a whole-job plan (<see cref="NestJobStage.PlanImproved"/>).
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
/// </summary>
public sealed class CirclesNestingEngine : INestingEngine
@@ -81,7 +82,10 @@ public sealed class CirclesNestingEngine : INestingEngine
preferred: preferred);
var scored = CandidateScore.Of(job, result, run++);
if (best == null || scored.IsBetterThan(best))
{
best = scored;
CandidateProgress.ReportPlan(best.Result, progress);
}
}
best = ReplanTail(best!);
CandidateProgress.ReportCommits(best.Result, progress);
@@ -120,7 +124,10 @@ public sealed class CirclesNestingEngine : INestingEngine
builder.AddSheet(stock, layout.Poses);
var scored = CandidateScore.Of(job, builder.Build(chosen.Result.StopReason), run++);
if (scored.IsBetterThan(chosen))
{
chosen = scored;
CandidateProgress.ReportPlan(chosen.Result, progress);
}
}
}
}
@@ -25,6 +25,11 @@ namespace OpenNest.Engine.NestingEngines.Default;
/// first exception is rethrown. Cancellation stops the search. Candidates' plate commits are not forwarded; the chosen
/// result's commits are reported once it is selected.
///
/// Candidates' whole-job plans (<see cref="NestJobStage.PlanImproved"/>) are scored the same way
/// and reported only when one beats every plan reported so far, so a later candidate never shows a
/// worse plan after a better one. A candidate result that beats them is reported too, and the
/// chosen result is always the last plan reported.
///
/// Deterministic: candidates run one after another and the choice uses no clock.
/// </summary>
public sealed class DefaultNestingEngine : INestingEngine
@@ -56,7 +61,7 @@ public sealed class DefaultNestingEngine : INestingEngine
{
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
var forward = progress == null ? null : new CandidateProgress(progress);
var forward = progress == null ? null : new PlanFilter(job, progress, token);
CandidateScore? best = null;
ExceptionDispatchInfo? firstFailure = null;
@@ -64,6 +69,8 @@ public sealed class DefaultNestingEngine : INestingEngine
{
token.ThrowIfCancellationRequested();
NestJobResult? result;
if (forward != null)
forward.Order = order;
try
{
result = candidates[order]().Solve(job, forward, token);
@@ -80,6 +87,7 @@ public sealed class DefaultNestingEngine : INestingEngine
var scored = CandidateScore.Of(job, result, order);
if (best == null || scored.IsBetterThan(best))
best = scored;
forward?.Offer(scored);
}
token.ThrowIfCancellationRequested();
@@ -89,7 +97,64 @@ public sealed class DefaultNestingEngine : INestingEngine
throw new InvalidOperationException("No candidate engine returned a result.");
}
forward?.Finish(best);
CandidateProgress.ReportCommits(best.Result, progress);
return best.Result;
}
/// <summary>
/// Forwards candidate progress except plate commits, and replaces candidates' raw plan reports
/// with Default's own best plan so far by <see cref="CandidateScore"/>.
/// </summary>
private sealed class PlanFilter(NestJob job, IProgress<NestJobProgress> inner, CancellationToken token)
: IProgress<NestJobProgress>
{
private CandidateScore? shown;
/// <summary>Candidate order of the plans now arriving, for score ties.</summary>
public int Order { get; set; }
public void Report(NestJobProgress value)
{
if (value == null || value.Stage == NestJobStage.PlateCommitted)
return;
if (value.Stage != NestJobStage.PlanImproved)
{
inner.Report(value);
return;
}
if (value.Plan == null)
return;
CandidateScore plan;
try
{
plan = CandidateScore.Of(job, value.Plan, Order);
}
catch (Exception) when (!token.IsCancellationRequested)
{
// A plan that cannot be scored is not shown; throwing would fail the candidate.
return;
}
Offer(plan);
}
/// <summary>Reports the plan when it beats every plan reported so far.</summary>
public void Offer(CandidateScore plan)
{
if (shown != null && !plan.IsBetterThan(shown))
return;
shown = plan;
CandidateProgress.ReportPlan(plan.Result, inner);
}
/// <summary>Makes the chosen result the last plan reported. A candidate's plan can outscore
/// every returned result (its engine judged a later plan better), so this may follow it.</summary>
public void Finish(CandidateScore chosen)
{
if (shown != null && CandidateProgress.SameLayout(shown.Result, chosen.Result))
return;
shown = chosen;
CandidateProgress.ReportPlan(chosen.Result, inner);
}
}
}
@@ -110,8 +110,7 @@ public sealed class IrregularNestingEngine : INestingEngine
{
if (progress == null)
return;
var result = BuildResult(job, types, plan, null, token);
progress.Report(new NestJobProgress(NestJobStage.PlanImproved, string.Empty, -1, 0, 0, Plan: result));
CandidateProgress.ReportPlan(BuildResult(job, types, plan, null, token), progress);
}
/// <summary>Exercises the production physical-tail lifecycle from a test-owned incumbent.
@@ -18,7 +18,8 @@ namespace OpenNest.Engine.NestingEngines.Rectangles;
///
/// Sheet by sheet (<see cref="SheetByStock"/>), each available stock is packed under several
/// free-space scoring rules and two pick modes (best-fitting box anywhere, or largest type first),
/// and the candidate with the lowest estimated whole-job cost wins.
/// and the candidate with the lowest estimated whole-job cost wins. The finished job is reported as
/// its one whole-job plan (<see cref="NestJobStage.PlanImproved"/>) before its plate commits.
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
/// </summary>
public sealed class RectanglesNestingEngine : INestingEngine
@@ -52,7 +53,12 @@ public sealed class RectanglesNestingEngine : INestingEngine
remaining[t.Index] = 0;
var demand = types.Select(t => new SheetDemand(t.Index, t.Id, t.Priority, t.MaterialArea)).ToArray();
return SheetByStock.Run(job, demand, remaining, Pack, progress, token, BatchSelected);
// One run makes one whole-job plan: report it, then its commits, as the other engines do.
var forward = progress == null ? null : new CandidateProgress(progress);
var result = SheetByStock.Run(job, demand, remaining, Pack, forward, token, BatchSelected);
CandidateProgress.ReportPlan(result, progress);
CandidateProgress.ReportCommits(result, progress, token);
return result;
IEnumerable<SheetLayout> Pack(NestPlateStock stock, IReadOnlyList<int> left)
{
+1 -1
View File
@@ -27,7 +27,7 @@ Auto Nest starts on empty/new sheets and does not change existing populated plat
Progress is owned and modal so the input drawings cannot be edited while a worker uses them. Stop or closing progress cancels and discards the whole proposal. The dialog waits for the worker to finish; no engine has an Accept-early button on this path. A plug-in that ignores the cancellation token cannot commit its late result.
Auto Nest never draws its parts on the plate view before they are kept: its result is a set of sheets, possibly of several sizes, not parts on the current plate. Ghost (preview) parts on the plate view mean only "what this plate gets if you accept now", and stay with interactive Fill and the remnant tools. Instead the progress window shows **Best plan so far**: every sheet of the engine's best whole-job plan drawn side by side at one scale, identical sheets once with their count, each captioned with size, part count and utilization. An engine reports a plan with `NestJobStage.PlanImproved` and `NestJobProgress.Plan`; `NestPipeline` adds `PlanSheets`, parts on detached drawings built from the job geometry, so nothing touches the caller's drawings or quantities. The plan is unvalidated and may be replaced; only Irregular reports plans today, so other engines show the strip empty until the review.
Auto Nest never draws its parts on the plate view before they are kept: its result is a set of sheets, possibly of several sizes, not parts on the current plate. Ghost (preview) parts on the plate view mean only "what this plate gets if you accept now", and stay with interactive Fill and the remnant tools. Instead the progress window shows **Best plan so far**: every sheet of the engine's best whole-job plan drawn side by side at one scale, identical sheets once with their count, each captioned with size, part count and utilization. An engine reports a plan with `NestJobStage.PlanImproved` and `NestJobProgress.Plan`; `NestPipeline` adds `PlanSheets`, parts on detached drawings built from the job geometry, so nothing touches the caller's drawings or quantities. The plan is unvalidated and may be replaced. Default, Irregular, Rectangles and Circles report plans (see [nesting engines](nesting-engines.md#best-plan-so-far)); FlatBar, StockLadder, the fixed-strategy engines and plug-ins that report none show the strip empty until the review.
After a valid result, or an invalid one kept in the validation report, a review window shows the final sheets in the same strip with totals, the supplied-cost summary and every unplaced drawing, count and reason. **Keep** (Enter) commits the whole proposal; **Discard** (Escape) applies nothing.
+20 -5
View File
@@ -30,6 +30,26 @@ Rectangles places irregular parts validly, but only as their bounding boxes; it
notch or hole. Box sides account for how the layout check flattens arcs, so round-edged parts stay
valid at box contact.
### Best plan so far
Whole-job engines report each better whole-job plan as `NestJobStage.PlanImproved` with the plan
in `NestJobProgress.Plan`, built without plate commits; desktop Auto Nest shows it as its "Best
plan so far" sheet strip (see [automatic nesting](automatic-nesting.md)). Plans are unvalidated
proposals with no committed counts, all come before the plate commits, and the last one is the
returned layout. Reporting never changes the search: layouts are identical with or without a
progress sink.
- Irregular reports when a strategy variant or the tail re-plan beats its best plan so far.
- Rectangles makes one sheet-by-sheet run, so it reports the finished job as its one plan.
- 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,
then candidate order) and reports it only when it beats every plan Default has reported, so a
later candidate never shows a worse plan after a better one. A candidate result that reports no
plans is offered the same way. When the chosen result is not the last plan reported (a
candidate's plan can outscore every returned result), Default reports it last.
Plug-ins may report plans the same way; FlatBar, StockLadder and the fixed-strategy engines do not.
Interactive Fill shows its rectangle best-fit (box packing) layout before the best-fit pair
phase, whose first computation for a drawing can take a minute, so **Accept** is available at
once. The box layout is only a fallback: the strategies still run in their usual order and a
@@ -63,11 +83,6 @@ Curved outlines retain conservative flattening-error padding. This does not rela
footprints, no-fit polygons or layout validation tolerances; a part extending beyond the accepted
work-area bounds remains invalid.
Each time a strategy variant or the tail re-plan beats the best whole-job plan so far,
Irregular reports it as `NestJobStage.PlanImproved`, which desktop Auto Nest shows as its
"Best plan so far" sheet strip (see [automatic nesting](automatic-nesting.md)). The last
report is the returned layout; reporting never changes the search.
Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside
enclosed cutouts. For a part with two or more copies it also offers its best-fit pairs (two copies
interlocked, as the Best Fit viewer shows them) alongside the single copies, and places a pair