From 859d10b31d80e1dc4973cf89e7c74cd923921ecb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 18 Oct 2026 12:11:30 -0400 Subject: [PATCH] feat(engine): offer a box layout before best-fit pairs in Fill Interactive Fill ran the best-fit pair phase first. The first pair computation for a drawing can take about a minute, and until it finished the progress dialog showed no layout, Accept stayed disabled and the status text did not change. The fill now packs the part boxes (RectBestFit) before the pair phase and reports that layout, so Accept is available at once. The layout is only a fallback: strategies still run in their usual order, and a completed fill picks the same winner as before. While the pairs are computed, the detail line reads "Computing best-fit pairs (may take a minute)...". Accept or Stop now stops the wait. The computation keeps running in the background and is cached for the next fill. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Engine/BestFit/BestFitCache.cs | 33 +++++++ OpenNest.Engine/Fill/PairFiller.cs | 26 +++-- OpenNest.Engine/Fill/StripeFiller.cs | 3 +- .../Placement/Fillers/DefaultPlateFiller.cs | 47 +++++++++- OpenNest.Engine/Strategies/FillContext.cs | 17 +++- .../Strategies/PairsFillStrategy.cs | 3 +- .../Strategies/RectBestFitStrategy.cs | 17 +++- OpenNest.Tests/BestFit/BestFitCacheTests.cs | 39 ++++++++ .../Strategies/FillPipelineTests.cs | 94 ++++++++++++++++++- docs/nesting-engines.md | 9 ++ 10 files changed, 272 insertions(+), 16 deletions(-) diff --git a/OpenNest.Engine/BestFit/BestFitCache.cs b/OpenNest.Engine/BestFit/BestFitCache.cs index b61ba1bf..3b93d910 100644 --- a/OpenNest.Engine/BestFit/BestFitCache.cs +++ b/OpenNest.Engine/BestFit/BestFitCache.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; namespace OpenNest.Engine.BestFit { @@ -40,6 +41,38 @@ namespace OpenNest.Engine.BestFit return entry.Filtered.GetOrAdd(key, _ => FilterForSize(candidates, plateWidth, plateHeight)); } + /// + /// Like , but cancelling stops + /// the wait, not the computation: it finishes in the background and caches its result. + /// Callers must pass a drawing nothing else mutates, such as a canonical copy. + /// + public static List GetOrCompute( + Drawing drawing, + double plateWidth, + double plateHeight, + double spacing, + CancellationToken token + ) + { + if (!token.CanBeCanceled) + return GetOrCompute(drawing, plateWidth, plateHeight, spacing); + + token.ThrowIfCancellationRequested(); + return Task.Run(() => GetOrCompute(drawing, plateWidth, plateHeight, spacing)) + .WaitAsync(token) + .GetAwaiter() + .GetResult(); + } + + /// True when would return at once. + public static bool IsCached(Drawing drawing, double plateWidth, double plateHeight, double spacing) + { + var source = CanonicalFrame.SourceOf(drawing); + return _entries.TryGetValue(source, out var entry) + && entry.IsCurrentFor(source) + && entry.Filtered.ContainsKey((plateWidth, plateHeight, spacing)); + } + public static void ComputeForSizes( Drawing drawing, double spacing, diff --git a/OpenNest.Engine/Fill/PairFiller.cs b/OpenNest.Engine/Fill/PairFiller.cs index fe3577bd..206d29dc 100644 --- a/OpenNest.Engine/Fill/PairFiller.cs +++ b/OpenNest.Engine/Fill/PairFiller.cs @@ -110,12 +110,26 @@ namespace OpenNest.Engine.Fill Action, string> reportProgress = null ) { - var bestFits = BestFitCache.GetOrCompute( - item.Drawing, - plateSize.Length, - plateSize.Width, - partSpacing - ); + // The first computation for a drawing can take a minute; say so rather than look stuck. + if (!BestFitCache.IsCached(item.Drawing, plateSize.Length, plateSize.Width, partSpacing)) + reportProgress?.Invoke(null, "Computing best-fit pairs (may take a minute)..."); + + List bestFits; + try + { + bestFits = BestFitCache.GetOrCompute( + item.Drawing, + plateSize.Length, + plateSize.Width, + partSpacing, + token + ); + } + catch (OperationCanceledException) + { + // The pairs finish computing in the background and are cached for the next fill. + return new PairFillResult(); + } var candidates = candidateSelector.Select(bestFits, workArea); Debug.WriteLine( diff --git a/OpenNest.Engine/Fill/StripeFiller.cs b/OpenNest.Engine/Fill/StripeFiller.cs index 25e6f312..15837b39 100644 --- a/OpenNest.Engine/Fill/StripeFiller.cs +++ b/OpenNest.Engine/Fill/StripeFiller.cs @@ -214,7 +214,8 @@ public class StripeFiller _context.Item.Drawing, _context.Plate.Size.Length, _context.Plate.Size.Width, - _context.Plate.PartSpacing + _context.Plate.PartSpacing, + _context.Token ); return bestFits.Where(r => r.Keep).Take(MaxPairCandidates).ToList(); diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs b/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs index 96f819a0..a8f7115e 100644 --- a/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs +++ b/OpenNest.Engine/Jobs/Placement/Fillers/DefaultPlateFiller.cs @@ -419,6 +419,8 @@ internal class DefaultPlateFiller : PlateFillerBase var angles = BuildAngles(context.Item, classification, context.WorkArea); context.SharedState["AngleCandidates"] = angles; + ReportProvisional(context); + try { foreach (var strategy in FillStrategyRegistry.Strategies) @@ -456,7 +458,12 @@ internal class DefaultPlateFiller : PlateFillerBase context.WinnerPhase = strategy.Phase; } - if (improved && context.CurrentBest != null && context.CurrentBest.Count > 0) + if ( + improved + && context.CurrentBest != null + && context.CurrentBest.Count > 0 + && !context.ProvisionalLeads(context.CurrentBest) + ) { NestProgressReporter.Report( context.Progress, @@ -476,8 +483,46 @@ internal class DefaultPlateFiller : PlateFillerBase catch (OperationCanceledException) { Debug.WriteLine("[RunPipeline] Cancelled, returning current best"); + if (context.ProvisionalLeads(context.CurrentBest)) + { + context.CurrentBest = context.Provisional; + context.CurrentBestScore = FillScore.Compute(context.Provisional, context.WorkArea); + context.WinnerPhase = NestPhase.RectBestFit; + } } RecordProductiveAngles(context.AngleResults); } + + /// + /// Shows the box-packing layout before best-fit pairs, whose first computation for a drawing + /// can take a long time, so the user can accept a usable layout at once. Only offered when the + /// RectBestFit strategy is active, so the pipeline still computes it and a completed fill + /// returns exactly what it would without the preview. + /// + private void ReportProvisional(FillContext context) + { + if (context.Token.IsCancellationRequested) + return; + if (!FillStrategyRegistry.Strategies.Any(s => s is RectBestFitStrategy)) + return; + + var parts = RectBestFitStrategy.GetOrCompute(context); + if (parts == null || parts.Count == 0) + return; + + context.Provisional = parts; + NestProgressReporter.Report( + context.Progress, + new ProgressReport + { + Phase = NestPhase.RectBestFit, + PlateNumber = PlateNumber, + Parts = context.ToOriginalFrame(parts), + WorkArea = context.WorkArea, + Description = $"Quick box layout: {parts.Count} parts", + IsOverallBest = true, + } + ); + } } diff --git a/OpenNest.Engine/Strategies/FillContext.cs b/OpenNest.Engine/Strategies/FillContext.cs index 558db3af..e4e39ea0 100644 --- a/OpenNest.Engine/Strategies/FillContext.cs +++ b/OpenNest.Engine/Strategies/FillContext.cs @@ -36,6 +36,17 @@ namespace OpenNest.Engine.Strategies public List CurrentBest { get; set; } + /// + /// A cheap layout shown before the slow strategies run, so it can be accepted early. It + /// never becomes while strategies run, so they pick the same + /// winner as without it; the pipeline returns it only when cancelled while it still leads. + /// + public List Provisional { get; set; } + + /// True when beats . + internal bool ProvisionalLeads(List parts) => + Provisional != null && Policy.Comparer.IsBetter(Provisional, parts, WorkArea); + /// For progress reporting only; comparisons use Policy.Comparer. public FillScore CurrentBestScore { get; set; } public NestPhase WinnerPhase { get; set; } @@ -65,16 +76,18 @@ namespace OpenNest.Engine.Strategies WinnerPhase = ActivePhase; } + // The shown layout never regresses below the provisional one. + var shown = ProvisionalLeads(CurrentBest) ? Provisional : CurrentBest; NestProgressReporter.Report( Progress, new ProgressReport { Phase = ActivePhase, PlateNumber = PlateNumber, - Parts = ToOriginalFrame(isNewBest ? parts : CurrentBest), + Parts = ToOriginalFrame(shown), WorkArea = WorkArea, Description = description, - IsOverallBest = isNewBest, + IsOverallBest = isNewBest && ReferenceEquals(shown, parts), } ); } diff --git a/OpenNest.Engine/Strategies/PairsFillStrategy.cs b/OpenNest.Engine/Strategies/PairsFillStrategy.cs index e0867fe4..472cebad 100644 --- a/OpenNest.Engine/Strategies/PairsFillStrategy.cs +++ b/OpenNest.Engine/Strategies/PairsFillStrategy.cs @@ -36,7 +36,8 @@ namespace OpenNest.Engine.Strategies context.ReportProgress ); - context.SharedState["BestFits"] = result.BestFits; + if (result.BestFits != null) + context.SharedState["BestFits"] = result.BestFits; return result.Parts; } diff --git a/OpenNest.Engine/Strategies/RectBestFitStrategy.cs b/OpenNest.Engine/Strategies/RectBestFitStrategy.cs index 3b5cdfb9..9c694575 100644 --- a/OpenNest.Engine/Strategies/RectBestFitStrategy.cs +++ b/OpenNest.Engine/Strategies/RectBestFitStrategy.cs @@ -5,18 +5,31 @@ namespace OpenNest.Engine.Strategies { public class RectBestFitStrategy : IFillStrategy { + private const string ResultKey = "RectBestFit"; + public string Name => "RectBestFit"; public NestPhase Phase => NestPhase.RectBestFit; public int Order => 200; - public List Fill(FillContext context) + public List Fill(FillContext context) => GetOrCompute(context); + + /// + /// Packs the item's boxes once per context. It is cheap, so the pipeline also uses it as + /// the provisional layout shown before the best-fit pairs are computed. + /// + internal static List GetOrCompute(FillContext context) { + if (context.SharedState.TryGetValue(ResultKey, out var cached)) + return (List)cached; + var binItem = BinConverter.ToItem(context.Item, context.Plate.PartSpacing); var bin = BinConverter.CreateBin(context.WorkArea, context.Plate.PartSpacing); RectFill.FillBest(bin, binItem); - return BinConverter.ToParts(bin, new List { context.Item }); + var parts = BinConverter.ToParts(bin, new List { context.Item }); + context.SharedState[ResultKey] = parts; + return parts; } } } diff --git a/OpenNest.Tests/BestFit/BestFitCacheTests.cs b/OpenNest.Tests/BestFit/BestFitCacheTests.cs index 1781f1c0..0be733e6 100644 --- a/OpenNest.Tests/BestFit/BestFitCacheTests.cs +++ b/OpenNest.Tests/BestFit/BestFitCacheTests.cs @@ -53,6 +53,45 @@ public class BestFitCacheTests public void Dispose() => BestFitCache.CreateEvaluator = previous; } + [Fact] + public async Task GetOrCompute_Cancelled_StopsWaitingAndCachesBackgroundResult() + { + var drawing = MakeRotatedTShape(); + var canonical = CanonicalFrame.AsCanonicalCopy(drawing); + var previous = BestFitCache.CreateEvaluator; + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var count = 0; + BestFitCache.CreateEvaluator = (candidate, _) => + { + if (!ReferenceEquals(CanonicalFrame.SourceOf(candidate), drawing)) + return new PairEvaluator(); + Interlocked.Increment(ref count); + entered.Set(); + release.Wait(); + return new PairEvaluator(); + }; + try + { + using var cts = new CancellationTokenSource(); + var waiting = Task.Run(() => BestFitCache.GetOrCompute(canonical, 60, 120, Spacing, cts.Token)); + Assert.True(entered.Wait(TimeSpan.FromSeconds(10))); + + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => waiting); + + release.Set(); + var results = BestFitCache.GetOrCompute(canonical, 60, 120, Spacing); + Assert.NotEmpty(results); + Assert.Equal(1, Volatile.Read(ref count)); + } + finally + { + release.Set(); + BestFitCache.CreateEvaluator = previous; + } + } + [Fact] public void GetOrCompute_CanonicalCopiesOfOneDrawing_ShareOneComputation() { diff --git a/OpenNest.Tests/Strategies/FillPipelineTests.cs b/OpenNest.Tests/Strategies/FillPipelineTests.cs index 437ff609..c74f3e59 100644 --- a/OpenNest.Tests/Strategies/FillPipelineTests.cs +++ b/OpenNest.Tests/Strategies/FillPipelineTests.cs @@ -1,7 +1,11 @@ -using OpenNest.Engine.Strategies; -using OpenNest.Engine.Jobs.Placement.Fillers; -using OpenNest.Geometry; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using OpenNest.Engine; +using OpenNest.Engine.Jobs.Placement.Fillers; +using OpenNest.Engine.Strategies; +using OpenNest.Geometry; namespace OpenNest.Tests.Strategies; @@ -72,4 +76,88 @@ public class FillPipelineTests // Should not throw — graceful degradation Assert.NotNull(parts); } + + [Fact] + public void Pipeline_ReportsBoxLayoutBeforeBestFitPairs() + { + var plate = new Plate(60, 120); + var filler = new DefaultPlateFiller(plate); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var reports = new List(); + + filler.Fill(item, plate.WorkArea(), new SyncProgress(reports.Add), CancellationToken.None); + + var first = reports.First(); + Assert.True(first.IsOverallBest); + Assert.Equal(NestPhase.RectBestFit, first.Phase); + Assert.NotEmpty(first.BestParts); + } + + [Fact] + public void Pipeline_ReportsStatusWhileComputingBestFitPairs() + { + var plate = new Plate(60, 120); + var filler = new DefaultPlateFiller(plate); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var reports = new List(); + + filler.Fill(item, plate.WorkArea(), new SyncProgress(reports.Add), CancellationToken.None); + + var status = Assert.Single(reports, p => p.Description?.StartsWith("Computing best-fit pairs") == true); + Assert.Equal(NestPhase.Pairs, status.Phase); + Assert.False(status.IsOverallBest); + Assert.Equal(reports.First().BestParts.Count, status.BestParts.Count); + } + + [Fact] + public void Pipeline_AcceptedBeforeBestFitPairs_ReturnsBoxLayout() + { + var plate = new Plate(60, 120); + var filler = new DefaultPlateFiller(plate); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + using var cts = new CancellationTokenSource(); + NestProgress first = null; + + var parts = filler.Fill( + item, + plate.WorkArea(), + new SyncProgress(p => + { + first ??= p; + cts.Cancel(); + }), + cts.Token + ); + + Assert.Equal(NestPhase.RectBestFit, filler.WinnerPhase); + Assert.Equal(first.BestParts.Count, parts.Count); + } + + [Fact] + public void Pipeline_ShownLayoutNeverRegressesBelowBoxLayout() + { + var plate = new Plate(60, 120); + var filler = new DefaultPlateFiller(plate); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var shown = new List(); + + filler.Fill( + item, + plate.WorkArea(), + new SyncProgress(p => + { + if (p.IsOverallBest) + shown.Add(p.BestParts.Count); + }), + CancellationToken.None + ); + + for (var i = 1; i < shown.Count; i++) + Assert.True(shown[i] >= shown[0], $"Report {i} showed {shown[i]} < {shown[0]} parts"); + } + + private sealed class SyncProgress(Action report) : IProgress + { + public void Report(NestProgress value) => report(value); + } } diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index b68d2195..81fba02e 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -30,6 +30,15 @@ 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. +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 +completed fill picks the same winner as without it. While the pairs are computed the progress +detail reads "Computing best-fit pairs (may take a minute)...". Accepting or stopping stops waiting for the +pair computation, which finishes in the background and is cached for the next fill. Fills +inside clamp-free areas of a clamped plate report only their final certified proposal, so this +early layout is not shown there. + Interactive/full-area box packing uses the same 90% work-area slack allowance as Rectangles. A free box may absorb a slightly oversized side only at its right/top edge when that edge coincides with the plate work-area boundary. Internal leftover edges keep the strict packing