diff --git a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs
new file mode 100644
index 0000000..2a596b1
--- /dev/null
+++ b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs
@@ -0,0 +1,175 @@
+using System.IO.Compression;
+using System.Text;
+using OpenNest.Geometry;
+
+namespace OpenNest.IO.Tests;
+
+///
+/// The nest's machine clamp rail persists on the plate defaults so plates created after a
+/// reload still get the clamps; plates whose saved zones are the rail's follow it again,
+/// anything else is preserved as saved, and an empty sheet the rail recreates is not
+/// written (the editor's trailing sentinel stays out of the file).
+///
+public class ClampRailPersistenceTests
+{
+ private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 };
+
+ private static ClampRail Machine() => new(1.5, 0.625, MachineCenters);
+
+ /// A rail nest whose first sheet is kept by a cut-off; the second is empty.
+ private static Nest RailNest(bool withEmptyTail = true)
+ {
+ var nest = new Nest("railed");
+ nest.PlateDefaults.Size = new Size(48, 96);
+ nest.SetClampRail(Machine());
+ var used = nest.CreatePlate();
+ used.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
+ if (withEmptyTail)
+ nest.CreatePlate();
+ return nest;
+ }
+
+ private static MemoryStream Write(Nest nest)
+ {
+ var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ stream.Position = 0;
+ return stream;
+ }
+
+ private static Nest RoundTrip(Nest nest) => new NestReader(Write(nest)).Read();
+
+ [Fact]
+ public void RailRoundTripsAndPlatesFollowItAfterReload()
+ {
+ var reopened = RoundTrip(RailNest());
+
+ Assert.Equal(Machine(), reopened.PlateDefaults.ClampRail);
+ var plate = Assert.Single(reopened.Plates); // the empty tail was not written
+ Assert.Same(reopened.PlateDefaults.ClampRail, plate.ClampRail);
+ Assert.Equal(7, plate.ClampZones.Count); // 96" sheet: the clamp at 111" is skipped
+
+ // Plates created or resized after the reload still get the machine clamps.
+ Assert.Equal(7, reopened.CreatePlate().ClampZones.Count);
+ plate.Size = new Size(48, 120);
+ Assert.Equal(8, plate.ClampZones.Count);
+ }
+
+ [Fact]
+ public void EmptyPlateWithZonesTheRailCannotRecreateIsKept()
+ {
+ var nest = RailNest(withEmptyTail: false);
+ var odd = nest.CreatePlate();
+ odd.SetClampRail(null!);
+ odd.ClampZones.Add(new ClampZone("manual", new Box(10, 0, 2, 1)));
+
+ var reopened = RoundTrip(nest);
+
+ Assert.Equal(2, reopened.Plates.Count);
+ var kept = reopened.Plates[1];
+ Assert.Equal("manual", Assert.Single(kept.ClampZones).Id);
+ Assert.Null(kept.ClampRail); // preserved as saved, not re-resolved
+ }
+
+ [Fact]
+ public void SavedZonesThatDifferFromTheRailArePreservedExactly()
+ {
+ var nest = RailNest(withEmptyTail: false);
+ var plate = nest.Plates[0];
+ plate.SetClampRail(null!);
+ plate.ClampZones.Add(new ClampZone("clamp-1", new Box(5.5, 0, 1.25, 0.5)));
+
+ var reopenedPlate = Assert.Single(RoundTrip(nest).Plates);
+
+ var zone = Assert.Single(reopenedPlate.ClampZones);
+ Assert.Equal(5.5, zone.Bounds.X);
+ Assert.Equal(1.25, zone.Bounds.Length);
+ Assert.Null(reopenedPlate.ClampRail);
+ }
+
+ [Fact]
+ public void NestWithoutRailWritesNoRailKeyAndReadsNone()
+ {
+ var nest = new Nest("plain");
+ var plate = new Plate(48, 96);
+ plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
+ nest.Plates.Add(plate);
+ var stream = Write(nest);
+
+ Assert.DoesNotContain("clampRail", ReadNestJson(stream), StringComparison.Ordinal);
+ stream.Position = 0;
+ var reopened = new NestReader(stream).Read();
+ Assert.Null(reopened.PlateDefaults.ClampRail);
+ Assert.Empty(reopened.CreatePlate().ClampZones);
+ }
+
+ [Fact]
+ public void WriterEmitsCamelCaseRailOnPlateDefaults()
+ {
+ var json = ReadNestJson(Write(RailNest()));
+
+ Assert.Contains("\"clampRail\": {", json, StringComparison.Ordinal);
+ Assert.Contains("\"depth\": 0.625", json, StringComparison.Ordinal);
+ Assert.Contains("\"centers\": [", json, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("\"width\": 1.5", "\"width\": -1.5")]
+ [InlineData("\"centers\": [", "\"centers\": [-6, ")]
+ public void MalformedRailIsIgnoredWhileTheNestLoads(string find, string replace)
+ {
+ var tampered = RewriteNestJson(Write(RailNest()), json =>
+ {
+ var start = json.IndexOf("\"clampRail\"", StringComparison.Ordinal);
+ Assert.True(start >= 0);
+ var at = json.IndexOf(find, start, StringComparison.Ordinal);
+ Assert.True(at >= 0);
+ return json[..at] + replace + json[(at + find.Length)..];
+ });
+
+ var reopened = new NestReader(tampered).Read();
+
+ Assert.Null(reopened.PlateDefaults.ClampRail);
+ var plate = Assert.Single(reopened.Plates);
+ Assert.Equal(7, plate.ClampZones.Count); // saved zones still load, explicitly
+ Assert.Null(plate.ClampRail);
+ }
+
+ 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.Tests/ClampZonePersistenceTests.cs b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs
index a182473..7e5fc61 100644
--- a/OpenNest.IO.Tests/ClampZonePersistenceTests.cs
+++ b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs
@@ -7,7 +7,8 @@ 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.
+/// empty-plate filter unless the nest's saved rail recreates exactly those zones
+/// (see ClampRailPersistenceTests). Malformed zones are skipped rather than poisoning the file.
///
public class ClampZonePersistenceTests
{
diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs
index e765f2d..a06ae8d 100644
--- a/OpenNest.IO/NestFormat.cs
+++ b/OpenNest.IO/NestFormat.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Text.Json;
+using System.Text.Json.Serialization;
namespace OpenNest.IO
{
@@ -43,6 +44,25 @@ namespace OpenNest.IO
public double PartSpacing { get; init; }
public MaterialDto Material { get; init; } = new();
public SpacingDto EdgeSpacing { get; init; } = new();
+
+ ///
+ /// The nest's machine clamp rail, which new plates follow. Omitted when the nest
+ /// has none; older files and older readers simply have no rail.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public ClampRailDto ClampRail { get; init; }
+ }
+
+ public record ClampRailDto
+ {
+ /// Clamp extent along the origin-side edge (X).
+ public double Width { get; init; }
+
+ /// Clamp intrusion into the sheet from that edge (Y).
+ public double Depth { get; init; }
+
+ /// Clamp centers along X, measured from the plate origin.
+ public List Centers { get; init; } = new();
}
public record DrawingDto
diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs
index 9fac84c..e88377d 100644
--- a/OpenNest.IO/NestReader.cs
+++ b/OpenNest.IO/NestReader.cs
@@ -327,6 +327,24 @@ namespace OpenNest.IO
}
}
+ ///
+ /// The saved machine clamp rail, or null when absent, empty or malformed (a bad rail
+ /// is ignored rather than failing the nest; each plate's saved zones still load).
+ ///
+ private static ClampRail ReadClampRail(ClampRailDto dto)
+ {
+ if (dto?.Centers == null || dto.Centers.Count == 0)
+ return null;
+ try
+ {
+ return new ClampRail(dto.Width, dto.Depth, dto.Centers);
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ }
+
private Nest BuildNest(NestDto dto, Dictionary drawingMap)
{
var nest = new Nest();
@@ -363,6 +381,8 @@ namespace OpenNest.IO
pd.EdgeSpacing.Right,
pd.EdgeSpacing.Top
);
+ var clampRail = ReadClampRail(pd.ClampRail);
+ nest.PlateDefaults.ClampRail = clampRail;
// Stock options: sheet sizes and salvage rate
nest.SalvageRate = dto.SalvageRate;
@@ -418,6 +438,11 @@ namespace OpenNest.IO
}
}
+ // A plate follows the nest rail only when its saved zones are exactly the
+ // rail's for this sheet; anything else is preserved as saved.
+ if (clampRail != null && clampRail.Matches(plate))
+ plate.SetClampRail(clampRail);
+
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 9a0d58a..8c69b9c 100644
--- a/OpenNest.IO/NestWriter.cs
+++ b/OpenNest.IO/NestWriter.cs
@@ -135,6 +135,14 @@ namespace OpenNest.IO
Right = pd.EdgeSpacing.Right,
Bottom = pd.EdgeSpacing.Bottom,
},
+ ClampRail = pd.ClampRail == null
+ ? null
+ : new ClampRailDto
+ {
+ Width = pd.ClampRail.Width,
+ Depth = pd.ClampRail.Depth,
+ Centers = pd.ClampRail.Centers.ToList(),
+ },
};
}
@@ -197,6 +205,9 @@ namespace OpenNest.IO
return list;
}
+ private bool RailRecreates(Plate plate) =>
+ nest.PlateDefaults.ClampRail is { } rail && rail.Matches(plate);
+
private List BuildPlateDtos()
{
var list = new List();
@@ -205,10 +216,12 @@ namespace OpenNest.IO
{
var plate = nest.Plates[i];
+ // An empty plate is omitted (this also drops the editor's trailing sentinel)
+ // unless that would lose clamp zones the saved rail cannot recreate.
if (
plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
&& plate.CutOffs.Count == 0
- && plate.ClampZones.Count == 0
+ && (plate.ClampZones.Count == 0 || RailRecreates(plate))
)
continue;
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index 30a4a9d..e707dee 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -67,9 +67,22 @@ regions in plate coordinates: `id`, `x`, `y`, `length` (X extent) and `width`
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.
+dropped while the rest of the nest loads.
+
+`plateDefaults.clampRail` optionally stores the nest's machine clamp rail:
+`width` (along the edge), `depth` (into the sheet) and `centers` (X positions
+measured from the plate origin along the origin-side edge). New plates follow
+the rail and resolve it for their own size and quadrant; clamps wholly beyond a
+sheet are skipped and one still reaching onto it is clipped. The key is omitted
+when a nest has no rail, so such files are unchanged; a missing, empty or
+malformed rail loads as none. On load a plate follows the rail again only when
+its saved `clamps` are exactly the rail's zones for that sheet; any other saved
+zones are kept as written.
+
+An empty plate (no parts, no cut-offs) is omitted from the file unless that
+would lose clamp zones: a plate whose zones the saved rail recreates is
+omitted like any other empty plate (this keeps the editor's trailing empty
+sheet out of the file), while one carrying other zones is written.
## Drawing edits