mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-17 00:31:37 -04:00
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:
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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user