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