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(