diff --git a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs
index 2a596b14..5696c29c 100644
--- a/OpenNest.IO.Tests/ClampRailPersistenceTests.cs
+++ b/OpenNest.IO.Tests/ClampRailPersistenceTests.cs
@@ -71,6 +71,25 @@ public class ClampRailPersistenceTests
Assert.Null(kept.ClampRail); // preserved as saved, not re-resolved
}
+ [Fact]
+ public void EmptyRailSheetThatDiffersFromThePlateDefaultsIsKept()
+ {
+ // The rail recreates its zones, but nothing recreates this sheet's size and quadrant.
+ var nest = RailNest(withEmptyTail: false);
+ var odd = nest.CreatePlate();
+ odd.Size = new Size(48, 60);
+ odd.Quadrant = 3;
+
+ var reopened = RoundTrip(nest);
+
+ Assert.Equal(2, reopened.Plates.Count);
+ var kept = reopened.Plates[1];
+ Assert.Equal(new Size(48, 60), kept.Size);
+ Assert.Equal(3, kept.Quadrant);
+ Assert.Equal(4, kept.ClampZones.Count);
+ Assert.Same(reopened.PlateDefaults.ClampRail, kept.ClampRail);
+ }
+
[Fact]
public void SavedZonesThatDifferFromTheRailArePreservedExactly()
{
@@ -116,6 +135,12 @@ public class ClampRailPersistenceTests
[Theory]
[InlineData("\"width\": 1.5", "\"width\": -1.5")]
[InlineData("\"centers\": [", "\"centers\": [-6, ")]
+ [InlineData("\"width\": 1.5", "\"width\": \"bad\"")]
+ [InlineData("\"width\": 1.5", "\"width\": null")]
+ [InlineData("\"centers\": [", "\"centers\": [\"bad\", ")]
+ [InlineData("\"centers\": [", "\"centers\": [null, ")]
+ [InlineData("\"centers\": [", "\"centers\": 6, \"unused\": [")]
+ [InlineData("\"clampRail\": {", "\"clampRail\": [6], \"unused\": {")]
public void MalformedRailIsIgnoredWhileTheNestLoads(string find, string replace)
{
var tampered = RewriteNestJson(Write(RailNest()), json =>
diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs
index a06ae8d9..baedf527 100644
--- a/OpenNest.IO/NestFormat.cs
+++ b/OpenNest.IO/NestFormat.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -50,9 +51,68 @@ namespace OpenNest.IO
/// has none; older files and older readers simply have no rail.
///
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonConverter(typeof(LenientClampRailConverter))]
public ClampRailDto ClampRail { get; init; }
}
+ ///
+ /// Reads a malformed or wrongly typed clamp rail as no rail instead of failing the
+ /// whole nest; the reader still validates the values (sign, size) it receives.
+ ///
+ public sealed class LenientClampRailConverter : JsonConverter
+ {
+ public override ClampRailDto Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options
+ )
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ var rail = document.RootElement;
+ if (
+ rail.ValueKind != JsonValueKind.Object
+ || !TryNumber(rail, "width", out var width)
+ || !TryNumber(rail, "depth", out var depth)
+ || !rail.TryGetProperty("centers", out var centers)
+ || centers.ValueKind != JsonValueKind.Array
+ )
+ return null;
+
+ var list = new List();
+ foreach (var center in centers.EnumerateArray())
+ {
+ if (center.ValueKind != JsonValueKind.Number || !center.TryGetDouble(out var x))
+ return null;
+ list.Add(x);
+ }
+ return new ClampRailDto { Width = width, Depth = depth, Centers = list };
+ }
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ ClampRailDto value,
+ JsonSerializerOptions options
+ )
+ {
+ writer.WriteStartObject();
+ writer.WriteNumber("width", value.Width);
+ writer.WriteNumber("depth", value.Depth);
+ writer.WriteStartArray("centers");
+ foreach (var center in value.Centers ?? new List())
+ writer.WriteNumberValue(center);
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+ }
+
+ private static bool TryNumber(JsonElement rail, string name, out double value)
+ {
+ value = 0;
+ return rail.TryGetProperty(name, out var element)
+ && element.ValueKind == JsonValueKind.Number
+ && element.TryGetDouble(out value);
+ }
+ }
+
public record ClampRailDto
{
/// Clamp extent along the origin-side edge (X).
diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs
index 8c69b9cf..b53c749b 100644
--- a/OpenNest.IO/NestWriter.cs
+++ b/OpenNest.IO/NestWriter.cs
@@ -205,8 +205,19 @@ namespace OpenNest.IO
return list;
}
- private bool RailRecreates(Plate plate) =>
- nest.PlateDefaults.ClampRail is { } rail && rail.Matches(plate);
+ ///
+ /// True when a plate created from the saved defaults would be this sheet's clamp
+ /// configuration: same size and quadrant, and zones the saved rail recreates.
+ ///
+ private bool DefaultsRecreateClamps(Plate plate)
+ {
+ var defaults = nest.PlateDefaults;
+ return defaults.ClampRail is { } rail
+ && plate.Size.Width == defaults.Size.Width
+ && plate.Size.Length == defaults.Size.Length
+ && plate.Quadrant == defaults.Quadrant
+ && rail.Matches(plate);
+ }
private List BuildPlateDtos()
{
@@ -216,12 +227,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.
+ // An empty plate is omitted unless that would lose a clamp configuration the
+ // saved defaults cannot recreate; the editor's trailing empty sheet is dropped.
if (
plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
&& plate.CutOffs.Count == 0
- && (plate.ClampZones.Count == 0 || RailRecreates(plate))
+ && (plate.ClampZones.Count == 0 || DefaultsRecreateClamps(plate))
)
continue;
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index e707deea..63b238a0 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -80,9 +80,11 @@ 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.
+would lose clamp zones: it is omitted like any other empty plate only when a
+new plate from the saved defaults would recreate it (same size and quadrant,
+zones the saved rail recreates), which keeps the editor's trailing empty sheet
+out of the file. An empty plate with other zones, or a rail-following one
+whose size or quadrant differs from the defaults, is written.
## Drawing edits