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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 12:49:48 -04:00
1 parent de845a5818
commit 859d10b31d
10 files changed
+272 -16

No files matched your search

+33
View File
@@ -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));
}
/// <summary>
/// Like <see cref="GetOrCompute(Drawing, double, double, double)"/>, 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.
/// </summary>
public static List<BestFitResult> 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();
}
/// <summary>True when <see cref="GetOrCompute(Drawing, double, double, double)"/> would return at once.</summary>
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,
+20 -6
View File
@@ -110,12 +110,26 @@ namespace OpenNest.Engine.Fill
Action<List<Part>, 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<BestFitResult> 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(
+2 -1
View File
@@ -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();
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
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,
}
);
}
}
+15 -2
View File
@@ -36,6 +36,17 @@ namespace OpenNest.Engine.Strategies
public List<Part> CurrentBest { get; set; }
/// <summary>
/// A cheap layout shown before the slow strategies run, so it can be accepted early. It
/// never becomes <see cref="CurrentBest"/> while strategies run, so they pick the same
/// winner as without it; the pipeline returns it only when cancelled while it still leads.
/// </summary>
public List<Part> Provisional { get; set; }
/// <summary>True when <see cref="Provisional"/> beats <paramref name="parts"/>.</summary>
internal bool ProvisionalLeads(List<Part> parts) =>
Provisional != null && Policy.Comparer.IsBetter(Provisional, parts, WorkArea);
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
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),
}
);
}
@@ -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;
}
@@ -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<Part> Fill(FillContext context)
public List<Part> Fill(FillContext context) => GetOrCompute(context);
/// <summary>
/// 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.
/// </summary>
internal static List<Part> GetOrCompute(FillContext context)
{
if (context.SharedState.TryGetValue(ResultKey, out var cached))
return (List<Part>)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<NestItem> { context.Item });
var parts = BinConverter.ToParts(bin, new List<NestItem> { context.Item });
context.SharedState[ResultKey] = parts;
return parts;
}
}
}
@@ -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<OperationCanceledException>(() => 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()
{
+91 -3
View File
@@ -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<NestProgress>();
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<NestProgress>();
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<int>();
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<NestProgress> report) : IProgress<NestProgress>
{
public void Report(NestProgress value) => report(value);
}
}
+9
View File
@@ -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