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