mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 15:01:07 -04:00
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:
1 parent
de845a5818
commit
859d10b31d
10 files changed
+272
-16
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user