diff --git a/OpenNest.Engine.Tests/NestingEngines/CirclesNestingEngineTests.cs b/OpenNest.Engine.Tests/NestingEngines/CirclesNestingEngineTests.cs index f3377cd2..d0cc487c 100644 --- a/OpenNest.Engine.Tests/NestingEngines/CirclesNestingEngineTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/CirclesNestingEngineTests.cs @@ -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))); + } + /// A polygonal gear: alternating tip and root radii. private static Program Gear(int teeth, double tip, double root) => Polyline(Enumerable.Range(0, 2 * teeth).Select(i => diff --git a/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs b/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs index 86f7d5e4..b107f230 100644 --- a/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs @@ -176,6 +176,89 @@ public class DefaultNestingEngineTests Assert.Equal(best, NestJobCost.Evaluate(job, result), 9); } + /// A candidate that reports each given plan, then returns its layout. + private static Func Plans(Func layout, params Func[] 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 ReportedPlans(NestJob job, out NestJobResult result, params Func[] candidates) + { + var reports = new List(); + 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 action) : IProgress { public void Report(NestJobProgress value) => action(value); diff --git a/OpenNest.Engine.Tests/NestingEngines/PlanAssert.cs b/OpenNest.Engine.Tests/NestingEngines/PlanAssert.cs new file mode 100644 index 00000000..91132d11 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/PlanAssert.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.Tests.NestingEngines; + +/// Checks an engine's whole-job plan reports (). +internal static class PlanAssert +{ + /// + /// 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. + /// + public static IReadOnlyList ReportedAndEndWithTheResult(Func engine, NestJob job) + { + var reports = new List(); + 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 report) : IProgress + { + public void Report(NestJobProgress value) => report(value); + } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs b/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs index 495db334..ab2e5874 100644 --- a/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs @@ -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 { } diff --git a/OpenNest.Engine/NestingEngines/CandidateProgress.cs b/OpenNest.Engine/NestingEngines/CandidateProgress.cs index be924858..449cf39e 100644 --- a/OpenNest.Engine/NestingEngines/CandidateProgress.cs +++ b/OpenNest.Engine/NestingEngines/CandidateProgress.cs @@ -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 inner) : IPro inner.Report(value); } - public static void ReportCommits(NestJobResult result, IProgress? progress) + /// Reports the plate commits of in order. The token is + /// observed before each commit and after the last, as while building a result. + public static void ReportCommits(NestJobResult result, IProgress? 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(); } + + /// Reports a whole-job plan as : a proposal + /// with no committed counts. Reporting never changes the search. + public static void ReportPlan(NestJobResult plan, IProgress? progress) => + progress?.Report(new NestJobProgress(NestJobStage.PlanImproved, string.Empty, -1, 0, 0, Plan: plan)); + + /// True when both results place the same parts at the same poses on the same stock, + /// sheet by sheet. + 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))); } diff --git a/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs index 6d449eef..64364e07 100644 --- a/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Circles/CirclesNestingEngine.cs @@ -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 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 (). /// Deterministic: no clocks or randomness; the only stop besides completion is the host token. /// 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); + } } } } diff --git a/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs b/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs index 0504fa04..6e7ffefb 100644 --- a/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs @@ -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 () 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. /// 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; } + + /// + /// Forwards candidate progress except plate commits, and replaces candidates' raw plan reports + /// with Default's own best plan so far by . + /// + private sealed class PlanFilter(NestJob job, IProgress inner, CancellationToken token) + : IProgress + { + private CandidateScore? shown; + + /// Candidate order of the plans now arriving, for score ties. + 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); + } + + /// Reports the plan when it beats every plan reported so far. + public void Offer(CandidateScore plan) + { + if (shown != null && !plan.IsBetterThan(shown)) + return; + shown = plan; + CandidateProgress.ReportPlan(plan.Result, inner); + } + + /// 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. + public void Finish(CandidateScore chosen) + { + if (shown != null && CandidateProgress.SameLayout(shown.Result, chosen.Result)) + return; + shown = chosen; + CandidateProgress.ReportPlan(chosen.Result, inner); + } + } } diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index 2f2fdad9..df50dc91 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -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); } /// Exercises the production physical-tail lifecycle from a test-owned incumbent. diff --git a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs index eaefca4f..024ed304 100644 --- a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs @@ -18,7 +18,8 @@ namespace OpenNest.Engine.NestingEngines.Rectangles; /// /// Sheet by sheet (), 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 () before its plate commits. /// Deterministic: no clocks or randomness; the only stop besides completion is the host token. /// 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 Pack(NestPlateStock stock, IReadOnlyList left) { diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index 3017444d..eccda7db 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -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. diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index a83e6835..012c402f 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -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