mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-14 18:21:55 -04:00
feat(io): persist clamp zones in nest files
Add the additive per-plate clamps array (id, x, y, length, width) to the nest DTO with writer and reader support. Files without the key load with zero zones; an individually malformed entry is dropped while the rest of the nest loads. A plate carrying only clamp definitions now survives the writer's empty-plate filter; genuinely empty plates remain omitted. Format documented in docs/nest-file-format.md.
This commit is contained in:
1 parent
c16092c293
commit
d001abcf69
5 files changed
+236
No files matched your search
@@ -0,0 +1,175 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,25 @@ namespace OpenNest.IO
|
||||
public CuttingParametersDto CuttingParameters { get; init; }
|
||||
public List<PartDto> Parts { get; init; } = new();
|
||||
public List<CutOffDto> CutOffs { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Machine clamp keep-out rectangles in plate coordinates. Missing in older
|
||||
/// files, which load with no zones.
|
||||
/// </summary>
|
||||
public List<ClampZoneDto> Clamps { get; init; } = new();
|
||||
}
|
||||
|
||||
public record ClampZoneDto
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
|
||||
/// <summary>Extent along X.</summary>
|
||||
public double Length { get; init; }
|
||||
|
||||
/// <summary>Extent along Y (depth from the y = 0 operator-side edge).</summary>
|
||||
public double Width { get; init; }
|
||||
}
|
||||
|
||||
public record PartDto
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user