mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-14 21:51:56 -04:00
feat(irregular): reuse completed plate layouts for repeat batches
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user