diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs
index 16187ff..3a1780e 100644
--- a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs
@@ -219,4 +219,64 @@ public class ClampRemovalRepairTests
Assert.Equal(1, result.Job.Plates.Sum(s => s.Quantity ?? int.MaxValue));
Assert.All(result.Raw.StockUsage, u => Assert.True(u.Remaining is null or >= 0));
}
+ /// Never places anything; counts how often recovery asks it to.
+ private sealed class CountingNoFitEngine : INestingEngine
+ {
+ public int Calls;
+
+ public NestJobResult Solve(NestJob job, IProgress progress = null,
+ CancellationToken token = default)
+ {
+ Calls++;
+ return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.NoPlacementFound,
+ Array.Empty(),
+ job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)).ToList(),
+ job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)).ToList());
+ }
+ }
+
+ private static NestPlateStock WideRailStock(string id, ClampRail rail)
+ {
+ var size = new Size(3, 23);
+ return new NestPlateStock(id, size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) { ClampRail = rail };
+ }
+
+ // PM 0e655fdb: parts that cannot fit even with every clamp removed are dismissed by one
+ // all-clamps-removed probe per stock instead of a search over every clamp run.
+ [Fact]
+ public void PartsThatNeverFitAreDismissedWithoutSearchingClampRuns()
+ {
+ var rail = new ClampRail(0.5, 0.625, Enumerable.Range(0, 20).Select(i => 0.5 + i * 1.1));
+ var stocks = Enumerable.Range(0, 4).Select(i => WideRailStock($"option-{i}", rail)).ToArray();
+ var parts = Enumerable.Range(0, 25).Select(i => JobBuilder.Rectangle($"p{i}", w: 30, h: 3, count: 1)).ToArray();
+ var job = new NestJob(parts, stocks);
+ var engine = new CountingNoFitEngine();
+ var raw = engine.Solve(job);
+ engine.Calls = 0;
+
+ var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
+
+ Assert.Null(outcome);
+ // One feasibility probe per part plus one all-clamps-removed probe per part and stock.
+ Assert.True(engine.Calls <= 25 + 25 * 4, $"recovery made {engine.Calls} solves");
+ }
+
+ // PM 0e655fdb: the search has a fixed budget; running out keeps the ordinary result.
+ [Fact]
+ public void RecoveryStopsAtItsProbeBudget()
+ {
+ // 23-wide parts fit only with all 20 clamps off: every smaller run is probed first.
+ var rail = new ClampRail(0.5, 0.625, Enumerable.Range(0, 20).Select(i => 0.5 + i * 1.1));
+ var stocks = Enumerable.Range(0, 4).Select(i => WideRailStock($"option-{i}", rail)).ToArray();
+ var parts = Enumerable.Range(0, 25).Select(i => JobBuilder.Rectangle($"p{i}", w: 23, h: 3, count: 1)).ToArray();
+ var job = new NestJob(parts, stocks);
+ var engine = new CountingNoFitEngine();
+ var raw = engine.Solve(job);
+ engine.Calls = 0;
+
+ var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
+
+ Assert.Null(outcome);
+ Assert.True(engine.Calls <= ClampRemovalRecovery.MaxProbeSolves, $"recovery made {engine.Calls} solves");
+ }
}
diff --git a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs
index 86cef3e..8a8c5e8 100644
--- a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs
+++ b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs
@@ -19,6 +19,13 @@ internal static class ClampRemovalRecovery
/// validated against (the caller's own stock plus the reduced-clamp variants used).
internal sealed record Outcome(NestJob Job, NestJobResult Result);
+ ///
+ /// Most engine solves one recovery may spend on feasibility probes, independent of job,
+ /// stock and rail size. Running out abandons the recovery and keeps the ordinary result
+ /// (no clamp is removed on an unfinished search).
+ ///
+ internal const int MaxProbeSolves = 1000;
+
///
/// Null when there is nothing to recover: the job is already complete, no offered stock
/// has any clamp, no remaining part is genuinely clamp-blocked, or removing clamps did
@@ -72,8 +79,9 @@ internal static class ClampRemovalRecovery
// C10: only a part that fits nowhere with every clamp in place is "blocked". One
// lane's failure is not proof: a part any built-in lane can place clamped is not.
var lanes = FeasibilityLanes(engine);
+ var budget = new ProbeBudget(MaxProbeSolves);
var blocked = remaining
- .Where(x => !lanes.Any(lane => FitsAnywhere(x.Part, availableStock, job.Options, lane, token)))
+ .Where(x => !lanes.Any(lane => FitsAnywhere(x.Part, availableStock, job.Options, lane, budget, token)))
.Select(x => x.Part)
.ToList();
if (blocked.Count == 0)
@@ -86,7 +94,7 @@ internal static class ClampRemovalRecovery
foreach (var part in blocked)
{
token.ThrowIfCancellationRequested();
- var best = BestRemoval(part, availableStock, job.Options, lanes, token);
+ var best = BestRemoval(part, availableStock, job.Options, lanes, budget, token);
if (best == null)
continue; // Unfit even with every clamp removed: stays unplaced, as today.
var group = groups.FirstOrDefault(g => g.Removal.Stock.Id == best.Stock.Id);
@@ -194,29 +202,56 @@ internal static class ClampRemovalRecovery
private sealed record Removal(NestPlateStock Stock, string BaseStockId, IReadOnlyList RemovedIds);
- /// The (stock, removal) with the fewest removed clamps across every offered
- /// rail-backed stock; ties prefer the stock listed earliest.
+ ///
+ /// The (stock, removal) with the fewest removed clamps across every offered rail-backed
+ /// stock; ties prefer the stock listed earliest, then the run nearest the origin. One
+ /// all-clamps-removed probe per stock first dismisses stock the part cannot use at all
+ /// and picks the lanes worth asking; the run search then goes size by size across the
+ /// remaining stock and stops at the first size that fits anywhere.
+ ///
private static Removal BestRemoval(
NestJobPart part,
IReadOnlyList stocks,
NestJobOptions options,
IReadOnlyList lanes,
+ ProbeBudget budget,
CancellationToken token
)
{
- Removal best = null;
+ var candidates = new List<(NestPlateStock Stock, List Zones, List Lanes)>();
foreach (var stock in stocks)
{
if (!CanRemoveClamps(stock))
continue;
token.ThrowIfCancellationRequested();
- var found = MinimalRemovalForStock(part, stock, options, lanes, token);
- if (found == null)
- continue;
- if (best == null || found.RemovedIds.Count < best.RemovedIds.Count)
- best = found;
+ // Placement-frame X is signed from the origin, so |centre X| is the distance
+ // along the edge in every quadrant.
+ var zones = stock.ClampZones
+ .OrderBy(z => System.Math.Abs(z.Bounds.X + z.Bounds.Length / 2))
+ .ToList();
+ var allRemoved = Clone(stock, stock.Id, null, zones.Select(z => z.Id).ToList());
+ var fitting = lanes.Where(lane => FitsAlone(part, allRemoved, options, lane, budget, token)).ToList();
+ if (fitting.Count > 0)
+ candidates.Add((stock, zones, fitting));
}
- return best;
+
+ var maxRun = candidates.Count == 0 ? 0 : candidates.Max(c => c.Zones.Count);
+ for (var run = 1; run <= maxRun; run++)
+ {
+ foreach (var (stock, zones, fitting) in candidates)
+ {
+ for (var start = 0; start + run <= zones.Count; start++)
+ {
+ token.ThrowIfCancellationRequested();
+ var removedIds = zones.Skip(start).Take(run).Select(z => z.Id).ToList();
+ var candidate = Clone(stock, Suffixed(stock.Id, removedIds), null, removedIds);
+ // Removing every clamp was already probed.
+ if (run == zones.Count || fitting.Any(lane => FitsAlone(part, candidate, options, lane, budget, token)))
+ return new Removal(candidate, stock.Id, removedIds);
+ }
+ }
+ }
+ return null;
}
/// Only clamps resolved from a machine rail can be removed (see
@@ -224,65 +259,36 @@ internal static class ClampRemovalRecovery
private static bool CanRemoveClamps(NestPlateStock stock) =>
stock.ClampRail != null && stock.ClampZones.Count > 0;
- ///
- /// Ascending run size, then ascending start index: the first contiguous run of the
- /// stock's own clamp zones, ordered by distance from the origin along the edge, whose
- /// removal lets the part fit alone (in any lane) is both the fewest clamps and, among
- /// ties, the run nearest the origin.
- ///
- private static Removal MinimalRemovalForStock(
- NestJobPart part,
- NestPlateStock stock,
- NestJobOptions options,
- IReadOnlyList lanes,
- CancellationToken token
- )
- {
- // Placement-frame X is signed from the origin, so |centre X| is the distance along
- // the edge in every quadrant.
- var zones = stock.ClampZones
- .OrderBy(z => System.Math.Abs(z.Bounds.X + z.Bounds.Length / 2))
- .ToList();
- for (var run = 1; run <= zones.Count; run++)
- {
- for (var start = 0; start + run <= zones.Count; start++)
- {
- token.ThrowIfCancellationRequested();
- var removedIds = zones.Skip(start).Take(run).Select(z => z.Id).ToList();
- var candidate = Clone(stock, Suffixed(stock.Id, removedIds), null, removedIds);
- if (lanes.Any(lane => FitsAlone(part, candidate, options, lane, token)))
- return new Removal(candidate, stock.Id, removedIds);
- }
- }
- return null;
- }
-
private static bool FitsAlone(
NestJobPart part,
NestPlateStock stock,
NestJobOptions options,
INestingEngine engine,
+ ProbeBudget budget,
CancellationToken token
- ) => Probe(part, new[] { stock }, options, engine, token);
+ ) => Probe(part, new[] { stock }, options, engine, budget, token);
private static bool FitsAnywhere(
NestJobPart part,
IReadOnlyList stocks,
NestJobOptions options,
INestingEngine engine,
+ ProbeBudget budget,
CancellationToken token
- ) => Probe(part, stocks, options, engine, token);
+ ) => 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.
+ /// of the given stock entries with the given engine. Spends one probe from the budget.
private static bool Probe(
NestJobPart part,
IReadOnlyList stocks,
NestJobOptions options,
INestingEngine engine,
+ ProbeBudget budget,
CancellationToken token
)
{
+ budget.Spend();
var probePart = new NestJobPart(part.Id, part.Geometry, 1, part.Priority, part.Rotation);
var probeStock = stocks.Select(s => Unlimited(s)).ToList();
var probeOptions = new NestJobOptions(options.PlacementStrategy, 1, 0, 0);
@@ -292,6 +298,22 @@ internal static class ClampRemovalRecovery
return result != null && result.Status == NestJobStatus.Complete;
}
+ private sealed class ProbeBudget(int solves)
+ {
+ private int left = solves;
+
+ /// Throws once spent; recovery
+ /// then keeps the ordinary result.
+ public void Spend()
+ {
+ if (left-- <= 0)
+ throw new ProbeBudgetExhaustedException();
+ }
+ }
+
+ private sealed class ProbeBudgetExhaustedException()
+ : Exception("Clamp removal recovery ran out of feasibility probes.");
+
/// Same stock with unlimited quantity (feasibility probes never consume real
/// inventory); id/zones unchanged.
private static NestPlateStock Unlimited(NestPlateStock stock) =>