diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs index 436367d..e21b2e1 100644 --- a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs @@ -333,4 +333,30 @@ public class ClampRemovalRepairTests Assert.All(result.Raw.Plates.Where(p => p.StockId.Contains("removed")), p => Assert.Single(p.Stock.ClampZones)); Assert.Equal(2, result.Raw.StockUsage.Sum(u => u.Used)); } + // Delta review policy gate 3: identical offered stock (four 48x120 sheets with the same + // eight-clamp rail) must not multiply the probe count, or a moderate job of full-sheet + // parts runs out of budget although every part fits with its clamps removed. + [Fact] + public void IdenticalStockOptionsShareTheirProbes() + { + var rail = new ClampRail(1.5, 0.625, new[] { 6.0, 21.0, 36.0, 51.0, 66.0, 81.0, 96.0, 111.0 }); + var size = new Size(48, 120); + var stocks = Enumerable.Range(0, 4) + .Select(i => new NestPlateStock($"s{i}", size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) { ClampRail = rail }) + .ToArray(); + var parts = Enumerable.Range(0, 8) + .Select(i => JobBuilder.Rectangle($"full-{i}", w: 120, h: 48, count: 1, x: i * 1e-3)) + .ToArray(); + var job = new NestJob(parts, stocks); + var engine = new OpenNest.Engine.NestingEngines.Default.DefaultNestingEngine(); + var raw = engine.Solve(job); + Assert.Empty(raw.Plates); + + var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default); + + Assert.NotNull(outcome); + Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status); + Assert.Equal(8, outcome.Result.Plates.Count); + Assert.All(outcome.Result.Plates, sheet => Assert.Empty(sheet.Stock.ClampZones)); + } } diff --git a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs index 81bbf65..bb4e6c0 100644 --- a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs +++ b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs @@ -298,7 +298,9 @@ internal static class ClampRemovalRecovery ) => Probe(part, stocks, options, engine, budget, token); /// Whether this part, alone and at quantity one, places on an empty sheet of any - /// of the given stock entries with the given engine. Spends one probe from the budget. + /// of the given stock entries with the given engine. A question already answered for the + /// same part, engine and physically identical stock is reused; otherwise it spends one + /// probe from the budget. private static bool Probe( NestJobPart part, IReadOnlyList stocks, @@ -308,6 +310,9 @@ internal static class ClampRemovalRecovery CancellationToken token ) { + var key = (engine, part.Id, string.Join(";", stocks.Select(Signature).Distinct(StringComparer.Ordinal))); + if (budget.Answers.TryGetValue(key, out var known)) + return known; budget.Spend(); var probePart = new NestJobPart(part.Id, part.Geometry, 1, part.Priority, part.Rotation); var probeStock = stocks.Select(s => Unlimited(s)).ToList(); @@ -315,13 +320,30 @@ internal static class ClampRemovalRecovery var probeJob = new NestJob(new[] { probePart }, probeStock, probeOptions); token.ThrowIfCancellationRequested(); var result = engine.Solve(probeJob, null, token); - return result != null && result.Status == NestJobStatus.Complete; + var fits = result != null && result.Status == NestJobStatus.Complete; + budget.Answers[key] = fits; + return fits; + } + + /// Everything about a stock entry that can change whether a lone part fits: + /// sheet size, quadrant, spacings and the exact clamp zones (not its id, cost or + /// quantity). + private static string Signature(NestPlateStock stock) + { + var e = stock.EdgeSpacing; + var zones = string.Join(",", stock.SheetClampZones.Select(z => + FormattableString.Invariant($"{z.Id}:{z.Bounds.X:R}/{z.Bounds.Y:R}/{z.Bounds.Length:R}/{z.Bounds.Width:R}"))); + return FormattableString.Invariant( + $"{stock.Size.Width:R}x{stock.Size.Length:R}|q{stock.Quadrant}|{stock.PartSpacing:R}|{e.Left:R},{e.Bottom:R},{e.Right:R},{e.Top:R}|{zones}"); } private sealed class ProbeBudget(int solves) { private int left = solves; + /// Answers already paid for, by (engine, part, stock signatures). + public Dictionary<(INestingEngine, string, string), bool> Answers { get; } = new(); + /// Throws once spent; recovery /// then keeps the ordinary result. public void Spend()