diff --git a/OpenNest.Core/ClampZone.cs b/OpenNest.Core/ClampZone.cs
new file mode 100644
index 00000000..43f15fb4
--- /dev/null
+++ b/OpenNest.Core/ClampZone.cs
@@ -0,0 +1,52 @@
+using System;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame:
+ /// origin at the plate corner, X along the edge, Y depth from it). Machine edge clamps
+ /// are the first users; a future "don't cut here" tag is another entry of this shape.
+ /// Zones are exclusion and display only - never material, quantity, utilization,
+ /// cut sequence or CNC output. Rectangles only: axis-aligned by construction.
+ ///
+ public sealed class ClampZone
+ {
+ public ClampZone(string id, Box bounds)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(id);
+ ArgumentNullException.ThrowIfNull(bounds);
+ if (
+ !double.IsFinite(bounds.X)
+ || !double.IsFinite(bounds.Y)
+ || !double.IsFinite(bounds.Length)
+ || !double.IsFinite(bounds.Width)
+ )
+ throw new ArgumentException(
+ $"Clamp zone '{id}' bounds must have finite coordinates and size.",
+ nameof(bounds)
+ );
+ if (bounds.Length <= 0 || bounds.Width <= 0)
+ throw new ArgumentException(
+ $"Clamp zone '{id}' must have a positive length (X) and width (Y).",
+ nameof(bounds)
+ );
+
+ Id = id;
+ // Detached copy: the zone owns its rectangle, callers may keep mutating theirs.
+ Bounds = new Box(bounds.X, bounds.Y, bounds.Length, bounds.Width);
+ }
+
+ /// Stable identifier used by persistence and diagnostics.
+ public string Id { get; }
+
+ ///
+ /// The rectangle in plate coordinates: Length is the extent along X, Width the
+ /// extent along Y (depth from the y = 0 operator-side edge for machine clamps).
+ ///
+ public Box Bounds { get; }
+
+ public override string ToString() =>
+ $"ClampZone '{Id}' [{Bounds.Left:R}, {Bounds.Bottom:R}] x {Bounds.Length:R} x {Bounds.Width:R}";
+ }
+}
diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs
index 0f3cf969..c441d961 100644
--- a/OpenNest.Core/Plate.cs
+++ b/OpenNest.Core/Plate.cs
@@ -55,6 +55,10 @@ namespace OpenNest
Parts.ItemAdded += Parts_PartAdded;
Parts.ItemRemoved += Parts_PartRemoved;
CutOffs = new ObservableList();
+ ClampZones = new ObservableList();
+ ClampZones.ItemAdded += (_, _) => OnClampZonesChanged();
+ ClampZones.ItemRemoved += (_, _) => OnClampZonesChanged();
+ ClampZones.ItemChanged += (_, _) => OnClampZonesChanged();
Quadrant = 1;
}
@@ -102,6 +106,22 @@ namespace OpenNest
///
public ObservableList CutOffs { get; set; }
+ ///
+ /// Fixed rectangular machine-clamp keep-outs in plate coordinates
+ /// (). Never parts: excluded from quantity, utilization,
+ /// cut sequence and CNC output; placement rules and display consume them. Mutate
+ /// through this list; fires for every change.
+ ///
+ public ObservableList ClampZones { get; }
+
+ ///
+ /// Raised after content changes so views can refresh
+ /// the zone overlay. Carries no geometry arguments.
+ ///
+ public event EventHandler? ClampZonesChanged;
+
+ private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
+
///
/// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is
diff --git a/OpenNest.Tests/ClampZoneTests.cs b/OpenNest.Tests/ClampZoneTests.cs
new file mode 100644
index 00000000..e720f927
--- /dev/null
+++ b/OpenNest.Tests/ClampZoneTests.cs
@@ -0,0 +1,133 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+///
+/// Rectangular machine-clamp keep-out zones: a first-class rectangle on the plate,
+/// never a part. Zones are exclusion and display only - quantity, utilization and
+/// cut accounting must never see them.
+///
+public class ClampZoneTests
+{
+ private static ClampZone Valid(string id = "clamp-1") =>
+ new(id, new Box(5.25, 0, 1.5, 0.625));
+
+ [Fact]
+ public void ZoneCarriesIdAndBounds()
+ {
+ var zone = Valid();
+ Assert.Equal("clamp-1", zone.Id);
+ Assert.Equal(5.25, 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 ZoneOwnsADetachedCopyOfItsBounds()
+ {
+ var bounds = new Box(5.25, 0, 1.5, 0.625);
+ var zone = new ClampZone("clamp", bounds);
+ bounds.Offset(100, 100);
+ Assert.Equal(5.25, zone.Bounds.X);
+ Assert.Equal(0, zone.Bounds.Y);
+ }
+
+ [Theory]
+ [InlineData(double.NaN, 0, 1.5, 0.625)]
+ [InlineData(0, double.PositiveInfinity, 1.5, 0.625)]
+ [InlineData(0, 0, double.NaN, 0.625)]
+ [InlineData(0, 0, 1.5, 0)] // zero-size zone keeps out nothing
+ [InlineData(0, 0, -1.5, 0.625)] // negative size is not a rectangle
+ [InlineData(0, 0, 1.5, -0.625)]
+ public void MalformedBoundsAreRejected(double x, double y, double length, double width)
+ {
+ Assert.Throws(() => new ClampZone("clamp", new Box(x, y, length, width)));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void MissingIdIsRejected(string? id)
+ {
+ Assert.ThrowsAny(
+ () => new ClampZone(id!, new Box(0, 0, 1.5, 0.625))
+ );
+ }
+
+ [Fact]
+ public void NullBoundsRejected()
+ {
+ Assert.Throws(() => new ClampZone("clamp", null!));
+ }
+
+ [Fact]
+ public void PlateStartsEmptyAndKeepsIndependentLists()
+ {
+ var a = new Plate(48, 96);
+ var b = new Plate(48, 96);
+ Assert.Empty(a.ClampZones);
+ a.ClampZones.Add(Valid());
+ Assert.Single(a.ClampZones);
+ Assert.Empty(b.ClampZones);
+ }
+
+ [Fact]
+ public void ZoneChangesRaiseClampZonesChanged()
+ {
+ var plate = new Plate(48, 96);
+ var raised = 0;
+ plate.ClampZonesChanged += (_, _) => raised++;
+
+ var zone = Valid();
+ plate.ClampZones.Add(zone);
+ Assert.Equal(1, raised);
+
+ plate.ClampZones.Remove(zone);
+ Assert.Equal(2, raised);
+ }
+
+ [Fact]
+ public void ZonesAreInvisibleToQuantityAndUtilization()
+ {
+ var plate = new Plate(48, 96) { Quantity = 1 };
+ for (var i = 0; i < 8; i++)
+ plate.ClampZones.Add(new ClampZone($"clamp-{i}", new Box(i * 2.0, 0, 1.5, 0.625)));
+
+ var drawing = MakeRectDrawing(10, 5);
+ drawing.Quantity.Required = 4;
+ var part = new Part(drawing);
+ plate.Parts.Add(part);
+
+ // The single placed part accounts exactly as it does on a plate without clamps.
+ Assert.Equal(1, drawing.Quantity.Nested);
+ Assert.Single(plate.Parts); // clamp zones never enter Parts
+ Assert.Equal(drawing.Area / plate.Area(), plate.Utilization(), 9);
+
+ plate.Parts.Remove(part);
+ Assert.Equal(0, drawing.Quantity.Nested);
+ }
+
+ [Fact]
+ public void ZonesNeverEnterCutSequenceOrPartList()
+ {
+ var plate = new Plate(48, 96);
+ plate.ClampZones.Add(Valid());
+ Assert.All(plate.Parts, p => Assert.False(p.BaseDrawing.IsCutOff));
+ Assert.Empty(plate.Parts);
+ Assert.DoesNotContain(plate.ClampZones, z => ReferenceEquals(z, null));
+ }
+
+ private static Drawing MakeRectDrawing(double length, double width)
+ {
+ var pgm = new Program();
+ pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(length, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(length, width)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, width)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
+ return new Drawing("rect", pgm);
+ }
+}