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;
}
}
}