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