diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs
index a6e169e..5f2c005 100644
--- a/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs
@@ -79,6 +79,26 @@ public class ClampZoneSheetFrameTests
Assert.False(report.CanPost(false));
}
+ [Fact]
+ public void StockClampZonesCannotBeEditedThroughTheReturnedZones()
+ {
+ // Stock is immutable input: editing a returned zone must not move the stock's clamp
+ // away from the zones its materialized plates (validation, fill, posting) use.
+ var live = new Plate(24, 48);
+ live.ClampZones.Add(new ClampZone("z", new Box(10, 0, 1.5, 0.625)));
+ var stock = DrawingJobMapper.FromPlate("s", live, 1);
+
+ stock.ClampZones[0].Bounds.X += 10;
+ foreach (var zone in stock.ClampZones)
+ zone.Bounds.Y = 5;
+
+ var current = Assert.Single(stock.ClampZones);
+ Assert.Equal(10, current.Bounds.X);
+ Assert.Equal(0, current.Bounds.Y);
+ var plate = DrawingJobMapper.CreatePlate(stock);
+ Assert.Equal(stock.ClampZones.Select(Bits), ClampZoneFrame.ToPlacement(plate).Select(Bits));
+ }
+
[Theory]
[InlineData(1)]
[InlineData(2)]
diff --git a/OpenNest.Engine/Jobs/NestPlateStock.cs b/OpenNest.Engine/Jobs/NestPlateStock.cs
index a855ea7..649b9be 100644
--- a/OpenNest.Engine/Jobs/NestPlateStock.cs
+++ b/OpenNest.Engine/Jobs/NestPlateStock.cs
@@ -60,14 +60,16 @@ public sealed class NestPlateStock
zones[i] = new ClampZone(clampZones[i].Id, clampZones[i].Bounds.Translate(left, bottom));
sheetZones[i] = new ClampZone(clampZones[i].Id, clampZones[i].Bounds);
}
- ClampZones = zones;
+ ClampZones = new DetachedZones(zones);
SheetClampZones = sheetZones;
}
}
///
/// Keep-out rectangles in the placement frame (quadrant applied), translated exactly
- /// like . Empty when the plate defines none; never null.
+ /// like . Empty when the plate defines none; never null. Every
+ /// read returns detached copies: zone bounds are mutable, and the stock must stay
+ /// consistent with the zones its materialized plates use.
///
public IReadOnlyList ClampZones { get; }
@@ -102,4 +104,26 @@ public sealed class NestPlateStock
/// True when a width (X) by height (Y) envelope fits the work area within epsilon.
public bool Fits(double width, double height, double epsilon = 1e-9) =>
width <= workArea.Length + epsilon && height <= workArea.Width + epsilon;
+
+ private sealed class DetachedZones : IReadOnlyList
+ {
+ private readonly ClampZone[] zones;
+
+ public DetachedZones(ClampZone[] zones) => this.zones = zones;
+
+ public int Count => zones.Length;
+
+ public ClampZone this[int index] => Copy(zones[index]);
+
+ public IEnumerator GetEnumerator()
+ {
+ foreach (var zone in zones)
+ yield return Copy(zone);
+ }
+
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+
+ // The ClampZone constructor copies the rectangle, keeping its exact bits.
+ private static ClampZone Copy(ClampZone zone) => new(zone.Id, zone.Bounds);
+ }
}