diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneCommitTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneCommitTests.cs
new file mode 100644
index 00000000..db1e8d07
--- /dev/null
+++ b/OpenNest.Engine.Tests/Jobs/ClampZoneCommitTests.cs
@@ -0,0 +1,110 @@
+using OpenNest.Engine.Jobs;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Tests.Jobs;
+
+///
+/// A committed live plate must carry exactly the clamp keep-outs its layout was solved and
+/// validated against, in plate coordinates, including sheets the commit had to create.
+///
+public class ClampZoneCommitTests
+{
+ // A 1.5 x 0.625 clamp on the front edge of a 48 x 96 sheet (Length 96 runs along X).
+ private const double ZoneX = 5.25;
+
+ private static ClampZone Zone() => new("clamp-1", new Box(ZoneX, 0, 1.5, 0.625));
+
+ /// Two different one-part sheets so repeat grouping cannot merge them.
+ private sealed class TwoSheetStub : INestingEngine
+ {
+ public NestJobResult Solve(NestJob job, IProgress? progress = null,
+ CancellationToken token = default)
+ {
+ var stock = job.Plates[0];
+ var work = stock.WorkArea;
+ var builder = new NestJobResultBuilder(job);
+ builder.AddSheet(stock, new[] { (job.Parts[0].Id, work.Left + 20, work.Bottom + 10, 0.0) });
+ builder.AddSheet(stock, new[] { (job.Parts[0].Id, work.Left + 40, work.Bottom + 20, 0.0) });
+ return builder.Build(NestJobStopReason.Completed);
+ }
+ }
+
+ private static NestPipelineResult Result(Drawing drawing, int quadrant, params ClampZone[] zones)
+ {
+ var result = NestPipeline.Run(new TwoSheetStub(), "stub", new NestPipelineRequest("stub",
+ new[] { new NestItem { Drawing = drawing, Quantity = 2 } },
+ new[]
+ {
+ new NestPlateStock("sheet", new Size(48, 96), null, 0.25, new Spacing(1, 1, 1, 1),
+ quadrant, cost: null, clampZones: zones),
+ }));
+ Assert.True(result.IsValid, string.Join("; ", result.Violations));
+ Assert.Equal(2, result.Plates.Count);
+ return result;
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void EveryCommittedPlateCarriesTheSolvedZonesInPlateCoordinates(int quadrant)
+ {
+ var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
+ var nest = new Nest();
+ nest.Drawings.Add(drawing);
+ nest.CreatePlate(); // empty template without zones; the second sheet is created by commit
+ using var manager = new PlateManager(nest);
+
+ var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, quadrant, Zone()), manager);
+
+ Assert.Equal(2, applied.Count);
+ Assert.All(applied, plate =>
+ {
+ var zone = Assert.Single(plate.ClampZones);
+ Assert.Equal("clamp-1", zone.Id);
+ // Exact bits: the inverse of the stock's placement-frame translation.
+ Assert.Equal(ZoneX, zone.Bounds.X);
+ Assert.Equal(0, zone.Bounds.Y);
+ Assert.Equal(1.5, zone.Bounds.Length);
+ Assert.Equal(0.625, zone.Bounds.Width);
+ });
+ }
+
+ [Fact]
+ public void MatchingPlateZonesAreKeptWithoutChangeEvents()
+ {
+ var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
+ var nest = new Nest();
+ nest.Drawings.Add(drawing);
+ var template = nest.CreatePlate();
+ template.Quadrant = 3;
+ template.Size = new Size(48, 96);
+ var original = Zone();
+ template.ClampZones.Add(original);
+ var changes = 0;
+ template.ClampZonesChanged += (_, _) => changes++;
+ using var manager = new PlateManager(nest);
+
+ var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, 3, Zone()), manager);
+
+ Assert.Same(template, applied[0]);
+ Assert.Same(original, Assert.Single(template.ClampZones));
+ Assert.Equal(0, changes);
+ }
+
+ [Fact]
+ public void ZonesTheLayoutWasNotSolvedWithAreRemoved()
+ {
+ var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
+ var nest = new Nest();
+ nest.Drawings.Add(drawing);
+ var template = nest.CreatePlate();
+ template.ClampZones.Add(Zone());
+ using var manager = new PlateManager(nest);
+
+ var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, 1), manager);
+
+ Assert.All(applied, plate => Assert.Empty(plate.ClampZones));
+ }
+}
diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs
index a4333458..fbfc4764 100644
--- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs
+++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs
@@ -42,6 +42,7 @@ public static class NestPipelineCommit
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
plate.Quantity = proposed.Quantity;
+ SyncClampZones(plate, proposed.Stock);
plate.Parts.AddRange(proposed.Parts);
applied.Add(plate);
}
@@ -52,4 +53,43 @@ public static class NestPipelineCommit
}
return applied.AsReadOnly();
}
+
+ ///
+ /// The live plate keeps exactly the clamp keep-outs its layout was solved and validated
+ /// 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).
+ ///
+ private static void SyncClampZones(Plate plate, NestPlateStock stock)
+ {
+ var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
+ var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
+ if (SameInPlacementFrame(plate.ClampZones, stock.ClampZones, left, bottom))
+ return;
+
+ plate.ClampZones.Clear();
+ foreach (var zone in stock.ClampZones)
+ plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds.Translate(-left, -bottom)));
+ }
+
+ private static bool SameInPlacementFrame(
+ IList plateZones,
+ IReadOnlyList stockZones,
+ double left,
+ double bottom)
+ {
+ if (plateZones.Count != stockZones.Count)
+ return false;
+ for (var i = 0; i < plateZones.Count; i++)
+ {
+ // Same arithmetic NestPlateStock uses to carry plate zones into the placement frame.
+ var placed = plateZones[i].Bounds.Translate(left, bottom);
+ var solved = stockZones[i].Bounds;
+ if (!string.Equals(plateZones[i].Id, stockZones[i].Id, StringComparison.Ordinal)
+ || placed.X != solved.X || placed.Y != solved.Y
+ || placed.Length != solved.Length || placed.Width != solved.Width)
+ return false;
+ }
+ return true;
+ }
}