From eda4a846f0cebe0fdf8b6b41f84d5180275ed7e7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 5 Oct 2026 07:57:24 -0400 Subject: [PATCH] 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 --- .../NestingEngines/IrregularBlockTests.cs | 10 +++++----- .../NestingEngines/Irregular/BlockCatalog.cs | 13 +++++++++++-- .../Irregular/IrregularNestingEngine.cs | 7 ++++--- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularBlockTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularBlockTests.cs index 92b79ef..c2327ef 100644 --- a/OpenNest.Engine.Tests/NestingEngines/IrregularBlockTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularBlockTests.cs @@ -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(() => catalog.Get(types[0], 3, new Box(0, 0, 20, 10), new CancellationToken(true))); } diff --git a/OpenNest.Engine/NestingEngines/Irregular/BlockCatalog.cs b/OpenNest.Engine/NestingEngines/Irregular/BlockCatalog.cs index 78c0991..2df13e3 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/BlockCatalog.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/BlockCatalog.cs @@ -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 drawings = new(); private readonly Dictionary> orientations = new(); private readonly Dictionary attempts = new(); @@ -24,9 +26,11 @@ internal sealed class BlockCatalog : IDisposable private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList> cache = new(); public BlockCatalog(double spacing, IReadOnlyList types, - IReadOnlyDictionary> pairs) + IReadOnlyDictionary> 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(); 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 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 diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index aeb106e..12c12ac 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -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 types, IProgress? 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; }