fix(irregular): charge nested block-fill cost against the work budget

BlockCatalog's PrepareBlocks pre-pass ran a full independent NFP-based
pack (PrivatePlateFill.Run) for up to 8 attempts per part type, but that
cost never touched the engine's WorkCounter/WorkBudget. For jobs with
several part types at double-digit quantities, these uncounted nested
solves could burn the entire 5-minute timeout inside the first sheet of
the first strategy variant, so Solve() never returned and nothing was
ever committed.

Charge quantity^2 * outline vertex count against the shared counter
before each nested fill, and stop issuing new block proposals once the
budget is exhausted, so the existing effort governor actually sees and
bounds this cost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Sonnet 5 committed 2026-10-05 09:29:46 -04:00
1 parent 32db086384
commit eda4a846f0
3 files changed
+20 -10

No files matched your search

@@ -19,7 +19,7 @@ public class IrregularBlockTests
var job = Job(new[] { Part("ell", LShape(9, 7, 3), quantity, RotationPolicy.Automatic) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var types = PartCatalog.Build(job);
using var catalog = new BlockCatalog(0.25, types, NoPairs);
using var catalog = new BlockCatalog(0.25, types, NoPairs, new WorkCounter(), long.MaxValue);
Assert.Empty(catalog.Get(types[0], quantity, new Box(0, 0, 60, 40), CancellationToken.None));
Assert.Equal(0, catalog.PreparationCount);
}
@@ -31,7 +31,7 @@ public class IrregularBlockTests
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var types = PartCatalog.Build(job);
var original = types[0].Orientations.ToArray();
using var catalog = new BlockCatalog(0.25, types, NoPairs);
using var catalog = new BlockCatalog(0.25, types, NoPairs, new WorkCounter(), long.MaxValue);
var block = catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None);
Assert.Equal(7, block.Count);
Assert.Equal(original, types[0].Orientations);
@@ -79,7 +79,7 @@ public class IrregularBlockTests
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 44, RotationPolicy.Automatic) }, new[] { stock });
var types = PartCatalog.Build(job);
var pairs = PairCatalog.Build(types, 0.25, 40, 30, CancellationToken.None);
using var blocks = new BlockCatalog(0.25, types, pairs);
using var blocks = new BlockCatalog(0.25, types, pairs, new WorkCounter(), long.MaxValue);
var before = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
PackAxis.X, 1, new WorkCounter()).Fill(new[] { 44 }, CancellationToken.None);
var after = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
@@ -97,7 +97,7 @@ public class IrregularBlockTests
var stock = Stock("sheet", 3, 3, spacing: 0.25);
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { stock });
var types = PartCatalog.Build(job);
using var blocks = new BlockCatalog(0.25, types, NoPairs);
using var blocks = new BlockCatalog(0.25, types, NoPairs, new WorkCounter(), long.MaxValue);
Assert.Empty(blocks.Get(types[0], 3, new Box(0, 0, 3, 3), CancellationToken.None));
Assert.Equal(0, blocks.PreparationCount);
var fill = new FrontierPacker(types, new NoFitCache(0.25), NoPairs, stock,
@@ -158,7 +158,7 @@ public class IrregularBlockTests
{
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { Stock("sheet", 10, 20) });
var types = PartCatalog.Build(job);
using var catalog = new BlockCatalog(0.25, types, NoPairs);
using var catalog = new BlockCatalog(0.25, types, NoPairs, new WorkCounter(), long.MaxValue);
Assert.Throws<OperationCanceledException>(() =>
catalog.Get(types[0], 3, new Box(0, 0, 20, 10), new CancellationToken(true)));
}
@@ -17,6 +17,8 @@ namespace OpenNest.Engine.NestingEngines.Irregular;
internal sealed class BlockCatalog : IDisposable
{
private readonly double spacing;
private readonly WorkCounter counter;
private readonly long budget;
private readonly Dictionary<int, Drawing> drawings = new();
private readonly Dictionary<int, List<Orientation>> orientations = new();
private readonly Dictionary<int, int> attempts = new();
@@ -24,9 +26,11 @@ internal sealed class BlockCatalog : IDisposable
private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList<Placed>> cache = new();
public BlockCatalog(double spacing, IReadOnlyList<PartType> types,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs)
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs, WorkCounter counter, long budget)
{
this.spacing = spacing;
this.counter = counter;
this.budget = budget;
foreach (var type in types)
{
var poses = type.Orientations.ToList();
@@ -65,7 +69,7 @@ internal sealed class BlockCatalog : IDisposable
var key = (type.Index, quantity, rectangle.Length, rectangle.Width);
if (cache.TryGetValue(key, out var cached))
return cached;
if (rectangle.Area() < 3 * type.Area || attempts.GetValueOrDefault(type.Index) >= 8)
if (rectangle.Area() < 3 * type.Area || attempts.GetValueOrDefault(type.Index) >= 8 || counter.Value >= budget)
return Array.Empty<Placed>();
attempts[type.Index] = attempts.GetValueOrDefault(type.Index) + 1;
var result = Build(type, quantity, rectangle, token);
@@ -75,6 +79,11 @@ internal sealed class BlockCatalog : IDisposable
private IReadOnlyList<Placed> Build(PartType type, int quantity, Box rectangle, CancellationToken token)
{
// PrivatePlateFill.Run below is a full independent NFP-based pack whose own operations
// never touch this solve's effort meter; charge it here using the same factors - demand
// and outline complexity - that drive its real cost, so the shared budget actually sees it.
var vertices = type.Orientations.Count > 0 ? type.Orientations[0].Outline.Count : 1;
counter.Add((long)quantity * quantity * System.Math.Max(1, vertices));
if (!drawings.TryGetValue(type.Index, out var drawing))
drawings[type.Index] = drawing = DrawingJobMapper.CreateDrawing(type.Part);
try
@@ -48,7 +48,7 @@ public sealed class IrregularNestingEngine : INestingEngine
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
var types = PartCatalog.Build(job);
using var solver = new Solver(job, types, progress, token);
using var solver = new Solver(job, types, progress, token, WorkBudget);
// Pair-only orientations may fit stock even when the sampled single poses do not.
// Demand that neither a single nor a pair can fit is reported unplaced.
@@ -86,7 +86,8 @@ public sealed class IrregularNestingEngine : INestingEngine
NestJob job,
IReadOnlyList<PartType> types,
IProgress<NestJobProgress>? progress,
CancellationToken token
CancellationToken token,
long workBudget
) : IDisposable
{
public void Dispose()
@@ -104,7 +105,7 @@ public sealed class IrregularNestingEngine : INestingEngine
{
var spacing = System.Math.Max(0, stock.PartSpacing);
if (!blocks.TryGetValue(spacing, out var found))
blocks[spacing] = found = new BlockCatalog(spacing, types, PairsFor(stock));
blocks[spacing] = found = new BlockCatalog(spacing, types, PairsFor(stock), Work, workBudget);
return found;
}