From 92d8964339e7d4b382fa6f372fa7b13803694087 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 11:29:41 -0400 Subject: [PATCH] fix(engine): return detached clamp zones from plate stock NestPlateStock is documented as immutable input, but ClampZones handed out its own zones, whose bounds are mutable. Editing one after the stock was built moved the stock's clamp while plates materialized from the stock (layout check, fill, posting) kept the original zone, so a part or cut on the moved clamp validated and posted clear. Return detached copies on every read, so the stock and its plates can never disagree. Copies keep the exact bits; engine results are unchanged. Project Memory: opennest 14f93b26-2e41-41f1-b09f-87dc895bb4da --- .../Jobs/ClampZoneSheetFrameTests.cs | 20 +++++++++++++ OpenNest.Engine/Jobs/NestPlateStock.cs | 28 +++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs index a6e169e3..5f2c005e 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 a855ea7f..649b9be3 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); + } }