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
This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent df2f1b796d
commit 92d8964339
2 files changed
+46 -2

No files matched your search

@@ -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)]
+26 -2
View File
@@ -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;
}
}
/// <summary>
/// Keep-out rectangles in the placement frame (quadrant applied), translated exactly
/// like <see cref="WorkArea"/>. Empty when the plate defines none; never null.
/// like <see cref="WorkArea"/>. 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.
/// </summary>
public IReadOnlyList<ClampZone> ClampZones { get; }
@@ -102,4 +104,26 @@ public sealed class NestPlateStock
/// <summary>True when a width (X) by height (Y) envelope fits the work area within epsilon.</summary>
public bool Fits(double width, double height, double epsilon = 1e-9) =>
width <= workArea.Length + epsilon && height <= workArea.Width + epsilon;
private sealed class DetachedZones : IReadOnlyList<ClampZone>
{
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<ClampZone> 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);
}
}