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