using System; using System.Collections.Generic; using System.Linq; using System.Threading; namespace OpenNest.Engine.Jobs; /// /// D8/D9 last-resort clamp removal for the whole-job pipeline. After a normal clamped solve /// leaves demand unmet, finds parts that cannot fit anywhere with every clamp in place (C10), /// computes the fewest clamps whose removal lets each one fit (C11), and re-solves the /// residual demand on stock variants with those clamps taken off (C12). Never changes the /// result when nothing is blocked (C10's bit-identical guarantee) and never trades a removal /// for material savings - it only runs after the ordinary solve could not place a part. /// internal static class ClampRemovalRecovery { /// A result that replaces the caller's raw solve, and the expanded job it was /// 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 /// not let anything new fit. Callers keep their original job/result unchanged in every /// one of those cases (C10). /// internal static Outcome TryRecover( NestJob job, NestJobResult raw, INestingEngine engine, CancellationToken token ) { ArgumentNullException.ThrowIfNull(job); ArgumentNullException.ThrowIfNull(raw); ArgumentNullException.ThrowIfNull(engine); // Recovery is optional: any failure inside it (a throwing probe or residual solve) // keeps the caller's ordinary result instead of discarding it. Cancellation still // propagates. try { return Recover(job, raw, engine, token); } catch (Exception exception) when (exception is not OperationCanceledException) { token.ThrowIfCancellationRequested(); return null; } } private static Outcome Recover(NestJob job, NestJobResult raw, INestingEngine engine, CancellationToken token) { if (raw.Status == NestJobStatus.Complete) return null; var availableStock = job.Plates.Where(s => s.Quantity != 0).ToList(); if (!availableStock.Any(CanRemoveClamps)) return null; var partsById = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal); var remaining = raw.Fulfillment .Where(f => f.Unplaced > 0) .Select(f => (Part: partsById[f.PartId], Unplaced: f.Unplaced)) .ToList(); if (remaining.Count == 0) return null; token.ThrowIfCancellationRequested(); // 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, budget, token))) .Select(x => x.Part) .ToList(); if (blocked.Count == 0) return null; // Phase 2 state. Each kept sheet comes out of its base stock's remaining physical // pool and the plate cap (C12: a finite pool is never exceeded). var unmet = remaining.ToDictionary(x => x.Part.Id, x => x.Unplaced, StringComparer.Ordinal); var blockedIds = new HashSet(blocked.Select(p => p.Id), StringComparer.Ordinal); var pool = availableStock.ToDictionary( s => s.Id, s => s.Quantity is int q ? q - raw.StockUsage.Where(u => u.StockId == s.Id).Sum(u => u.Used) : (int?)null, StringComparer.Ordinal); int? platesLeft = job.Options.MaxPlates is int cap ? cap - raw.Plates.Count : null; var tried = blocked.ToDictionary(p => p.Id, _ => new HashSet(StringComparer.Ordinal), StringComparer.Ordinal); var recovered = new List<(NestPlateStock Variant, string BaseId, List Sheets)>(); // Rounds: each blocked part with demand left takes its best removal among the offered // stocks it has not tried that still have sheets, so a part whose preferred stock runs // out moves on to another stock (with that stock's own minimal removal). for (var round = 0; round < availableStock.Count && platesLeft is not <= 0; round++) { // C11: per blocked part, the fewest clamps (any set, nearest the origin on ties) // whose removal frees an empty sheet of one of those stocks. // Parts needing the same removal on the same stock share one variant. var groups = new List(); foreach (var part in blocked) { token.ThrowIfCancellationRequested(); if (unmet[part.Id] <= 0) continue; var usable = availableStock .Where(s => pool[s.Id] is not <= 0 && !tried[part.Id].Contains(s.Id)) .ToList(); if (usable.Count == 0) continue; var best = BestRemoval(part, usable, job.Options, lanes, budget, token); if (best == null) { // Unfit on all of them even with every clamp removed: stays unplaced. tried[part.Id].UnionWith(usable.Select(s => s.Id)); continue; } tried[part.Id].Add(best.BaseStockId); var group = groups.FirstOrDefault(g => g.Removal.Stock.Id == best.Stock.Id); if (group == null) groups.Add(group = new RemovalGroup(best)); group.BlockedIds.Add(part.Id); } if (groups.Count == 0) break; // One removal variant at a time, fewest removed clamps first: each variant takes // its own blocked parts plus any other unmet demand that is not blocked (freed // space may take other parts, C12). A sheet is kept only when it holds a part // that needed exactly this removal (C10/C11 per sheet). foreach (var group in groups.OrderBy(g => g.Removal.RemovedIds.Count)) { token.ThrowIfCancellationRequested(); var baseId = group.Removal.BaseStockId; var available = pool[baseId]; if (available <= 0 || platesLeft <= 0) continue; int? sheetCap = (available, platesLeft) switch { (int a, int p) => System.Math.Min(a, p), (int a, null) => a, (null, int p) => p, _ => null, }; var parts = remaining .Where(x => unmet[x.Part.Id] > 0 && (group.BlockedIds.Contains(x.Part.Id) || !blockedIds.Contains(x.Part.Id))) .Select(x => new NestJobPart(x.Part.Id, x.Part.Geometry, unmet[x.Part.Id], x.Part.Priority, x.Part.Rotation)) .ToList(); if (!parts.Any(p => group.BlockedIds.Contains(p.Id))) continue; var variant = Clone(group.Removal.Stock, group.Removal.Stock.Id, sheetCap, null); var options = new NestJobOptions(job.Options.PlacementStrategy, platesLeft, job.Options.SalvageRate, job.Options.MinimumSalvageDimension); var groupJob = new NestJob(parts, new[] { variant }, options); List kept = null; foreach (var lane in lanes) { token.ThrowIfCancellationRequested(); var solved = lane.Solve(groupJob, null, token); token.ThrowIfCancellationRequested(); kept = solved?.Plates .Where(sheet => sheet.Placements.Any(p => group.BlockedIds.Contains(p.PartId))) .Take(sheetCap ?? int.MaxValue) .ToList(); if (kept is { Count: > 0 }) break; } if (kept is not { Count: > 0 }) continue; foreach (var placement in kept.SelectMany(sheet => sheet.Placements)) unmet[placement.PartId]--; if (pool[baseId] is int left) pool[baseId] = left - kept.Count; if (platesLeft is int plates) platesLeft = plates - kept.Count; recovered.Add((group.Removal.Stock, baseId, kept)); } } if (recovered.Count == 0) return null; return Merge(job, raw, recovered); } private sealed class RemovalGroup(Removal removal) { public Removal Removal { get; } = removal; public HashSet BlockedIds { get; } = new(StringComparer.Ordinal); } /// /// Engines whose placement of a lone part proves it fits (C10): the caller's own engine, /// plus the built-in Irregular (NFP, true material) and Rectangles lanes. Default already /// runs both, so it is enough on its own. Phase 2 tries them in this order too. /// private static IReadOnlyList FeasibilityLanes(INestingEngine engine) { if (engine is NestingEngines.Default.DefaultNestingEngine) return new[] { engine }; var lanes = new List { engine }; if (engine is not NestingEngines.Irregular.IrregularNestingEngine) lanes.Add(new NestingEngines.Irregular.IrregularNestingEngine()); if (engine is not NestingEngines.Rectangles.RectanglesNestingEngine) lanes.Add(new NestingEngines.Rectangles.RectanglesNestingEngine()); return lanes; } 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, then the clamps 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; one probe per clamp then finds the clamps every /// removal must include; the set search 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 ) { var candidates = new List<(NestPlateStock Stock, List Zones, List Lanes)>(); foreach (var stock in stocks) { if (!CanRemoveClamps(stock)) continue; token.ThrowIfCancellationRequested(); // 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)); } // Removing more clamps never stops a part fitting, so a clamp the part cannot live // with even when it is the only one left must be in every feasible removal. var searches = new List<(NestPlateStock Stock, List Zones, List Lanes, List Mandatory)>(); foreach (var (stock, zones, fitting) in candidates) { var mandatory = new List(); for (var i = 0; i < zones.Count; i++) { token.ThrowIfCancellationRequested(); var onlyThis = Clone(stock, stock.Id, null, zones.Where((_, j) => j != i).Select(z => z.Id).ToList()); if (zones.Count == 1 || !fitting.Any(lane => FitsAlone(part, onlyThis, options, lane, budget, token))) mandatory.Add(i); } searches.Add((stock, zones, fitting, mandatory)); } // Then the fewest clamps overall (any set, not only neighbours: a notch can leave a // middle clamp in place), size by size across stocks in offered order; within a stock // the optional additions nearest the origin first. var maxSize = searches.Count == 0 ? 0 : searches.Max(c => c.Zones.Count); for (var size = 1; size <= maxSize; size++) { foreach (var (stock, zones, fitting, mandatory) in searches) { if (mandatory.Count > size || size > zones.Count) continue; var optional = Enumerable.Range(0, zones.Count).Where(i => !mandatory.Contains(i)).ToList(); foreach (var added in Combinations(optional, size - mandatory.Count)) { token.ThrowIfCancellationRequested(); var removedIds = mandatory.Concat(added).OrderBy(i => i).Select(i => zones[i].Id).ToList(); var candidate = Clone(stock, Suffixed(stock.Id, removedIds), null, removedIds); // Removing every clamp was already probed. if (size == zones.Count || fitting.Any(lane => FitsAlone(part, candidate, options, lane, budget, token))) return new Removal(candidate, stock.Id, removedIds); } } } return null; } /// Every -element subset of , lazily, /// in lexicographic order of positions. private static IEnumerable> Combinations(IReadOnlyList items, int count) { if (count < 0 || count > items.Count) yield break; var positions = Enumerable.Range(0, count).ToArray(); while (true) { yield return positions.Select(p => items[p]).ToList(); var k = count - 1; while (k >= 0 && positions[k] == items.Count - count + k) k--; if (k < 0) yield break; positions[k]++; for (var j = k + 1; j < count; j++) positions[j] = positions[j - 1] + 1; } } /// Only clamps resolved from a machine rail can be removed (see /// ): explicit zones have no clamp to name. private static bool CanRemoveClamps(NestPlateStock stock) => stock.ClampRail != null && stock.ClampZones.Count > 0; private static bool FitsAlone( NestJobPart part, NestPlateStock stock, NestJobOptions options, INestingEngine engine, ProbeBudget budget, CancellationToken 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, 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. 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, NestJobOptions options, INestingEngine engine, ProbeBudget budget, 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(); var probeOptions = new NestJobOptions(options.PlacementStrategy, 1, 0, 0); var probeJob = new NestJob(new[] { probePart }, probeStock, probeOptions); token.ThrowIfCancellationRequested(); var result = engine.Solve(probeJob, null, token); 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() { 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) => stock.Quantity == null ? stock : Clone(stock, stock.Id, null, null); /// /// A copy of with a new id/quantity and, when /// is given, those clamp zones dropped. Built from the /// stock's own sheet-frame zones, so the kept zones are bit-identical to the original's. /// private static NestPlateStock Clone( NestPlateStock stock, string id, int? quantity, IReadOnlyCollection removedIds ) { var sheetZones = stock.SheetClampZones .Where(z => removedIds == null || !removedIds.Contains(z.Id)) .ToList(); return new NestPlateStock( id, stock.Size, quantity, stock.PartSpacing, stock.EdgeSpacing, stock.Quadrant, stock.Cost, sheetZones ) { ClampRail = stock.ClampRail }; } private static string Suffixed(string stockId, IReadOnlyList removedIds) => $"{stockId}::removed:{string.Join(",", removedIds)}"; /// /// Combines the ordinary solve with the kept recovery sheets into one result, and the /// expanded job it validates against: the caller's stock with each finite base reduced /// by the sheets its variants took, plus each variant with exactly the sheets it used /// (unlimited when its base is), so the expanded inventory equals the physical one. /// Recovery placements get fresh instance indices continuing on from phase 1's. /// private static Outcome Merge( NestJob job, NestJobResult raw, IReadOnlyList<(NestPlateStock Variant, string BaseId, List Sheets)> recovered ) { // A variant used in more than one round is one stock entry with all its sheets. recovered = recovered .GroupBy(r => r.Variant.Id, StringComparer.Ordinal) .Select(g => (g.First().Variant, g.First().BaseId, g.SelectMany(r => r.Sheets).ToList())) .ToList(); var placedSoFar = raw.Fulfillment.ToDictionary(f => f.PartId, f => f.Placed, StringComparer.Ordinal); var nextPlateIndex = raw.Plates.Count; var reindexed = new List(); var variantStock = new List(); var takenFromBase = new Dictionary(StringComparer.Ordinal); foreach (var (variant, baseId, sheets) in recovered) { var baseStock = job.Plates.First(s => s.Id == baseId); var used = Clone(variant, variant.Id, baseStock.Quantity == null ? null : sheets.Count, null); variantStock.Add(used); takenFromBase[baseStock.Id] = takenFromBase.GetValueOrDefault(baseStock.Id) + sheets.Count; foreach (var sheet in sheets) { var placements = new List(sheet.Placements.Count); foreach (var pose in sheet.Placements) { var index = placedSoFar.GetValueOrDefault(pose.PartId); placedSoFar[pose.PartId] = index + 1; placements.Add(pose with { InstanceIndex = index }); } reindexed.Add(new NestJobPlateResult(nextPlateIndex++, used, placements)); } } var allPlates = raw.Plates.Concat(reindexed).ToList(); var fulfillment = job.Parts .Select(p => { var placed = placedSoFar.GetValueOrDefault(p.Id); return new PartFulfillment(p.Id, p.Quantity, placed, p.Quantity - placed); }) .ToList(); var used1 = new Dictionary(StringComparer.Ordinal); foreach (var u in raw.StockUsage) used1[u.StockId] = used1.GetValueOrDefault(u.StockId) + u.Used; var expandedStock = job.Plates .Select(s => s.Quantity is int q && takenFromBase.TryGetValue(s.Id, out var taken) ? Clone(s, s.Id, q - taken, null) : s) .Concat(variantStock) .ToList(); var stockUsage = expandedStock .Select(s => { var usedCount = used1.GetValueOrDefault(s.Id) + reindexed.Count(p => p.StockId == s.Id); return new StockUsage(s.Id, usedCount, s.Quantity is int q ? q - usedCount : null); }) .ToList(); var complete = fulfillment.All(f => f.Unplaced == 0); var status = complete ? NestJobStatus.Complete : raw.Status; var stopReason = complete ? NestJobStopReason.Completed : raw.StopReason; var expandedJob = new NestJob(job.Parts, expandedStock, job.Options); var result = new NestJobResult(status, stopReason, allPlates, fulfillment, stockUsage); return new Outcome(expandedJob, result); } }