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()