feat(io): persist the nest clamp rail

Write the nest's machine clamp rail under plateDefaults.clampRail
(width, depth, centers) so plates created or resized after a reload
still get the clamps. The key is omitted for nests without a rail, and
a missing, empty or malformed rail loads as none while each plate's
saved zones still load.

On load a plate follows the rail again only when its saved zones are
exactly the rail's for that sheet; other zones are kept as written.
The empty-plate filter now drops an empty sheet whose zones the saved
rail recreates, so the editor's trailing empty sheet is not written
for every clamped nest; empty sheets with other zones are still kept.
This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent 81e3e58f13
commit 5674764447
6 files changed
+252 -5

No files matched your search

@@ -0,0 +1,175 @@
using System.IO.Compression;
using System.Text;
using OpenNest.Geometry;
namespace OpenNest.IO.Tests;
/// <summary>
/// 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).
/// </summary>
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);
/// <summary>A rail nest whose first sheet is kept by a cut-off; the second is empty.</summary>
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<string, string> 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;
}
}
@@ -7,7 +7,8 @@ namespace OpenNest.IO.Tests;
/// <summary>
/// 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.
/// </summary>
public class ClampZonePersistenceTests
{
+20
View File
@@ -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();
/// <summary>
/// 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.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ClampRailDto ClampRail { get; init; }
}
public record ClampRailDto
{
/// <summary>Clamp extent along the origin-side edge (X).</summary>
public double Width { get; init; }
/// <summary>Clamp intrusion into the sheet from that edge (Y).</summary>
public double Depth { get; init; }
/// <summary>Clamp centers along X, measured from the plate origin.</summary>
public List<double> Centers { get; init; } = new();
}
public record DrawingDto
+25
View File
@@ -327,6 +327,24 @@ namespace OpenNest.IO
}
}
/// <summary>
/// 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).
/// </summary>
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<int, Drawing> 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];
+14 -1
View File
@@ -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<PlateDto> BuildPlateDtos()
{
var list = new List<PlateDto>();
@@ -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;
+16 -3
View File
@@ -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