diff --git a/OpenNest.IO.Tests/ClampZonePersistenceTests.cs b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs
new file mode 100644
index 0000000..a182473
--- /dev/null
+++ b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs
@@ -0,0 +1,175 @@
+using System.IO.Compression;
+using System.Text;
+using OpenNest.Geometry;
+
+namespace OpenNest.IO.Tests;
+
+///
+/// Clamp zones survive the .nest round trip; files without the "clamps" key load
+/// unchanged with zero zones, and a clamp-carrying sheet is never dropped by the
+/// empty-plate filter. Malformed zones are skipped rather than poisoning the file.
+///
+public class ClampZonePersistenceTests
+{
+ private static Nest NestWithClamps(int zoneCount)
+ {
+ var nest = new Nest("clamped");
+ var plate = new Plate(48, 96);
+ for (var i = 0; i < zoneCount; i++)
+ plate.ClampZones.Add(new ClampZone($"clamp-{i}", new Box(2.25 + 10 * i, 0, 1.5, 0.625)));
+ nest.Plates.Add(plate);
+ return nest;
+ }
+
+ private static Nest RoundTrip(Nest nest)
+ {
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ stream.Position = 0;
+ return new NestReader(stream).Read();
+ }
+
+ [Fact]
+ public void ZonesRoundTripWithIdsAndPositions()
+ {
+ var reopened = RoundTrip(NestWithClamps(8));
+
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Equal(8, plate.ClampZones.Count);
+ for (var i = 0; i < 8; i++)
+ {
+ var zone = plate.ClampZones[i];
+ Assert.Equal($"clamp-{i}", zone.Id);
+ Assert.Equal(2.25 + 10 * i, zone.Bounds.X, 9);
+ Assert.Equal(0, zone.Bounds.Y, 9);
+ Assert.Equal(1.5, zone.Bounds.Length, 9);
+ Assert.Equal(0.625, zone.Bounds.Width, 9);
+ }
+ }
+
+ [Fact]
+ public void ClampOnlyPlateSurvivesTheEmptyPlateFilter()
+ {
+ // A sheet with no parts and no cut-offs but with clamp definitions is a real
+ // configuration (the machine rail exists even on an empty plate) and must persist.
+ var reopened = RoundTrip(NestWithClamps(3));
+
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Empty(plate.Parts);
+ Assert.Equal(3, plate.ClampZones.Count);
+ }
+
+ [Fact]
+ public void EmptyPlateWithoutClampsIsStillOmitted()
+ {
+ var nest = new Nest("empty");
+ nest.Plates.Add(new Plate(48, 96));
+ var reopened = RoundTrip(nest);
+ Assert.Empty(reopened.Plates);
+ }
+
+ [Fact]
+ public void ZonesCoexistWithPlacementsAndCutOffs()
+ {
+ var nest = new Nest("mixed");
+ var plate = new Plate(48, 96);
+ plate.ClampZones.Add(new ClampZone("clamp-0", new Box(5.25, 0, 1.5, 0.625)));
+ plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
+ nest.Plates.Add(plate);
+
+ var reopened = RoundTrip(nest);
+
+ var reopenedPlate = Assert.Single(reopened.Plates);
+ Assert.Single(reopenedPlate.ClampZones);
+ Assert.Equal("clamp-0", reopenedPlate.ClampZones[0].Id);
+ Assert.Single(reopenedPlate.CutOffs);
+ }
+
+ [Fact]
+ public void FileWithoutClampsKeyLoadsUnchanged()
+ {
+ // Files written before this feature have no "clamps" property at all.
+ var withZones = new MemoryStream();
+ Assert.True(new NestWriter(NestWithClamps(2)).Write(withZones));
+ var legacy = RewriteNestJson(withZones,
+ json => System.Text.RegularExpressions.Regex.Replace(json, @",\s*""clamps""\s*:\s*\[[^\]]*\]", ""));
+
+ legacy.Position = 0;
+ var reopened = new NestReader(legacy).Read();
+
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Empty(plate.Parts);
+ Assert.Empty(plate.ClampZones);
+ }
+
+ [Fact]
+ public void MalformedZonesAreSkippedWithoutThrowing()
+ {
+ // Hand-edited sizes must not poison the file: drop bad entries, keep good ones.
+ var withZones = new MemoryStream();
+ Assert.True(new NestWriter(NestWithClamps(1)).Write(withZones));
+ var tampered = RewriteNestJson(withZones, json => json.Replace(
+ "\"clamps\": [",
+ "\"clamps\": [{\"id\":\"bad\",\"x\":0,\"y\":0,\"length\":-5,\"width\":0.625},"
+ + "{\"id\":\"\",\"x\":0,\"y\":0,\"length\":1.5,\"width\":0.625},")
+ );
+
+ tampered.Position = 0;
+ var reopened = new NestReader(tampered).Read();
+
+ var plate = Assert.Single(reopened.Plates);
+ var zone = Assert.Single(plate.ClampZones);
+ Assert.Equal("clamp-0", zone.Id);
+ }
+
+ [Fact]
+ public void WriterEmitsCamelCaseClampsArray()
+ {
+ var withZones = new MemoryStream();
+ Assert.True(new NestWriter(NestWithClamps(1)).Write(withZones));
+ var json = ReadNestJson(withZones);
+
+ Assert.Contains("\"clamps\": [", json, StringComparison.Ordinal);
+ Assert.Contains("\"id\": \"clamp-0\"", json, StringComparison.Ordinal);
+ Assert.Contains("\"length\": 1.5", json, StringComparison.Ordinal);
+ Assert.Contains("\"width\": 0.625", json, StringComparison.Ordinal);
+ }
+
+ private static string ReadNestJson(MemoryStream zipBytes)
+ {
+ zipBytes.Position = 0;
+ using var archive = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true);
+ var entry = archive.GetEntry("nest.json");
+ Assert.NotNull(entry);
+ using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
+
+ private static MemoryStream RewriteNestJson(MemoryStream zipBytes, Func rewrite)
+ {
+ zipBytes.Position = 0;
+ var output = new MemoryStream();
+ using (var source = new ZipArchive(zipBytes, ZipArchiveMode.Read, leaveOpen: true))
+ using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ foreach (var entry in source.Entries)
+ {
+ var newEntry = target.CreateEntry(entry.FullName);
+ using var input = entry.Open();
+ using var outStream = newEntry.Open();
+ if (entry.FullName == "nest.json")
+ {
+ using var text = new StreamReader(input, Encoding.UTF8);
+ using var writer = new StreamWriter(outStream, new UTF8Encoding(false));
+ writer.Write(rewrite(text.ReadToEnd()));
+ }
+ else
+ {
+ input.CopyTo(outStream);
+ }
+ }
+ }
+ output.Position = 0;
+ return output;
+ }
+}
diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs
index 39fa7db..e765f2d 100644
--- a/OpenNest.IO/NestFormat.cs
+++ b/OpenNest.IO/NestFormat.cs
@@ -71,6 +71,25 @@ namespace OpenNest.IO
public CuttingParametersDto CuttingParameters { get; init; }
public List Parts { get; init; } = new();
public List CutOffs { get; init; } = new();
+
+ ///
+ /// Machine clamp keep-out rectangles in plate coordinates. Missing in older
+ /// files, which load with no zones.
+ ///
+ public List Clamps { get; init; } = new();
+ }
+
+ public record ClampZoneDto
+ {
+ public string Id { get; init; } = "";
+ public double X { get; init; }
+ public double Y { get; init; }
+
+ /// Extent along X.
+ public double Length { get; init; }
+
+ /// Extent along Y (depth from the y = 0 operator-side edge).
+ public double Width { get; init; }
}
public record PartDto
diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs
index 2c60753..9fac84c 100644
--- a/OpenNest.IO/NestReader.cs
+++ b/OpenNest.IO/NestReader.cs
@@ -399,6 +399,25 @@ namespace OpenNest.IO
plate.GrainAngle = p.GrainAngle;
plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
+ // Clamp zones: older files have no key at all; an individually invalid
+ // entry is dropped rather than poisoning the whole nest.
+ if (p.Clamps != null)
+ {
+ foreach (var c in p.Clamps)
+ {
+ try
+ {
+ plate.ClampZones.Add(
+ new ClampZone(c.Id, new OpenNest.Geometry.Box(c.X, c.Y, c.Length, c.Width))
+ );
+ }
+ catch (ArgumentException)
+ {
+ // Invalid zone definition; ignore this one entry.
+ }
+ }
+ }
+
for (var partIndex = 0; partIndex < p.Parts.Count; partIndex++)
{
var partDto = p.Parts[partIndex];
diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs
index e74c83b..9a0d58a 100644
--- a/OpenNest.IO/NestWriter.cs
+++ b/OpenNest.IO/NestWriter.cs
@@ -208,6 +208,7 @@ namespace OpenNest.IO
if (
plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
&& plate.CutOffs.Count == 0
+ && plate.ClampZones.Count == 0
)
continue;
@@ -283,6 +284,16 @@ namespace OpenNest.IO
CutOffs = cutoffs,
GrainAngle = plate.GrainAngle,
CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters),
+ Clamps = plate.ClampZones
+ .Select(z => new ClampZoneDto
+ {
+ Id = z.Id,
+ X = z.Bounds.X,
+ Y = z.Bounds.Y,
+ Length = z.Bounds.Length,
+ Width = z.Bounds.Width,
+ })
+ .ToList(),
}
);
}
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index e6032eb..30a4a9d 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -59,6 +59,18 @@ cross-platform DTO mapping; the desktop settings serializer delegates to it so
existing saved settings keep their JSON contract. Settings are independent of
the saved part programs: restoring settings never regenerates those programs.
+## Clamp zones
+
+Each plate may carry an optional `clamps` array of fixed rectangular keep-out
+regions in plate coordinates: `id`, `x`, `y`, `length` (X extent) and `width`
+(Y extent, the depth intrusion from the `y = 0` operator-side edge for machine
+clamps). Zones are exclusion and display data only - never material, quantity,
+utilization, cut sequence or CNC output. Files without the key load with no
+zones, and an individually malformed entry (blank id, non-positive size) is
+dropped while the rest of the nest loads. Because clamp definitions are real
+machine state, a plate that carries only clamps is written even when it has no
+parts and no cut-offs; genuinely empty plates without clamps remain omitted.
+
## Drawing edits
`DrawingProgramSnapshot` captures serialized main and hole-program text before