diff --git a/OpenNest.Engine.Tests/Jobs/CompletedLayoutRepeatTests.cs b/OpenNest.Engine.Tests/Jobs/CompletedLayoutRepeatTests.cs new file mode 100644 index 0000000..bf7b162 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/CompletedLayoutRepeatTests.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.Tests.Jobs; + +public class CompletedLayoutRepeatTests +{ + private static Dictionary Counts(int a, int b) => new(StringComparer.Ordinal) { ["a"] = a, ["b"] = b }; + + [Theory] + [InlineData(null, null, 5)] + [InlineData(3, null, 3)] + [InlineData(null, 2, 2)] + [InlineData(4, 3, 3)] + [InlineData(0, null, 0)] + [InlineData(null, 0, 0)] + public void ScarcestRequirementAndEachPhysicalBoundClampTotalCopies(int? stock, int? cap, int expected) + { + Assert.Equal(expected, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 1, stock, cap)); + } + + [Theory] + [InlineData(false, 1)] + [InlineData(true, 2)] + public void UnknownSearchAndCompetingEntriesUseOne(bool completed, int entries) + { + Assert.Equal(1, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), completed, entries, null, null)); + } + + [Fact] + public void EmptyLayoutAndNoStockCannotProduceASheet() + { + Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(20, 12), new Dictionary(), true, 1, null, null)); + Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 0, null, null)); + } + + [Fact] + public void ExactCaseSensitiveIdsAndOnlyPresentRequirementsMatter() + { + var remaining = new Dictionary(StringComparer.Ordinal) { ["a"] = 20, ["A"] = 3, ["absent"] = 0 }; + var layout = new Dictionary(StringComparer.Ordinal) { ["a"] = 4, ["A"] = 1 }; + Assert.Equal(3, CompletedLayoutRepeat.Limit(remaining, layout, true, 1, null, null)); + } + + [Fact] + public void IntegerEdgeHasNoMultiplicationOverflowOrFirstCopyOffByOne() + { + var remaining = new Dictionary { ["a"] = int.MaxValue }; + Assert.Equal(int.MaxValue, CompletedLayoutRepeat.Limit(remaining, new Dictionary { ["a"] = 1 }, true, 1, null, null)); + var q = CompletedLayoutRepeat.Limit(remaining, new Dictionary { ["a"] = 2 }, true, 1, null, null); + Assert.Equal(1, checked(int.MaxValue - q * 2)); + Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(3, 1), Counts(4, 2), true, 1, null, null)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void InvalidCountsAndUnknownIdsAreRefused(int count) + { + Assert.Throws(() => CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(count, 2), true, 1, null, null)); + Assert.Throws(() => CompletedLayoutRepeat.Limit(Counts(20, 12), new Dictionary { ["unknown"] = 1 }, true, 1, null, null)); + } + + [Theory] + [InlineData(-1, null)] + [InlineData(null, -1)] + public void NegativeBoundsAreRefused(int? stock, int? cap) + { + Assert.Throws(() => CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 1, stock, cap)); + } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/RectanglesRepeatTests.cs b/OpenNest.Engine.Tests/NestingEngines/RectanglesRepeatTests.cs new file mode 100644 index 0000000..1b00d12 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/RectanglesRepeatTests.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.NestingEngines.Rectangles; +using OpenNest.Engine.RectanglePacking; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; + +namespace OpenNest.Engine.Tests.NestingEngines; + +public class RectanglesRepeatTests : RepeatEngineSafetyTests +{ + private protected override INestingEngine Engine(Action? packed = null, Action? batches = null) => + new RectanglesNestingEngine { SheetPacked = _ => packed?.Invoke(), BatchSelected = batches }; + + [Fact] + public void CompletedFullLayoutRemovesActualPackCallsInBothModes() + { + var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]); + var calls = new List(); + var result = new RectanglesNestingEngine { SheetPacked = calls.Add }.Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(10, result.Plates.Count); + Assert.Equal(6, calls.Count(m => m == PickMode.Global)); + Assert.Equal(6, calls.Count(m => m == PickMode.Ordered)); + 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 CandidateOutsideTheStricterCertificationBoundsRetainsOrdinaryAllocation() + { + // MaxRects/layout-check slack accepts this overhang; the candidate validator does not. + // This real Solve must pack five times rather than certifying a repeat from that layout. + var job = Job([Rectangle("a", 10.000006, 9, 5)], [Stock("s", 10, 10)]); + var batches = new List(); + var calls = 0; + var result = new RectanglesNestingEngine { SheetPacked = _ => calls++, BatchSelected = batches.Add }.Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(5, result.Plates.Count); + Assert.Equal(60, calls); + Assert.Equal(5, batches.Count); + Assert.All(batches, b => Assert.Equal(1, b.Copies)); + var candidate = new PlateCandidate(result.Plates[0].Placements); + Assert.Throws(() => NestJobValidator.ValidateCandidate(candidate, job.Plates[0], + new Dictionary { ["a"] = 5 }, job.Parts.ToDictionary(p => p.Id))); + } + + private static (string, double, double, double) Pose(NestJobPlacement p) => (p.PartId, p.X, p.Y, p.Rotation); +} diff --git a/OpenNest.Engine.Tests/NestingEngines/RepeatEngineSafetyTests.cs b/OpenNest.Engine.Tests/NestingEngines/RepeatEngineSafetyTests.cs new file mode 100644 index 0000000..fc7964c --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/RepeatEngineSafetyTests.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; + +namespace OpenNest.Engine.Tests.NestingEngines; + +/// Physical accounting/continuation contract, run independently for each repeating engine. +public abstract class RepeatEngineSafetyTests +{ + private protected abstract INestingEngine Engine(Action? packed = null, Action? batches = null); + + [Theory] + [InlineData(5)] + [InlineData(1)] + public void MixedScarcityContinuesToDifferentResidualLayoutInOneSolve(int urgentCount) + { + var job = Mixed(urgentCount, 8); + var batches = new List(); + var result = Engine(batches: batches.Add).Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(urgentCount, batches[0].Copies); + Assert.True(batches.Count > 1); + Assert.Equal(urgentCount, result.Fulfillment.Single(f => f.PartId == "a").Placed); + Assert.Equal(8, result.Fulfillment.Single(f => f.PartId == "b").Placed); + Assert.All(result.Plates.Take(urgentCount), p => Assert.Equal(new[] { "a", "b" }, p.Placements.Select(v => v.PartId))); + Assert.All(result.Plates.Skip(urgentCount), p => Assert.All(p.Placements, v => Assert.Equal("b", v.PartId))); + } + + [Fact] + public void SmallDemandThatFitsTheRemnantIsPlacedBeforeRepeating() + { + var job = Mixed(10, 10); + var batches = new List(); + var result = Engine(batches: batches.Add).Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(10, Assert.Single(batches).Copies); + Assert.All(result.Plates, p => Assert.Equal(new[] { "a", "b" }, p.Placements.Select(v => v.PartId))); + } + + [Theory] + [InlineData(2, null, NestJobStopReason.StockExhausted)] + [InlineData(null, 2, NestJobStopReason.PlateLimitReached)] + [InlineData(3, 2, NestJobStopReason.PlateLimitReached)] + public void FiniteStockAndMaxPlatesCountPhysicalCopies(int? stockCount, int? cap, NestJobStopReason reason) + { + var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2, quantity: stockCount)], new NestJobOptions(maxPlates: cap)); + var batches = new List(); + var result = Engine(batches: batches.Add).Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(reason, result.StopReason); + Assert.Equal(2, result.Plates.Count); + Assert.Equal(8, Assert.Single(result.Fulfillment).Placed); + Assert.Equal(2, Assert.Single(batches).Copies); + Assert.Equal(stockCount - 2, Assert.Single(result.StockUsage).Remaining); + } + + [Fact] + public void CompetingSameSizeStockEntriesCommitSinglyUntilOneRemains() + { + var job = Job([Rectangle("a", 9, 9, 16)], [Stock("one", 20, 20, spacing: 0.2, quantity: 2), Stock("two", 20, 20, spacing: 0.2, quantity: 2)]); + var batches = new List(); + var calls = 0; + var result = Engine(() => calls++, batches.Add).Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(new[] { 1, 1, 2 }, batches.Select(b => b.Copies)); + Assert.Equal(new[] { 2, 2, 1 }, batches.Select(b => b.AvailableStockEntries)); + Assert.All(result.StockUsage, s => Assert.Equal(2, s.Used)); + Assert.True(calls >= 5); // Two competing trials twice, then the genuinely sole remaining stock. + } + + [Fact] + public void SameNamedDrawingsRetainExactDistinctRequirementIds() + { + var a = new Drawing("same", Shapes.Rectangle(6, 9)); + var b = new Drawing("same", Shapes.Rectangle(3, 9)) { Priority = 1 }; + var job = Job([DrawingJobMapper.FromDrawing("a", a, 5), DrawingJobMapper.FromDrawing("A", b, 8)], [Stock("s", 10, 10, spacing: 0.2)]); + var result = Engine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "a").Placed); + Assert.Equal(8, result.Fulfillment.Single(f => f.PartId == "A").Placed); + Assert.Equal(0, a.Quantity.Nested); + Assert.Equal(0, b.Quantity.Nested); + } + + [Fact] + public void SalvageOffcutDoesNotPreventRepeatingCompletedJobSearch() + { + var stock = new NestPlateStock("s", new OpenNest.Geometry.Size(10, 10), null, 0.2, default, 1, cost: 20); + var job = Job([Rectangle("a", 6, 9, 10)], [stock], new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1)); + var batches = new List(); + var result = Engine(batches: batches.Add).Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(10, Assert.Single(batches).Copies); + Assert.Equal(10, result.Plates.Count); + Assert.Equal(162, NestJobCost.Evaluate(job, result), precision: 8); + Assert.True(NestJobCost.NetSheetCost(job, result.Plates[0]) < stock.Cost); + } + + [Fact] + public void OversizedRequirementRemainsUnplacedWhileUsefulDemandRepeats() + { + var job = Job([Rectangle("huge", 100, 100, 4), Rectangle("small", 6, 9, 5)], [Stock("s", 10, 10, spacing: 0.2)]); + var batches = new List(); + var result = Engine(batches: batches.Add).Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(4, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced); + Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "small").Placed); + Assert.Contains(batches, b => b.Copies == 5); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void NoFitOrUnavailableStockProducesNoEmptyRepeat(bool unavailable) + { + var job = Job([Rectangle("a", 100, 100, 2)], [Stock("s", 10, 10, quantity: unavailable ? 0 : null)]); + var batches = new List(); + var result = Engine(batches: batches.Add).Solve(job); + LayoutAssert.Valid(job, result); + Assert.Empty(result.Plates); + Assert.Empty(batches); + Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced); + } + + [Fact] + public void CancellationAtRepeatDecisionCannotReturnAProposal() + { + using var cancellation = new CancellationTokenSource(); + var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]); + var observed = 0; + var engine = Engine(batches: b => { Assert.Equal(10, b.Copies); observed++; cancellation.Cancel(); }); + Assert.Throws(() => engine.Solve(job, token: cancellation.Token)); + Assert.Equal(1, observed); + } + + [Theory] + [InlineData(1)] + [InlineData(10)] + public void CancellationDuringPhysicalExpansionIncludingLastCopyIsObserved(int cancelAfter) + { + using var cancellation = new CancellationTokenSource(); + var committed = new List(); + var progress = new InlineProgress(p => + { + if (p.Stage != NestJobStage.PlateCommitted) return; + committed.Add(p); + if (p.CommittedPlates == cancelAfter) cancellation.Cancel(); + }); + var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]); + Assert.Throws(() => Engine().Solve(job, progress, cancellation.Token)); + Assert.Equal(cancelAfter, committed.Count); + Assert.Equal(cancelAfter * 4, committed.Last().CommittedParts); + } + + [Fact] + public void SuccessfulProgressRemainsPhysicalAndOrdered() + { + var committed = new List(); + var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]); + var result = Engine().Solve(job, new InlineProgress(p => { if (p.Stage == NestJobStage.PlateCommitted) committed.Add(p); })); + LayoutAssert.Valid(job, result); + Assert.Equal(Enumerable.Range(1, 10), committed.Select(p => p.CommittedPlates)); + Assert.Equal(Enumerable.Range(1, 10).Select(i => i * 4), committed.Select(p => p.CommittedParts)); + } + + [Fact] + public void UnreadableGeometryIsNotTurnedIntoAnEmptyRepeat() + { + var job = Job([Part("unsupported", new OpenNest.CNC.Program(), 12)], [Stock("s", 10, 10)]); + var batches = new List(); + var result = Engine(batches: batches.Add).Solve(job); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(12, Assert.Single(result.Fulfillment).Unplaced); + Assert.Empty(result.Plates); + Assert.Empty(batches); + Assert.Equal(0, Assert.Single(result.StockUsage).Used); + } + + [Fact] + public void CancellationInsideTheRealSheetSearchNeverCertifiesARepeat() + { + using var cancellation = new CancellationTokenSource(); + var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]); + var batches = new List(); + var packed = 0; + var progress = new InlineProgress(p => + { + if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel(); + }); + Assert.ThrowsAny(() => Engine(() => packed++, batches.Add).Solve(job, progress, cancellation.Token)); + Assert.Equal(0, packed); + Assert.Empty(batches); + } + + private static NestJob Mixed(int a, int b) => Job( + [Part("a", Shapes.Rectangle(6, 9), a, RotationPolicy.Fixed(0)), Part("b", Shapes.Rectangle(3, 9), b, RotationPolicy.Fixed(0), priority: 1)], + [Stock("s", 10, 10, spacing: 0.2)]); + + private sealed class InlineProgress(Action report) : IProgress + { + public void Report(NestJobProgress value) => report(value); + } +} diff --git a/OpenNest.Engine/Jobs/CompletedLayoutRepeat.cs b/OpenNest.Engine/Jobs/CompletedLayoutRepeat.cs new file mode 100644 index 0000000..eb6e190 --- /dev/null +++ b/OpenNest.Engine/Jobs/CompletedLayoutRepeat.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace OpenNest.Engine.Jobs; + +/// Pure limits for total physical copies, including the first, using pre-commit demand. +/// Callers must validate the selected layout before allowing more than one copy. +internal static class CompletedLayoutRepeat +{ + public static int Limit( + IReadOnlyDictionary remaining, + IReadOnlyDictionary layoutCounts, + bool searchCompleted, + int availableStockEntries, + int? stockRemaining, + int? physicalRemaining) + { + ArgumentNullException.ThrowIfNull(remaining); + ArgumentNullException.ThrowIfNull(layoutCounts); + if (availableStockEntries < 0 || stockRemaining < 0 || physicalRemaining < 0) + throw new ArgumentOutOfRangeException(nameof(availableStockEntries), "Repeat bounds cannot be negative."); + if (layoutCounts.Count == 0 || availableStockEntries == 0) + return 0; + + var copies = int.MaxValue; + foreach (var (id, count) in layoutCounts) + { + if (count <= 0 || !remaining.TryGetValue(id, out var demand) || demand < 0) + throw new ArgumentException("Layout counts must be positive and reference exact remaining requirement IDs.", nameof(layoutCounts)); + copies = System.Math.Min(copies, demand / count); + } + if (stockRemaining is int stock) + copies = System.Math.Min(copies, stock); + if (physicalRemaining is int cap) + copies = System.Math.Min(copies, cap); + if (!searchCompleted || availableStockEntries != 1) + copies = System.Math.Min(copies, 1); + return copies; + } +} + +/// Immutable observation of a decode decision, not production accounting. +internal sealed record RepeatBatch(string StockId, int Copies, int AvailableStockEntries, int SheetsBefore, bool ForcedFirst); diff --git a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs index 347266a..046ae06 100644 --- a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs @@ -32,6 +32,10 @@ public sealed class RectanglesNestingEngine : INestingEngine private static readonly PickMode[] Modes = { PickMode.Global, PickMode.Ordered }; + // Instance-owned diagnostics; no process-global counters or mutable solver state. + internal Action? SheetPacked { get; init; } + internal Action? BatchSelected { get; init; } + public NestJobResult Solve( NestJob job, IProgress? progress = null, @@ -49,11 +53,13 @@ public sealed class RectanglesNestingEngine : INestingEngine remaining[t.Index] = 0; var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal); + var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal); var result = new NestJobResultBuilder(job, progress); NestJobStopReason reason; while (true) { + token.ThrowIfCancellationRequested(); if (remaining.All(r => r == 0)) { reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met. @@ -77,6 +83,7 @@ public sealed class RectanglesNestingEngine : INestingEngine foreach (var rule in Rules) { var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token); + SheetPacked?.Invoke(mode); if (plan.Parts.Count > 0) trials.Add((plan, NetCost(job, plan))); } @@ -90,10 +97,39 @@ public sealed class RectanglesNestingEngine : INestingEngine } var chosen = Choose(types, remaining, trials); - result.AddSheet(chosen.Stock, chosen.Poses()); - used[chosen.Stock.Id]++; - foreach (var p in chosen.Parts) - remaining[p.Type.Index]--; + var poses = chosen.Poses().ToArray(); + var demand = types.ToDictionary(t => t.Id, t => remaining[t.Index], StringComparer.Ordinal); + var counts = chosen.Parts.GroupBy(p => p.Type.Id, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal); + var availableEntries = job.Plates.Count(s => s.Quantity == null || used[s.Id] < s.Quantity); + // Both private MaxRects modes return only after their normal demand/no-fit search; + // neither returns partial/budget-limited output. This is not a geometric impossibility proof. + var copies = CompletedLayoutRepeat.Limit(demand, counts, searchCompleted: true, availableEntries, + chosen.Stock.Quantity - used[chosen.Stock.Id], job.Options.MaxPlates - result.SheetsUsed(job)); + if (copies > 1) + { + try + { + NestJobValidator.ValidateCandidate(new PlateCandidate(poses.Select(p => + new NestJobPlacement(p.PartId, 0, p.X, p.Y, p.Rotation))), chosen.Stock, demand, parts); + } + catch (InvalidOperationException) + { + // Keep the original one-sheet path if the candidate validator cannot certify + // reuse (including its stricter work-area tolerance). Final job validation still applies. + copies = 1; + } + } + BatchSelected?.Invoke(new RepeatBatch(chosen.Stock.Id, copies, availableEntries, result.SheetsUsed(job), false)); + for (var copy = 0; copy < copies; copy++) + { + token.ThrowIfCancellationRequested(); + result.AddSheet(chosen.Stock, poses); // Builder owns fresh placements/instance indices. + } + used[chosen.Stock.Id] = checked(used[chosen.Stock.Id] + copies); + foreach (var type in types) + if (counts.TryGetValue(type.Id, out var count)) + remaining[type.Index] = checked(remaining[type.Index] - checked(count * copies)); } return result.Build(reason);