feat(io): drawing package

Library drawings travel as a single-drawing, zero-plate .nest archive so the
server stores opaque bytes and the client reuses NestWriter/NestReader. A
characterization test first proves that shape round-trips the program, hole
programs, source entities and suppression, offset, material, bends and units.

Packages are canonical: fixed archive and nest timestamps, zero nest quantity
and no best-fit caches. The same drawing content therefore has the same bytes
and SHA-256, which later revision checks rely on to tell geometry changes from
detail-only edits. Read checks the archive's own declaration before the lenient
nest reader, which would otherwise default unknown units and drop empty plates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-16 15:33:02 -04:00
1 parent 76133193df
commit 3c577d4ac7
4 files changed
+425

No files matched your search

@@ -0,0 +1,48 @@
using OpenNest.Bending;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.IO.Tests;
/// <summary>
/// A code-built, neutrally named library drawing: a rounded outside, a hole, a scribe
/// mark, a bend, source entities with one suppressed, and a nonzero source offset.
/// </summary>
internal static class DrawingPackageFixture
{
public static Drawing Build()
{
var entities = new List<Entity>
{
new Line(0, 0, 10, 0),
new Line(10, 0, 10, 4),
new Arc(8, 4, 2, 0, System.Math.PI / 2),
new Line(8, 6, 0, 6),
new Line(0, 6, 0, 0),
new Circle(3, 3, 1),
new Line(5, 2, 7, 2) { Layer = SpecialLayers.Scribe },
new Line(20, 20, 21, 21),
};
var drawing = new Drawing("Synthetic bracket", ConvertGeometry.ToProgram(entities.Take(7).ToList()))
{
Customer = "Synthetic customer",
Material = new Material("Synthetic Steel") { Grade = "Grade A", Density = 0.284 },
Priority = 3,
SourceEntities = entities,
};
drawing.SuppressedEntityIds.Add(entities[7].Id);
drawing.Source.Path = "synthetic.dxf";
drawing.Source.Offset = new Vector(3.5, -2.25);
drawing.Bends.Add(new Bend
{
StartPoint = new Vector(0, 3),
EndPoint = new Vector(10, 3),
Direction = BendDirection.Up,
Angle = 90,
Radius = 0.125,
NoteText = "UP 90",
});
drawing.Quantity.Required = 12;
return drawing;
}
}
+174
View File
@@ -0,0 +1,174 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using OpenNest.Geometry;
namespace OpenNest.IO.Tests;
public class DrawingPackageTests
{
[Fact]
public void Package_RoundTripsTheDrawingAndUnits()
{
var drawing = DrawingPackageFixture.Build();
var contents = DrawingPackage.Read(DrawingPackage.Write(drawing, Units.Millimeters));
Assert.Empty(contents.Warnings);
Assert.Equal(Units.Millimeters, contents.Units);
var copy = contents.Drawing;
Assert.NotSame(drawing, copy);
Assert.Equal(NestWriter.GetProgramText(drawing.Program), NestWriter.GetProgramText(copy.Program));
Assert.Equal(NestWriter.GetSubProgramsText(drawing.Program), NestWriter.GetSubProgramsText(copy.Program));
Assert.Equal(drawing.Name, copy.Name);
Assert.Equal(drawing.Customer, copy.Customer);
Assert.Equal((drawing.Material.Name, drawing.Material.Grade, drawing.Material.Density),
(copy.Material.Name, copy.Material.Grade, copy.Material.Density));
Assert.Equal(drawing.Source.Offset, copy.Source.Offset);
Assert.Equal(drawing.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)),
copy.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)));
Assert.True(drawing.SuppressedEntityIds.SetEquals(copy.SuppressedEntityIds));
var bend = Assert.Single(copy.Bends);
Assert.Equal((new Vector(0, 3), new Vector(10, 3), "UP 90"), (bend.StartPoint, bend.EndPoint, bend.NoteText));
// A library drawing has no nest demand of its own.
Assert.Equal(0, copy.Quantity.Required);
}
[Fact]
public void Package_DoesNotChangeTheDrawing()
{
var drawing = DrawingPackageFixture.Build();
var program = NestWriter.GetProgramText(drawing.Program);
DrawingPackage.Write(drawing, Units.Inches);
Assert.Equal(12, drawing.Quantity.Required);
Assert.Equal(program, NestWriter.GetProgramText(drawing.Program));
Assert.Equal("Synthetic bracket", drawing.Name);
}
[Fact]
public void Package_IsCanonical_SameContentSameBytes()
{
// Source entity ids are content, so the same drawing instance is written twice.
var drawing = DrawingPackageFixture.Build();
var first = DrawingPackage.Write(drawing, Units.Inches);
drawing.Quantity.Required = 40;
Thread.Sleep(2100); // ZIP times have two-second resolution; prove they are not recorded.
var second = DrawingPackage.Write(drawing, Units.Inches);
Assert.Equal(Sha(first), Sha(second));
Assert.Equal(first, DrawingPackage.Write(DrawingPackage.Read(first).Drawing, Units.Inches));
using var archive = new ZipArchive(new MemoryStream(first));
Assert.All(archive.Entries, e => Assert.Equal(new DateTime(1980, 1, 1), e.LastWriteTime.DateTime));
Assert.DoesNotContain(archive.Entries, e => e.FullName.StartsWith("bestfits/", StringComparison.Ordinal));
}
[Fact]
public void Package_ChangedContentOrUnits_ChangesBytes()
{
var drawing = DrawingPackageFixture.Build();
var baseline = Sha(DrawingPackage.Write(drawing, Units.Inches));
Assert.NotEqual(baseline, Sha(DrawingPackage.Write(drawing, Units.Millimeters)));
drawing.Customer = "Other customer";
var renamed = Sha(DrawingPackage.Write(drawing, Units.Inches));
Assert.NotEqual(baseline, renamed);
drawing.Bends[0].EndPoint = new Vector(10, 3.5);
Assert.NotEqual(renamed, Sha(DrawingPackage.Write(drawing, Units.Inches)));
}
[Fact]
public void Write_RejectsUndefinedUnits()
{
Assert.Throws<ArgumentOutOfRangeException>(() => DrawingPackage.Write(DrawingPackageFixture.Build(), (Units)9));
}
[Fact]
public void Read_RefusesNestsThatAreNotOneDrawingWithoutPlates()
{
var empty = new Nest("empty");
var two = new Nest("two");
two.Drawings.Add(DrawingPackageFixture.Build());
var second = DrawingPackageFixture.Build();
second.Name = "Second";
two.Drawings.Add(second);
// The writer omits an empty plate, so the plate carries a part.
var withPlate = new Nest("plate");
var placed = DrawingPackageFixture.Build();
withPlate.Drawings.Add(placed);
var plate = new Plate(48, 96);
plate.Parts.Add(new Part(placed));
withPlate.Plates.Add(plate);
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(Archive(empty)));
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(Archive(two)));
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(Archive(withPlate)));
}
[Theory]
[InlineData("Feet")]
[InlineData("1")]
[InlineData("Inches,Millimeters")]
[InlineData("")]
public void Read_RefusesUnsupportedUnits(string units)
{
var package = EditNestJson(DrawingPackage.Write(DrawingPackageFixture.Build(), Units.Inches),
root => root["units"] = units);
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(package));
}
[Fact]
public void Read_RefusesMalformedArchives()
{
var valid = DrawingPackage.Write(DrawingPackageFixture.Build(), Units.Inches);
var noNestJson = Rewrite(valid, name => name == "nest.json" ? null : name);
var badJson = Rewrite(valid, name => name, (name, bytes) => name == "nest.json" ? Encoding.UTF8.GetBytes("{") : bytes);
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(Encoding.UTF8.GetBytes("not a zip")));
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(noNestJson));
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(badJson));
}
private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes));
private static byte[] Archive(Nest nest)
{
using var stream = new MemoryStream();
new NestWriter(nest).Write(stream);
return stream.ToArray();
}
private static byte[] EditNestJson(byte[] package, Action<JsonObject> edit) =>
Rewrite(package, name => name, (name, bytes) =>
{
if (name != "nest.json") return bytes;
var root = JsonNode.Parse(Encoding.UTF8.GetString(bytes))!.AsObject();
edit(root);
return Encoding.UTF8.GetBytes(root.ToJsonString());
});
private static byte[] Rewrite(byte[] package, Func<string, string?> rename, Func<string, byte[], byte[]>? transform = null)
{
using var source = new ZipArchive(new MemoryStream(package));
using var output = new MemoryStream();
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in source.Entries)
{
var name = rename(entry.FullName);
if (name is null) continue;
using var input = entry.Open();
using var copy = new MemoryStream();
input.CopyTo(copy);
var bytes = transform is null ? copy.ToArray() : transform(entry.FullName, copy.ToArray());
using var stream = target.CreateEntry(name).Open();
stream.Write(bytes);
}
}
return output.ToArray();
}
}
+45
View File
@@ -0,0 +1,45 @@
using OpenNest.Geometry;
namespace OpenNest.IO.Tests;
/// <summary>
/// Characterizes a single-drawing, zero-plate .nest archive through the unchanged
/// writer and reader, which drawing packages rely on.
/// </summary>
public class ZeroPlateNestTests
{
[Fact]
public void SingleDrawingWithoutPlates_RoundTripsTheDrawing()
{
var drawing = DrawingPackageFixture.Build();
var nest = new Nest(drawing.Name) { Units = Units.Millimeters };
nest.Drawings.Add(drawing);
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var reader = new NestReader(stream);
var restored = reader.Read();
Assert.Empty(reader.Warnings);
Assert.Empty(restored.Plates);
Assert.Equal(Units.Millimeters, restored.Units);
var copy = Assert.Single(restored.Drawings);
Assert.Equal(NestWriter.GetProgramText(drawing.Program), NestWriter.GetProgramText(copy.Program));
Assert.Equal(NestWriter.GetSubProgramsText(drawing.Program), NestWriter.GetSubProgramsText(copy.Program));
Assert.Equal(drawing.Name, copy.Name);
Assert.Equal(drawing.Customer, copy.Customer);
Assert.Equal((drawing.Material.Name, drawing.Material.Grade, drawing.Material.Density),
(copy.Material.Name, copy.Material.Grade, copy.Material.Density));
Assert.Equal(drawing.Priority, copy.Priority);
Assert.Equal(drawing.Quantity.Required, copy.Quantity.Required);
Assert.Equal(drawing.Source.Path, copy.Source.Path);
Assert.Equal(drawing.Source.Offset, copy.Source.Offset);
Assert.Equal(drawing.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)),
copy.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)));
Assert.True(drawing.SuppressedEntityIds.SetEquals(copy.SuppressedEntityIds));
var bend = Assert.Single(copy.Bends);
Assert.Equal((new Vector(0, 3), new Vector(10, 3), (double?)90, (double?)0.125, "UP 90"),
(bend.StartPoint, bend.EndPoint, bend.Angle, bend.Radius, bend.NoteText));
}
}
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace OpenNest.IO
{
/// <summary>
/// A drawing library package: a .nest archive holding exactly one drawing and no
/// plates, written and read by the ordinary <see cref="NestWriter"/>/<see cref="NestReader"/>.
/// Packages are canonical, so the same drawing content always has the same bytes and
/// SHA-256: archive timestamps and nest dates are fixed, the nest-specific required
/// quantity is zero, and derived best-fit caches are left out.
/// </summary>
public static class DrawingPackage
{
// The earliest time a ZIP entry can record.
private static readonly DateTimeOffset EntryTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);
private const string FixedDate = "2000-01-01T00:00:00.0000000";
/// <summary>Packages <paramref name="drawing"/> without changing it.</summary>
public static byte[] Write(Drawing drawing, Units units)
{
ArgumentNullException.ThrowIfNull(drawing);
if (!Enum.IsDefined(units))
throw new ArgumentOutOfRangeException(nameof(units), units, "Unsupported units.");
var nest = new Nest(drawing.Name ?? "") { Units = units };
nest.Drawings.Add(drawing);
using var written = new MemoryStream();
new NestWriter(nest).Write(written);
return Canonicalize(written.ToArray());
}
/// <summary>
/// Reads a package. Throws <see cref="InvalidDataException"/> unless it is a .nest
/// archive with exactly one drawing, no plates and supported units.
/// </summary>
public static DrawingPackageContents Read(byte[] package)
{
ArgumentNullException.ThrowIfNull(package);
try
{
var units = CheckShape(package);
var reader = new NestReader(new MemoryStream(package, writable: false));
var nest = reader.Read();
if (nest.Plates.Count != 0 || nest.Drawings.Count != 1)
throw new InvalidDataException("A drawing package must hold exactly one drawing and no plates.");
return new DrawingPackageContents(nest.Drawings.Single(), units, reader.Warnings.ToList());
}
catch (Exception ex) when (ex is JsonException || ex is FormatException || ex is ArgumentException
|| (ex is IOException && ex is not InvalidDataException))
{
throw new InvalidDataException("The drawing package is not a readable .nest archive.", ex);
}
}
// Checks the archive's own declaration before the lenient nest reader sees it: that
// reader defaults unknown units and drops empty plates, which a package must not hide.
private static Units CheckShape(byte[] package)
{
using var archive = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
var entry = archive.GetEntry("nest.json")
?? throw new InvalidDataException("The drawing package has no nest.json.");
var root = JsonNode.Parse(ReadText(entry)) as JsonObject
?? throw new InvalidDataException("The drawing package's nest.json is not an object.");
if (root["plates"] is JsonArray plates && plates.Count != 0)
throw new InvalidDataException("A drawing package must not contain plates.");
if (root["drawings"] is not JsonArray drawings || drawings.Count != 1)
throw new InvalidDataException("A drawing package must contain exactly one drawing.");
var text = root["units"] is JsonValue value && value.TryGetValue<string>(out var s) ? s : "";
if (text.Length == 0 || !text.All(char.IsAsciiLetter)
|| !Enum.TryParse<Units>(text, ignoreCase: true, out var units) || !Enum.IsDefined(units))
throw new InvalidDataException($"The drawing package has unsupported units '{text}'.");
return units;
}
private static byte[] Canonicalize(byte[] archive)
{
using var source = new ZipArchive(new MemoryStream(archive, writable: false), ZipArchiveMode.Read);
using var output = new MemoryStream();
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in source.Entries.OrderBy(e => e.FullName, StringComparer.Ordinal))
{
if (entry.FullName.StartsWith("bestfits/", StringComparison.Ordinal))
continue;
byte[] bytes;
if (entry.FullName == "nest.json")
{
bytes = Encoding.UTF8.GetBytes(CanonicalNestJson(ReadText(entry)));
}
else
{
using var input = entry.Open();
using var copy = new MemoryStream();
input.CopyTo(copy);
bytes = copy.ToArray();
}
var written = target.CreateEntry(entry.FullName, CompressionLevel.Optimal);
written.LastWriteTime = EntryTimestamp;
using var stream = written.Open();
stream.Write(bytes, 0, bytes.Length);
}
}
return output.ToArray();
}
private static string CanonicalNestJson(string json)
{
var root = JsonNode.Parse(json)!.AsObject();
root["dateCreated"] = FixedDate;
root["dateLastModified"] = FixedDate;
if (root["drawings"] is JsonArray drawings)
{
foreach (var drawing in drawings.OfType<JsonObject>())
if (drawing["quantity"] is JsonObject quantity)
quantity["required"] = 0;
}
return root.ToJsonString(NestFormat.JsonOptions);
}
// StreamReader honours the byte-order mark the nest writer emits.
private static string ReadText(ZipArchiveEntry entry)
{
using var stream = entry.Open();
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd();
}
}
/// <summary>The drawing read from a package, the units it was drawn in, and reader warnings.</summary>
public sealed class DrawingPackageContents
{
public DrawingPackageContents(Drawing drawing, Units units, IReadOnlyList<string> warnings)
{
Drawing = drawing;
Units = units;
Warnings = warnings;
}
public Drawing Drawing { get; }
public Units Units { get; }
public IReadOnlyList<string> Warnings { get; }
}
}