diff --git a/OpenNest.IO.Tests/ClampZonePersistenceTests.cs b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs new file mode 100644 index 0000000..a182473 --- /dev/null +++ b/OpenNest.IO.Tests/ClampZonePersistenceTests.cs @@ -0,0 +1,175 @@ +using System.IO.Compression; +using System.Text; +using OpenNest.Geometry; + +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. +/// +public class ClampZonePersistenceTests +{ + private static Nest NestWithClamps(int zoneCount) + { + var nest = new Nest("clamped"); + var plate = new Plate(48, 96); + for (var i = 0; i < zoneCount; i++) + plate.ClampZones.Add(new ClampZone($"clamp-{i}", new Box(2.25 + 10 * i, 0, 1.5, 0.625))); + nest.Plates.Add(plate); + return nest; + } + + private static Nest RoundTrip(Nest nest) + { + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + return new NestReader(stream).Read(); + } + + [Fact] + public void ZonesRoundTripWithIdsAndPositions() + { + var reopened = RoundTrip(NestWithClamps(8)); + + var plate = Assert.Single(reopened.Plates); + Assert.Equal(8, plate.ClampZones.Count); + for (var i = 0; i < 8; i++) + { + var zone = plate.ClampZones[i]; + Assert.Equal($"clamp-{i}", zone.Id); + Assert.Equal(2.25 + 10 * i, zone.Bounds.X, 9); + Assert.Equal(0, zone.Bounds.Y, 9); + Assert.Equal(1.5, zone.Bounds.Length, 9); + Assert.Equal(0.625, zone.Bounds.Width, 9); + } + } + + [Fact] + public void ClampOnlyPlateSurvivesTheEmptyPlateFilter() + { + // A sheet with no parts and no cut-offs but with clamp definitions is a real + // configuration (the machine rail exists even on an empty plate) and must persist. + var reopened = RoundTrip(NestWithClamps(3)); + + var plate = Assert.Single(reopened.Plates); + Assert.Empty(plate.Parts); + Assert.Equal(3, plate.ClampZones.Count); + } + + [Fact] + public void EmptyPlateWithoutClampsIsStillOmitted() + { + var nest = new Nest("empty"); + nest.Plates.Add(new Plate(48, 96)); + var reopened = RoundTrip(nest); + Assert.Empty(reopened.Plates); + } + + [Fact] + public void ZonesCoexistWithPlacementsAndCutOffs() + { + var nest = new Nest("mixed"); + var plate = new Plate(48, 96); + plate.ClampZones.Add(new ClampZone("clamp-0", new Box(5.25, 0, 1.5, 0.625))); + plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); + nest.Plates.Add(plate); + + var reopened = RoundTrip(nest); + + var reopenedPlate = Assert.Single(reopened.Plates); + Assert.Single(reopenedPlate.ClampZones); + Assert.Equal("clamp-0", reopenedPlate.ClampZones[0].Id); + Assert.Single(reopenedPlate.CutOffs); + } + + [Fact] + public void FileWithoutClampsKeyLoadsUnchanged() + { + // Files written before this feature have no "clamps" property at all. + var withZones = new MemoryStream(); + Assert.True(new NestWriter(NestWithClamps(2)).Write(withZones)); + var legacy = RewriteNestJson(withZones, + json => System.Text.RegularExpressions.Regex.Replace(json, @",\s*""clamps""\s*:\s*\[[^\]]*\]", "")); + + legacy.Position = 0; + var reopened = new NestReader(legacy).Read(); + + var plate = Assert.Single(reopened.Plates); + Assert.Empty(plate.Parts); + Assert.Empty(plate.ClampZones); + } + + [Fact] + public void MalformedZonesAreSkippedWithoutThrowing() + { + // Hand-edited sizes must not poison the file: drop bad entries, keep good ones. + var withZones = new MemoryStream(); + Assert.True(new NestWriter(NestWithClamps(1)).Write(withZones)); + var tampered = RewriteNestJson(withZones, json => json.Replace( + "\"clamps\": [", + "\"clamps\": [{\"id\":\"bad\",\"x\":0,\"y\":0,\"length\":-5,\"width\":0.625}," + + "{\"id\":\"\",\"x\":0,\"y\":0,\"length\":1.5,\"width\":0.625},") + ); + + tampered.Position = 0; + var reopened = new NestReader(tampered).Read(); + + var plate = Assert.Single(reopened.Plates); + var zone = Assert.Single(plate.ClampZones); + Assert.Equal("clamp-0", zone.Id); + } + + [Fact] + public void WriterEmitsCamelCaseClampsArray() + { + var withZones = new MemoryStream(); + Assert.True(new NestWriter(NestWithClamps(1)).Write(withZones)); + var json = ReadNestJson(withZones); + + Assert.Contains("\"clamps\": [", json, StringComparison.Ordinal); + Assert.Contains("\"id\": \"clamp-0\"", json, StringComparison.Ordinal); + Assert.Contains("\"length\": 1.5", json, StringComparison.Ordinal); + Assert.Contains("\"width\": 0.625", json, StringComparison.Ordinal); + } + + 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/NestFormat.cs b/OpenNest.IO/NestFormat.cs index 39fa7db..e765f2d 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -71,6 +71,25 @@ namespace OpenNest.IO public CuttingParametersDto CuttingParameters { get; init; } public List Parts { get; init; } = new(); public List CutOffs { get; init; } = new(); + + /// + /// Machine clamp keep-out rectangles in plate coordinates. Missing in older + /// files, which load with no zones. + /// + public List Clamps { get; init; } = new(); + } + + public record ClampZoneDto + { + public string Id { get; init; } = ""; + public double X { get; init; } + public double Y { get; init; } + + /// Extent along X. + public double Length { get; init; } + + /// Extent along Y (depth from the y = 0 operator-side edge). + public double Width { get; init; } } public record PartDto diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 2c60753..9fac84c 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -399,6 +399,25 @@ namespace OpenNest.IO plate.GrainAngle = p.GrainAngle; plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters); + // Clamp zones: older files have no key at all; an individually invalid + // entry is dropped rather than poisoning the whole nest. + if (p.Clamps != null) + { + foreach (var c in p.Clamps) + { + try + { + plate.ClampZones.Add( + new ClampZone(c.Id, new OpenNest.Geometry.Box(c.X, c.Y, c.Length, c.Width)) + ); + } + catch (ArgumentException) + { + // Invalid zone definition; ignore this one entry. + } + } + } + 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 e74c83b..9a0d58a 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -208,6 +208,7 @@ namespace OpenNest.IO if ( plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0 && plate.CutOffs.Count == 0 + && plate.ClampZones.Count == 0 ) continue; @@ -283,6 +284,16 @@ namespace OpenNest.IO CutOffs = cutoffs, GrainAngle = plate.GrainAngle, CuttingParameters = CuttingParametersSerializer.ToDto(plate.CuttingParameters), + Clamps = plate.ClampZones + .Select(z => new ClampZoneDto + { + Id = z.Id, + X = z.Bounds.X, + Y = z.Bounds.Y, + Length = z.Bounds.Length, + Width = z.Bounds.Width, + }) + .ToList(), } ); } diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index e6032eb..30a4a9d 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -59,6 +59,18 @@ cross-platform DTO mapping; the desktop settings serializer delegates to it so existing saved settings keep their JSON contract. Settings are independent of the saved part programs: restoring settings never regenerates those programs. +## Clamp zones + +Each plate may carry an optional `clamps` array of fixed rectangular keep-out +regions in plate coordinates: `id`, `x`, `y`, `length` (X extent) and `width` +(Y extent, the depth intrusion from the `y = 0` operator-side edge for machine +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. + ## Drawing edits `DrawingProgramSnapshot` captures serialized main and hole-program text before