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