feat(irregular): reuse completed plate layouts for repeat batches

This commit is contained in:
aj committed 2026-10-12 12:54:15 -04:00
1 parent 49017c4a91
commit 522de2761c
3 files changed
+190 -9

No files matched your search

@@ -0,0 +1,92 @@
using System.Linq;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularRepeatTests : RepeatEngineSafetyTests
{
private protected override INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null) =>
new IrregularNestingEngine { WorkBudget = 0, SheetPacked = packed, BatchSelected = batches };
[Fact]
public void CompletedFullLayoutRemovesActualFrontierFillCallsIncludingTailTrials()
{
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var calls = 0;
var result = new IrregularNestingEngine { SheetPacked = () => calls++ }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(10, result.Plates.Count);
Assert.Equal(24, calls); // Six main variants and six trials for each physical tail of 1..3 sheets.
var first = result.Plates[0];
foreach (var plate in result.Plates.Skip(1))
{
Assert.NotSame(first.Placements, plate.Placements);
Assert.Equal(first.Placements.Select(Pose), plate.Placements.Select(Pose));
Assert.NotSame(first.Placements[0], plate.Placements[0]);
}
}
[Fact]
public void SeededPhysicalTailReturnsFiniteStockAndReconsumesItWithoutDroppingRepeatedPrefix()
{
// A lifecycle test, not a claim that public Solve chooses this deliberately wasteful
// incumbent. Four identical full copies precede two half-full physical tail copies.
var job = Job([Rectangle("a", 9, 9, 20)], [Stock("s", 20, 20, spacing: 0.2, quantity: 6)]);
var orientation = Assert.Single(Assert.Single(PartCatalog.Build(job)).Orientations);
var full = new[] { new Placed(orientation, 0, 0), new Placed(orientation, 9.2, 0),
new Placed(orientation, 0, 9.2), new Placed(orientation, 9.2, 9.2) };
var seed = Enumerable.Range(0, 4).Select(_ => new SheetFill(job.Plates[0], full.ToArray(), 324))
.Concat(Enumerable.Range(0, 2).Select(_ => new SheetFill(job.Plates[0], full.Take(2).ToArray(), 162))).ToArray();
var incumbent = new NestJobResultBuilder(job);
foreach (var sheet in seed)
incumbent.AddSheet(sheet.Stock, sheet.Parts.Select(p => ("a", p.X, p.Y, p.Orientation.Rotation)));
var before = incumbent.Build(NestJobStopReason.Completed);
LayoutAssert.Valid(job, before);
var replaced = new List<int>();
var batches = new List<RepeatBatch>();
var result = new IrregularNestingEngine { TailReplaced = replaced.Add, BatchSelected = batches.Add }
.ImproveTailForTesting(job, seed);
LayoutAssert.Valid(job, result);
Assert.Equal(new[] { 2 }, replaced);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(5, result.Plates.Count);
Assert.Equal(20, Assert.Single(result.Fulfillment).Placed);
Assert.Equal((5, 1), (Assert.Single(result.StockUsage).Used, Assert.Single(result.StockUsage).Remaining));
Assert.Equal(2000, NestJobCost.Evaluate(job, result), precision: 8);
Assert.True(NestJobCost.Evaluate(job, result) < NestJobCost.Evaluate(job, before));
for (var i = 0; i < 4; i++)
Assert.Equal(before.Plates[i].Placements.Select(Pose), result.Plates[i].Placements.Select(Pose));
Assert.All(result.Plates, p => Assert.Equal(4, p.Placements.Count));
Assert.Equal(Enumerable.Range(0, 20), result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex));
Assert.All(seed.Take(4), s => Assert.Equal(full, s.Parts)); // The incumbent was not mutated.
Assert.Contains(batches, b => b.ForcedFirst && b.Copies > 1); // The retained physical tail can still repeat.
}
[Fact]
public void ForcedFirstStockCountsAllAvailableEntriesAndResumesAlternatives()
{
var job = Job([Rectangle("a", 9, 9, 16)],
[Stock("one", 20, 20, spacing: 0.2, quantity: 10), Stock("two", 20, 20, spacing: 0.2, quantity: 10)]);
var batches = new List<RepeatBatch>();
var result = new IrregularNestingEngine { BatchSelected = batches.Add }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
var forced = batches.Where(b => b.ForcedFirst).ToArray();
Assert.NotEmpty(forced);
Assert.All(forced, b =>
{
Assert.Equal(2, b.AvailableStockEntries);
Assert.Equal(1, b.Copies);
});
// Within a multi-sheet Decode forced to start on "two", the next decision can
// choose "one". A trial-subset count of one would incorrectly batch that first sheet.
Assert.Contains(Enumerable.Range(0, batches.Count - 1), i =>
batches[i] is { ForcedFirst: true, StockId: "two" }
&& batches[i + 1] is { ForcedFirst: false, StockId: "one", SheetsBefore: 1 });
}
private static (string, double, double, double) Pose(NestJobPlacement p) => (p.PartId, p.X, p.Y, p.Rotation);
}
@@ -39,6 +39,11 @@ public sealed class IrregularNestingEngine : INestingEngine
/// </summary>
internal long WorkBudget { get; init; } = 1_500_000;
// Instance-owned diagnostics; no process-global counters or mutable solver state.
internal Action? SheetPacked { get; init; }
internal Action<RepeatBatch>? BatchSelected { get; init; }
internal Action<int>? TailReplaced { get; init; }
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
@@ -48,7 +53,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, WorkBudget);
using var solver = new Solver(job, types, progress, token, WorkBudget, SheetPacked, BatchSelected, TailReplaced);
// 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.
@@ -78,7 +83,19 @@ public sealed class IrregularNestingEngine : INestingEngine
// The last sheets hold the leftovers, which is where waste concentrates; re-plan them.
best = solver.ImproveTail(best!, WorkBudget * 2);
return BuildResult(job, types, best, progress);
return BuildResult(job, types, best, progress, token);
}
/// <summary>Exercises the production physical-tail lifecycle from a test-owned incumbent.
/// This does not alter normal Solve or expose a public packing API.</summary>
internal NestJobResult ImproveTailForTesting(NestJob job, IReadOnlyList<SheetFill> physicalPlan)
{
var types = PartCatalog.Build(job);
using var solver = new Solver(job, types, null, default, WorkBudget, SheetPacked, BatchSelected, TailReplaced);
var unplaced = job.Parts.Sum(p => p.Quantity) - physicalPlan.Sum(s => s.Parts.Count);
var plan = new Plan(physicalPlan, physicalPlan.Sum(s => NetCost(job.Options, s)), unplaced,
unplaced == 0 ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound, NestJobCost.UsesExplicitCosts(job));
return BuildResult(job, types, solver.ImproveTail(plan, WorkBudget * 2), null, default);
}
/// <summary>Shared state for one solve: job, catalog, NFP caches, effort meter.</summary>
@@ -87,7 +104,10 @@ public sealed class IrregularNestingEngine : INestingEngine
IReadOnlyList<PartType> types,
IProgress<NestJobProgress>? progress,
CancellationToken token,
long workBudget
long workBudget,
Action? sheetPacked,
Action<RepeatBatch>? batchSelected,
Action<int>? tailReplaced
) : IDisposable
{
public void Dispose()
@@ -100,6 +120,7 @@ public sealed class IrregularNestingEngine : INestingEngine
private readonly Dictionary<double, NoFitCache> caches = new();
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
private readonly Dictionary<double, BlockCatalog> blocks = new();
private readonly IReadOnlyDictionary<string, NestJobPart> parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
private BlockCatalog BlocksFor(NestPlateStock stock)
{
@@ -164,6 +185,7 @@ public sealed class IrregularNestingEngine : INestingEngine
continue;
sheets = prefix.Concat(bestRun.Sheets).ToList();
plan = plan with { Sheets = sheets.ToList(), Cost = plan.Cost - (tailNet - bestRun.Net) };
tailReplaced?.Invoke(k);
}
return plan;
}
@@ -222,6 +244,7 @@ public sealed class IrregularNestingEngine : INestingEngine
while (true)
{
token.ThrowIfCancellationRequested();
if (remaining.All(r => r == 0))
{
reason = NestJobStopReason.Completed;
@@ -244,6 +267,7 @@ public sealed class IrregularNestingEngine : INestingEngine
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
var fill = packer.Fill(remaining, token);
sheetPacked?.Invoke();
if (fill.Parts.Count > 0)
trials.Add((fill, NetCost(job.Options, fill)));
}
@@ -266,11 +290,42 @@ public sealed class IrregularNestingEngine : INestingEngine
.ThenBy(t => t.order)
.First();
sheets.Add(chosen.Fill);
net = NestJobCost.RequireFinite(net + chosen.Net);
used[chosen.Fill.Stock.Id]++;
foreach (var part in chosen.Fill.Parts)
remaining[part.Orientation.TypeIndex]--;
var outstanding = types.ToDictionary(t => t.Part.Id, t => remaining[t.Index], StringComparer.Ordinal);
var counts = chosen.Fill.Parts.GroupBy(p => types[p.Orientation.TypeIndex].Part.Id, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
// Count all stock still available, not just the forced-first trial subset.
var availableEntries = job.Plates.Count(s => s.Quantity == null || used[s.Id] < s.Quantity);
// Fill has no partial/budget exit: it returns after states empty or Choose finds
// no supported placement. WorkBudget only controls later variants/block preparation.
var copies = CompletedLayoutRepeat.Limit(outstanding, counts, searchCompleted: true, availableEntries,
chosen.Fill.Stock.Quantity - used[chosen.Fill.Stock.Id], sheetCap - sheets.Count);
if (copies > 1)
{
try
{
NestJobValidator.ValidateCandidate(new PlateCandidate(chosen.Fill.Parts.Select(p =>
new NestJobPlacement(types[p.Orientation.TypeIndex].Part.Id, 0, p.X, p.Y, p.Orientation.Rotation))),
chosen.Fill.Stock, outstanding, parts);
}
catch (InvalidOperationException)
{
copies = 1; // Uncertified candidates retain ordinary allocation/final validation.
}
}
batchSelected?.Invoke(new RepeatBatch(chosen.Fill.Stock.Id, copies, availableEntries, sheets.Count,
sheets.Count == 0 && first != null));
for (var copy = 0; copy < copies; copy++)
{
token.ThrowIfCancellationRequested();
// Poses are immutable records over the read-only catalog; each physical sheet
// owns a separate list so tail replanning can remove/replace individual copies.
sheets.Add(chosen.Fill with { Parts = chosen.Fill.Parts.ToList() });
net = NestJobCost.RequireFinite(net + chosen.Net);
}
used[chosen.Fill.Stock.Id] = checked(used[chosen.Fill.Stock.Id] + copies);
foreach (var type in types)
if (counts.TryGetValue(type.Part.Id, out var count))
remaining[type.Index] = checked(remaining[type.Index] - checked(count * copies));
}
return new Run(sheets, net, reason);
@@ -280,12 +335,16 @@ public sealed class IrregularNestingEngine : INestingEngine
private sealed record Run(IReadOnlyList<SheetFill> Sheets, double Net, NestJobStopReason Reason);
private static NestJobResult BuildResult(NestJob job, IReadOnlyList<PartType> types,
Plan plan, IProgress<NestJobProgress>? progress)
Plan plan, IProgress<NestJobProgress>? progress, CancellationToken token)
{
var builder = new NestJobResultBuilder(job, progress);
foreach (var sheet in plan.Sheets)
{
token.ThrowIfCancellationRequested();
builder.AddSheet(sheet.Stock, sheet.Parts.Select(p =>
(types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation)));
}
token.ThrowIfCancellationRequested();
return builder.Build(plan.Reason);
}
+30
View File
@@ -67,6 +67,36 @@ or invalid Fill proposal leaves singles and pairs available. Large enclosed-pock
pending the hole-geometry integration; containment cutting order and shop-use safety acceptance
remain separate sequencer/verification work.
## Repeating completed layouts
Rectangles and Irregular can skip repeated sheet searches after selecting one nonempty layout
whose normal sheet search has completed. The repeat count includes the first physical copy and
is limited by the scarcest requirement on that layout, remaining finite stock and the remaining
physical `MaxPlates` allowance. Parts absent from the layout keep their demand. A salvage-worthy
offcut does not prevent repetition: completion means the engine found no further supported
requested placement, not that no geometric packing exists.
Fast-forwarding applies only while exactly one stock entry remains available. Different stock
IDs count as alternatives even at identical dimensions. Irregular's forced-first-stock tail trial
still counts **all** remaining stock entries, not just that trial's restricted shortlist; it commits
the forced first sheet once when alternatives remain and resumes ordinary stock selection.
Candidates that cannot pass the existing candidate validator retain ordinary one-sheet allocation.
Cancellation never certifies a layout and is checked during physical-copy expansion.
Both engines continue automatically with residual demand in the same solve, including when a
layout's repeat count is one. There is no per-layout stop, confirmation or opt-in mode. Rectangles
retains both Global/Ordered packing modes and its priority guard. Irregular retains demand-dependent
singles, pairs and blocks, variant ordering and its existing work-budget semantics; saved work can
allow additional variants to run, so this is not a promise of historical pose equality.
Raw `NestJobResult` output remains one entry per physical sheet, with fresh placements and unique
instance indices, physical stock usage and physical progress counts. Irregular also retains each
physical copy in its working plan: its last-one-to-three-sheet improvement can return the removed
tail's finite stock, repack that demand, and replace just those copies while keeping the prefix.
Default inherits these fast paths by running the two engines; other engines and external plug-ins
keep their existing search behavior. Quantity-bearing presentation is a separate validated-pipeline
representation; repeating does not bypass whole-job validation or change cutting programs.
## Filling cutouts (not yet in production)
Placing parts inside another part's enclosed cutout is being built as a step that runs before any