diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularRepeatTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularRepeatTests.cs new file mode 100644 index 0000000..344a317 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularRepeatTests.cs @@ -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? 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(); + var batches = new List(); + 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(); + 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); +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index 2033c19..55dcf08 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -39,6 +39,11 @@ public sealed class IrregularNestingEngine : INestingEngine /// 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? BatchSelected { get; init; } + internal Action? TailReplaced { get; init; } + public NestJobResult Solve( NestJob job, IProgress? 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); + } + + /// Exercises the production physical-tail lifecycle from a test-owned incumbent. + /// This does not alter normal Solve or expose a public packing API. + internal NestJobResult ImproveTailForTesting(NestJob job, IReadOnlyList 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); } /// Shared state for one solve: job, catalog, NFP caches, effort meter. @@ -87,7 +104,10 @@ public sealed class IrregularNestingEngine : INestingEngine IReadOnlyList types, IProgress? progress, CancellationToken token, - long workBudget + long workBudget, + Action? sheetPacked, + Action? batchSelected, + Action? tailReplaced ) : IDisposable { public void Dispose() @@ -100,6 +120,7 @@ public sealed class IrregularNestingEngine : INestingEngine private readonly Dictionary caches = new(); private readonly Dictionary>> pairs = new(); private readonly Dictionary blocks = new(); + private readonly IReadOnlyDictionary 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 Sheets, double Net, NestJobStopReason Reason); private static NestJobResult BuildResult(NestJob job, IReadOnlyList types, - Plan plan, IProgress? progress) + Plan plan, IProgress? 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); } diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 4d96c79..066e1a1 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -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