feat(rectangles): repeat completed sheets from remaining demand

This commit is contained in:
aj committed 2026-10-12 12:53:59 -04:00
1 parent c1d5a827bc
commit 49017c4a91
5 files changed
+423 -4

No files matched your search

@@ -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<string, int> 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<string, int>(), 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<string, int>(StringComparer.Ordinal) { ["a"] = 20, ["A"] = 3, ["absent"] = 0 };
var layout = new Dictionary<string, int>(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<string, int> { ["a"] = int.MaxValue };
Assert.Equal(int.MaxValue, CompletedLayoutRepeat.Limit(remaining, new Dictionary<string, int> { ["a"] = 1 }, true, 1, null, null));
var q = CompletedLayoutRepeat.Limit(remaining, new Dictionary<string, int> { ["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<ArgumentException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(count, 2), true, 1, null, null));
Assert.Throws<ArgumentException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), new Dictionary<string, int> { ["unknown"] = 1 }, true, 1, null, null));
}
[Theory]
[InlineData(-1, null)]
[InlineData(null, -1)]
public void NegativeBoundsAreRefused(int? stock, int? cap)
{
Assert.Throws<ArgumentOutOfRangeException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 1, stock, cap));
}
}
@@ -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<RepeatBatch>? 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<PickMode>();
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<RepeatBatch>();
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<InvalidOperationException>(() => NestJobValidator.ValidateCandidate(candidate, job.Plates[0],
new Dictionary<string, int> { ["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);
}
@@ -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;
/// <summary>Physical accounting/continuation contract, run independently for each repeating engine.</summary>
public abstract class RepeatEngineSafetyTests
{
private protected abstract INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null);
[Theory]
[InlineData(5)]
[InlineData(1)]
public void MixedScarcityContinuesToDifferentResidualLayoutInOneSolve(int urgentCount)
{
var job = Mixed(urgentCount, 8);
var batches = new List<RepeatBatch>();
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<RepeatBatch>();
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<RepeatBatch>();
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<RepeatBatch>();
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<RepeatBatch>();
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<RepeatBatch>();
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<RepeatBatch>();
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<OperationCanceledException>(() => 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<NestJobProgress>();
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<OperationCanceledException>(() => 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<NestJobProgress>();
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<RepeatBatch>();
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<RepeatBatch>();
var packed = 0;
var progress = new InlineProgress(p =>
{
if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel();
});
Assert.ThrowsAny<OperationCanceledException>(() => 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<NestJobProgress> report) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value) => report(value);
}
}
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
namespace OpenNest.Engine.Jobs;
/// <summary>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.</summary>
internal static class CompletedLayoutRepeat
{
public static int Limit(
IReadOnlyDictionary<string, int> remaining,
IReadOnlyDictionary<string, int> 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;
}
}
/// <summary>Immutable observation of a decode decision, not production accounting.</summary>
internal sealed record RepeatBatch(string StockId, int Copies, int AvailableStockEntries, int SheetsBefore, bool ForcedFirst);
@@ -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<PickMode>? SheetPacked { get; init; }
internal Action<RepeatBatch>? BatchSelected { get; init; }
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? 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);