diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs index 5b31db5..16187ff 100644 --- a/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs @@ -180,4 +180,43 @@ public class ClampRemovalRepairTests Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id)); Assert.All(result.Raw.Plates, sheet => Assert.Single(sheet.Stock.ClampZones)); } + private static int RemovedCount(NestJobPlateResult sheet) => 2 - sheet.Stock.ClampZones.Count; + + // PM f91e054a (C11): minimality holds per sheet. part-0 (22 wide) needs both clamps off, + // part-1 (14 wide) only clamp-1; the sheet holding only part-1 must keep clamp-2. + [Fact] + public void EachSheetRemovesOnlyTheClampsItsOwnPartsNeed() + { + var result = NestPipeline.Run(Request(Stock(), Item("widest", 22, 3, 1), Item("wide", 14, 3, 1))); + + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(2, result.Raw.Plates.Count); + foreach (var sheet in result.Raw.Plates) + { + var needsBoth = sheet.Placements.Any(p => p.PartId == "part-0"); + Assert.Equal(needsBoth ? 2 : 1, RemovedCount(sheet)); + if (!needsBoth) + Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2"); + } + } + + // PM 245a555f (C12): reduced-clamp sheets come out of the same finite pool. + [Theory] + [InlineData(3, false)] // three blocked parts, one physical sheet + [InlineData(1, true)] // the ordinary part already used the only sheet + public void RemovalNeverInventsSheetsBeyondTheFiniteStock(int wide, bool small) + { + var items = new List { Item("wide", 14, 3, wide) }; + if (small) + items.Add(Item("small", 2, 1, 1)); + + var result = NestPipeline.Run(Request(Stock(quantity: 1), items.ToArray())); + + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Single(result.Raw.Plates); + Assert.Equal(1, result.Raw.StockUsage.Sum(u => u.Used)); + 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)); + } } diff --git a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs index 00da7e7..86cef3e 100644 --- a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs +++ b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs @@ -80,63 +80,105 @@ internal static class ClampRemovalRecovery return null; // C11: per blocked part, the fewest clamps (smallest contiguous run, nearest the - // origin on ties) whose removal frees an empty sheet of some offered stock. - var recoveryVariants = new Dictionary(StringComparer.Ordinal); - var baseIdsNeedingRemoval = new HashSet(StringComparer.Ordinal); + // origin on ties) whose removal frees an empty sheet of some offered stock. Parts + // needing the same removal on the same stock share one variant. + var groups = new List(); foreach (var part in blocked) { token.ThrowIfCancellationRequested(); - var best = BestRemoval(part, availableStock, job.Options, engine, token); + var best = BestRemoval(part, availableStock, job.Options, lanes, token); if (best == null) continue; // Unfit even with every clamp removed: stays unplaced, as today. - recoveryVariants[best.Stock.Id] = best.Stock; - baseIdsNeedingRemoval.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 (recoveryVariants.Count == 0) + if (groups.Count == 0) return null; - // Phase 2: solve the whole residual demand (not only the blocked parts - freed space - // may also take other unmet parts, C12) on the untouched stock plus the reduced-clamp - // variants. A base stock that needed removal is not also offered full-clamp here, so - // a finite physical pool is never double-counted across both forms. - var usedByStock = raw.StockUsage.ToDictionary(u => u.StockId, u => u.Used, StringComparer.Ordinal); - var carryForward = availableStock - .Where(s => !baseIdsNeedingRemoval.Contains(s.Id)) - .Select(s => ResidualCopy(s, usedByStock)) - .Where(s => s.Quantity != 0); - var residualStock = carryForward.Concat(recoveryVariants.Values).ToList(); + // Phase 2, 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), and every kept sheet comes out of + // its base stock's remaining physical pool (C12: a finite pool is never exceeded). + var unmet = remaining.ToDictionary(x => x.Part.Id, x => x.Unplaced, StringComparer.Ordinal); + var blockedIds = new HashSet(groups.SelectMany(g => g.BlockedIds), 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; - int? residualMaxPlates = job.Options.MaxPlates is int cap - ? System.Math.Max(0, cap - raw.Plates.Count) - : null; - if (residualMaxPlates == 0) + var recovered = new List<(NestPlateStock Variant, string BaseId, List Sheets)>(); + 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; - var residualParts = remaining - .Select(x => new NestJobPart(x.Part.Id, x.Part.Geometry, x.Unplaced, x.Part.Priority, x.Part.Rotation)) - .ToList(); - var residualOptions = new NestJobOptions( - job.Options.PlacementStrategy, - residualMaxPlates, - job.Options.SalvageRate, - job.Options.MinimumSalvageDimension - ); - var residualJob = new NestJob(residualParts, residualStock, residualOptions); + return Merge(job, raw, recovered); + } - token.ThrowIfCancellationRequested(); - var raw2 = engine.Solve(residualJob, null, token); - token.ThrowIfCancellationRequested(); - if (raw2 == null || raw2.Plates.Count == 0) - return null; - - var merged = Merge(job, raw, recoveryVariants.Values, raw2); - return merged; + 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. + /// runs both, so it is enough on its own. Phase 2 tries them in this order too. /// private static IReadOnlyList FeasibilityLanes(INestingEngine engine) { @@ -150,22 +192,15 @@ internal static class ClampRemovalRecovery return lanes; } - /// Same stock, quantity reduced by what phase 1 already used (unlimited stays - /// unlimited); id/zones unchanged. Used for stock phase 2 may still draw from. - private static NestPlateStock ResidualCopy(NestPlateStock stock, IReadOnlyDictionary usedByStock) => - stock.Quantity is int quantity - ? Clone(stock, stock.Id, System.Math.Max(0, quantity - usedByStock.GetValueOrDefault(stock.Id)), null) - : stock; - private sealed record Removal(NestPlateStock Stock, string BaseStockId, IReadOnlyList RemovedIds); /// The (stock, removal) with the fewest removed clamps across every offered - /// stock that has any; ties prefer the stock listed earliest. + /// rail-backed stock; ties prefer the stock listed earliest. private static Removal BestRemoval( NestJobPart part, IReadOnlyList stocks, NestJobOptions options, - INestingEngine engine, + IReadOnlyList lanes, CancellationToken token ) { @@ -175,7 +210,7 @@ internal static class ClampRemovalRecovery if (!CanRemoveClamps(stock)) continue; token.ThrowIfCancellationRequested(); - var found = MinimalRemovalForStock(part, stock, options, engine, token); + var found = MinimalRemovalForStock(part, stock, options, lanes, token); if (found == null) continue; if (best == null || found.RemovedIds.Count < best.RemovedIds.Count) @@ -192,14 +227,14 @@ internal static class ClampRemovalRecovery /// /// 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 is both the fewest clamps and, among ties, the run - /// nearest the origin. + /// 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, - INestingEngine engine, + IReadOnlyList lanes, CancellationToken token ) { @@ -215,7 +250,7 @@ internal static class ClampRemovalRecovery 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 (FitsAlone(part, candidate, options, engine, token)) + if (lanes.Any(lane => FitsAlone(part, candidate, options, lane, token))) return new Removal(candidate, stock.Id, removedIds); } } @@ -239,8 +274,7 @@ internal static class ClampRemovalRecovery ) => Probe(part, stocks, options, engine, token); /// Whether this part, alone and at quantity one, places on an empty sheet of any - /// of the given stock entries. Uses the caller's own selected engine, so the answer - /// matches whatever lane/rotation handling Auto Nest would actually use. + /// of the given stock entries with the given engine. private static bool Probe( NestJobPart part, IReadOnlyList stocks, @@ -265,10 +299,8 @@ internal static class ClampRemovalRecovery /// /// A copy of with a new id/quantity and, when - /// is given, those clamp zones dropped. Stock carries its - /// zones already translated into the placement frame; they are translated back to plate - /// coordinates and re-translated forward so the result matches what - /// 's own constructor would produce from a plate. + /// 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, @@ -277,11 +309,8 @@ internal static class ClampRemovalRecovery IReadOnlyCollection removedIds ) { - var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length; - var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width; - var plateFrameZones = stock.ClampZones + var sheetZones = stock.SheetClampZones .Where(z => removedIds == null || !removedIds.Contains(z.Id)) - .Select(z => new ClampZone(z.Id, z.Bounds.Translate(-left, -bottom))) .ToList(); return new NestPlateStock( id, @@ -291,7 +320,7 @@ internal static class ClampRemovalRecovery stock.EdgeSpacing, stock.Quadrant, stock.Cost, - plateFrameZones + sheetZones ) { ClampRail = stock.ClampRail }; } @@ -300,31 +329,40 @@ internal static class ClampRemovalRecovery $"{stockId}::removed:{string.Join(",", removedIds)}"; /// - /// Combines the ordinary solve with the residual recovery solve into one result, and the - /// expanded job both were validated against (the caller's stock plus the recovery - /// variants actually used). Phase 2 placements get fresh instance indices continuing on - /// from phase 1's, so no requirement's instances collide across the two solves. + /// 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, - IEnumerable recoveryVariants, - NestJobResult raw2 + IReadOnlyList<(NestPlateStock Variant, string BaseId, List Sheets)> recovered ) { var placedSoFar = raw.Fulfillment.ToDictionary(f => f.PartId, f => f.Placed, StringComparer.Ordinal); var nextPlateIndex = raw.Plates.Count; - var reindexed = new List(raw2.Plates.Count); - foreach (var sheet in raw2.Plates) + var reindexed = new List(); + var variantStock = new List(); + var takenFromBase = new Dictionary(StringComparer.Ordinal); + foreach (var (variant, baseId, sheets) in recovered) { - var placements = new List(sheet.Placements.Count); - foreach (var pose in sheet.Placements) + 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 index = placedSoFar.GetValueOrDefault(pose.PartId); - placedSoFar[pose.PartId] = index + 1; - placements.Add(pose with { InstanceIndex = index }); + 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)); } - reindexed.Add(new NestJobPlateResult(nextPlateIndex++, sheet.Stock, placements)); } var allPlates = raw.Plates.Concat(reindexed).ToList(); @@ -336,16 +374,22 @@ internal static class ClampRemovalRecovery }) .ToList(); - var used = new Dictionary(StringComparer.Ordinal); + var used1 = new Dictionary(StringComparer.Ordinal); foreach (var u in raw.StockUsage) - used[u.StockId] = u.Used; - foreach (var sheet in reindexed) - used[sheet.StockId] = used.GetValueOrDefault(sheet.StockId) + 1; + used1[u.StockId] = used1.GetValueOrDefault(u.StockId) + u.Used; - var expandedStock = job.Plates.Concat(recoveryVariants).ToList(); + 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 => new StockUsage(s.Id, used.GetValueOrDefault(s.Id), - s.Quantity is int q ? q - used.GetValueOrDefault(s.Id) : null)) + .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);