From 1e37c661ffd861c2f05ccb9553f3c22bec8593a5 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 12 Oct 2026 19:00:50 -0400 Subject: [PATCH] fix(io): harden clamp rail persistence Omit an empty clamp-carrying plate only when a new plate from the saved defaults would recreate it: same size and quadrant and zones the saved rail recreates. A rail-following empty sheet with another size or quadrant is written again instead of being lost. Read plateDefaults.clampRail through a lenient converter so a wrongly typed rail (a string or null width, non-numeric centers, an array in place of the object) loads as no rail while every plate's saved zones still load, as the file format documents, instead of failing the whole nest. Review findings 4 and 5, Project Memory 4e5f566d. --- .../ClampRailPersistenceTests.cs | 25 ++++++++ OpenNest.IO/NestFormat.cs | 60 +++++++++++++++++++ OpenNest.IO/NestWriter.cs | 21 +++++-- docs/nest-file-format.md | 8 ++- 4 files changed, 106 insertions(+), 8 deletions(-) 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