diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalCommitTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalCommitTests.cs new file mode 100644 index 0000000..3fc8fdb --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalCommitTests.cs @@ -0,0 +1,48 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// D8/C13: a plate Auto Nest commits with a last-resort clamp removal keeps following its +/// rail - minus the removed clamp - through later resizes, exactly like an ordinary +/// rail-following sheet (Slice 3), and the removal itself is real, inspectable plate state. +/// +public class ClampRemovalCommitTests +{ + [Fact] + public void CommittedPlateKeepsFollowingTheRailMinusTheRemovedClamp() + { + // Same geometry as ClampRemovalRecoveryTests.BlockedPartRemovesOnlyTheNearestOriginCoveredClamp: + // clamps 15" apart on a 23"-long, 3"-wide sheet; a 14"-wide part can only clear both by + // removing one, and "clamp-1" (nearest the origin) wins the tie. + var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3)); + var nest = new Nest(); + nest.Drawings.Add(drawing); + nest.PlateDefaults.Size = new Size(3, 23); + nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0); + nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 21.0 })); + nest.CreatePlate(); // empty template the commit fills + using var manager = new PlateManager(nest); + + var request = new NestPipelineRequest("Rectangles", + new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, + NestStockBuilder.SinglePlate(nest.Plates[0])); + var result = NestPipeline.Run(request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + + var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager); + var plate = Assert.Single(applied); + + Assert.Equal(new[] { "clamp-1" }, plate.RemovedClamps); + Assert.NotNull(plate.ClampRail); + Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-1"); + Assert.Contains(plate.ClampZones, z => z.Id == "clamp-2"); + Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes); // 1 clamp removed + + // C13: still follows the rail (minus the removal) through a resize/rotate. + plate.Size = new Size(3, 60); + Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-1"); + Assert.Contains(plate.ClampZones, z => z.Id == "clamp-2"); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs b/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs new file mode 100644 index 0000000..f78917b --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs @@ -0,0 +1,127 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Engine.NestingEngines.Rectangles; +using OpenNest.Engine.Tests.NestingEngines; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// D8/D9 last-resort clamp removal in the whole-job pipeline: a part blocked by clamps on +/// every offered stock gets the fewest clamps removed that let it fit (C10/C11), and the +/// residual solve is merged back into one result; everything else is untouched (C10's +/// bit-identical guarantee for an unblocked job). +/// +public class ClampRemovalRecoveryTests +{ + // Two clamps 15" apart on a 23"-long, 3"-wide sheet (no edge/part spacing, so the math + // below is exact): clamp-1 spans X [5.25, 6.75], clamp-2 spans X [20.25, 21.75]. A 3"-tall + // part exactly fills the sheet's Y extent, so it always sits at Y = 0 - through the clamp + // band - for whatever X it lands at. + private static ClampZone Clamp1() => new("clamp-1", new Box(5.25, 0, 1.5, 0.625)); + private static ClampZone Clamp2() => new("clamp-2", new Box(20.25, 0, 1.5, 0.625)); + + private static NestPlateStock ClampedSheet(params ClampZone[] zones) => + JobBuilder.Stock("sheet", width: 3, length: 23, clampZones: zones); + + private static readonly RectanglesNestingEngine Engine = new(); + + [Fact] + public void BlockedPartRemovesOnlyTheNearestOriginCoveredClamp() + { + // A 14"-wide part: with both clamps up, no X position in [0, 9] clears both (the gap + // between them is 13.5" < 14"), so it is genuinely blocked (C10), not merely unlucky. + var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1); + var stock = ClampedSheet(Clamp1(), Clamp2()); + var job = JobBuilder.Job(new[] { part }, new[] { stock }); + + var raw = Engine.Solve(job); + Assert.Equal(NestJobStatus.Incomplete, raw.Status); // sanity: today's solve fails + + var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default); + + Assert.NotNull(outcome); + Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status); + var sheet = Assert.Single(outcome.Result.Plates); + Assert.Single(sheet.Placements); + // Removing either clamp alone would free enough room; "clamp-1" is nearest the origin. + Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1"); + Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2"); + var fulfillment = Assert.Single(outcome.Result.Fulfillment); + Assert.Equal(0, fulfillment.Unplaced); + // The expanded job the result validates against offers the original stock plus + // exactly the reduced-clamp variant actually used. + Assert.Equal(2, outcome.Job.Plates.Count); + } + + [Fact] + public void RemovalFreesSpaceForOtherResidualDemandToo() + { + // C12: a sheet nested with a clamp removed may also take other residual parts. + var blocked = JobBuilder.Rectangle("wide", w: 14, h: 3, count: 1); + var filler = JobBuilder.Rectangle("filler", w: 2, h: 1, count: 1); + var stock = ClampedSheet(Clamp1(), Clamp2()); + var job = JobBuilder.Job(new[] { blocked, filler }, new[] { stock }); + + var raw = Engine.Solve(job); + var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default); + + Assert.NotNull(outcome); + Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status); + Assert.All(outcome.Result.Fulfillment, f => Assert.Equal(0, f.Unplaced)); + } + + [Fact] + public void AlreadyCompleteResultIsUntouched() + { + // C10: no blocked part exists, so recovery is a strict no-op (bit-identical to Slice 3). + var part = JobBuilder.Rectangle("p", w: 1, h: 1, count: 1); + var stock = ClampedSheet(Clamp1(), Clamp2()); + var job = JobBuilder.Job(new[] { part }, new[] { stock }); + var raw = Engine.Solve(job); + Assert.Equal(NestJobStatus.Complete, raw.Status); + + Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default)); + } + + [Fact] + public void NoClampsOfferedNeverRecovers() + { + var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1); + var stock = JobBuilder.Stock("sheet", width: 3, length: 10); // too short, no clamps + var job = JobBuilder.Job(new[] { part }, new[] { stock }); + var raw = Engine.Solve(job); + Assert.NotEqual(NestJobStatus.Complete, raw.Status); + + Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default)); + } + + [Fact] + public void PartBlockedOnlyByStockExhaustionIsNeverTreatedAsClampBlocked() + { + // The part fits a fresh clamped sheet on its own; only a single-sheet cap stops the + // second copy. That is a capacity limit, not a clamp block, so nothing is removed. + var part = JobBuilder.Rectangle("p", w: 12, h: 3, count: 2); + var stock = JobBuilder.Stock("sheet", width: 3, length: 23, quantity: 1, + clampZones: new[] { Clamp1(), Clamp2() }); + var job = JobBuilder.Job(new[] { part }, new[] { stock }); + + var raw = Engine.Solve(job); + Assert.Equal(NestJobStatus.Incomplete, raw.Status); + Assert.Equal(NestJobStopReason.StockExhausted, raw.StopReason); + + Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default)); + } + + [Fact] + public void UnfitEvenWithoutAnyClampStaysUnplaced() + { + // Wider than the whole sheet: removing every clamp still cannot make it fit. + var part = JobBuilder.Rectangle("p", w: 30, h: 3, count: 1); + var stock = ClampedSheet(Clamp1(), Clamp2()); + var job = JobBuilder.Job(new[] { part }, new[] { stock }); + var raw = Engine.Solve(job); + Assert.Equal(NestJobStatus.Incomplete, raw.Status); + + Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default)); + } +} diff --git a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs new file mode 100644 index 0000000..2c20831 --- /dev/null +++ b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs @@ -0,0 +1,313 @@ +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); + + /// + /// 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); + + if (raw.Status == NestJobStatus.Complete) + return null; + + var availableStock = job.Plates.Where(s => s.Quantity != 0).ToList(); + if (!availableStock.Any(s => s.ClampZones.Count > 0)) + 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". + var blocked = remaining + .Where(x => !FitsAnywhere(x.Part, availableStock, job.Options, engine, token)) + .Select(x => x.Part) + .ToList(); + if (blocked.Count == 0) + 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); + foreach (var part in blocked) + { + token.ThrowIfCancellationRequested(); + var best = BestRemoval(part, availableStock, job.Options, engine, 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); + } + if (recoveryVariants.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(); + + int? residualMaxPlates = job.Options.MaxPlates is int cap + ? System.Math.Max(0, cap - raw.Plates.Count) + : null; + if (residualMaxPlates == 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); + + 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; + } + + /// 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. + private static Removal BestRemoval( + NestJobPart part, + IReadOnlyList stocks, + NestJobOptions options, + INestingEngine engine, + CancellationToken token + ) + { + Removal best = null; + foreach (var stock in stocks) + { + if (stock.ClampZones.Count == 0) + continue; + token.ThrowIfCancellationRequested(); + var found = MinimalRemovalForStock(part, stock, options, engine, token); + if (found == null) + continue; + if (best == null || found.RemovedIds.Count < best.RemovedIds.Count) + best = found; + } + return best; + } + + /// + /// Ascending run size, then ascending start index: the first contiguous run of the + /// stock's own clamp zones (already ordered by rail position) whose removal lets the + /// part fit alone 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, + CancellationToken token + ) + { + var zones = stock.ClampZones; + 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 (FitsAlone(part, candidate, options, engine, token)) + return new Removal(candidate, stock.Id, removedIds); + } + } + return null; + } + + private static bool FitsAlone( + NestJobPart part, + NestPlateStock stock, + NestJobOptions options, + INestingEngine engine, + CancellationToken token + ) => Probe(part, new[] { stock }, options, engine, token); + + private static bool FitsAnywhere( + NestJobPart part, + IReadOnlyList stocks, + NestJobOptions options, + INestingEngine engine, + CancellationToken token + ) => 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. + private static bool Probe( + NestJobPart part, + IReadOnlyList stocks, + NestJobOptions options, + INestingEngine engine, + CancellationToken token + ) + { + 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); + return result != null && result.Status == NestJobStatus.Complete; + } + + /// 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. 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. + /// + private static NestPlateStock Clone( + NestPlateStock stock, + string id, + int? quantity, + 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 + .Where(z => removedIds == null || !removedIds.Contains(z.Id)) + .Select(z => new ClampZone(z.Id, z.Bounds.Translate(-left, -bottom))) + .ToList(); + return new NestPlateStock( + id, + stock.Size, + quantity, + stock.PartSpacing, + stock.EdgeSpacing, + stock.Quadrant, + stock.Cost, + plateFrameZones + ); + } + + private static string Suffixed(string stockId, IReadOnlyList removedIds) => + $"{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. + /// + private static Outcome Merge( + NestJob job, + NestJobResult raw, + IEnumerable recoveryVariants, + NestJobResult raw2 + ) + { + 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 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++, sheet.Stock, 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 used = 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; + + var expandedStock = job.Plates.Concat(recoveryVariants).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)) + .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); + } +} diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index b363327..0863533 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -176,6 +176,20 @@ public static class NestPipeline var solveTime = clock.Elapsed; token.ThrowIfCancellationRequested(); + // D8/C10: only the main (non-cutout-preview) path offers last-resort clamp removal. + // No-op whenever nothing is blocked, so an ordinary solve is unaffected (C10). + if (prepass == null) + { + var recovered = ClampRemovalRecovery.TryRecover(job, raw, engine, token); + if (recovered != null) + { + job = recovered.Job; + engineJob = recovered.Job; + raw = recovered.Result; + } + } + token.ThrowIfCancellationRequested(); + clock.Restart(); var names = drawingsByPartId.ToDictionary( kv => kv.Key, diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs index 85a1ae7..00f454e 100644 --- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs +++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; namespace OpenNest.Engine.Jobs; @@ -59,8 +60,9 @@ public static class NestPipelineCommit /// against, including sheets created for this commit. Stock zones live in the placement /// frame; plate zones in plate coordinates, so the stock's translation is inverted. Zones /// already equal in the placement frame are left untouched (no change events). Replaced - /// zones stay tied to the plate's clamp rail only when that rail recreates them; - /// otherwise a later size or quadrant assignment would silently swap in other clamps. + /// zones stay tied to the plate's clamp rail only when that rail recreates them (directly, + /// or minus whichever clamps Auto Nest removed as a last resort, D8/C13); otherwise a + /// later size or quadrant assignment would silently swap in other clamps. /// private static void SyncClampZones(Plate plate, NestPlateStock stock) { @@ -73,8 +75,19 @@ public static class NestPipelineCommit plate.SetClampRail(null); // explicit zones from here on foreach (var zone in stock.ClampZones) plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds.Translate(-left, -bottom))); - if (rail != null && rail.Matches(plate)) - plate.SetClampRail(rail); + if (rail == null) + return; + + // D8: the sheet's zones may be the rail's resolution for it minus clamps Auto Nest + // removed as a last resort; derive which (if any) so the committed plate keeps + // following the rail - and the removal - through later resizes (C13). + var kept = new HashSet(plate.ClampZones.Select(z => z.Id), StringComparer.Ordinal); + var removedClamps = rail.ResolveFor(plate.Size, plate.Quadrant) + .Select(z => z.Id) + .Where(id => !kept.Contains(id)) + .ToList(); + if (rail.Matches(plate, removedClamps)) + plate.SetClampRail(rail, removedClamps); } private static bool SameInPlacementFrame(