From 3c577d4ac7d633f50c26ece238736e3cacda782e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 16 Oct 2026 11:15:19 -0400 Subject: [PATCH] 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 --- OpenNest.IO.Tests/DrawingPackageFixture.cs | 48 ++++++ OpenNest.IO.Tests/DrawingPackageTests.cs | 174 +++++++++++++++++++++ OpenNest.IO.Tests/ZeroPlateNestTests.cs | 45 ++++++ OpenNest.IO/DrawingPackage.cs | 158 +++++++++++++++++++ 4 files changed, 425 insertions(+) create mode 100644 OpenNest.IO.Tests/DrawingPackageFixture.cs create mode 100644 OpenNest.IO.Tests/DrawingPackageTests.cs create mode 100644 OpenNest.IO.Tests/ZeroPlateNestTests.cs create mode 100644 OpenNest.IO/DrawingPackage.cs diff --git a/OpenNest.IO.Tests/DrawingPackageFixture.cs b/OpenNest.IO.Tests/DrawingPackageFixture.cs new file mode 100644 index 00000000..d2b990bf --- /dev/null +++ b/OpenNest.IO.Tests/DrawingPackageFixture.cs @@ -0,0 +1,48 @@ +using OpenNest.Bending; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.IO.Tests; + +/// +/// 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. +/// +internal static class DrawingPackageFixture +{ + public static Drawing Build() + { + var entities = new List + { + 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; + } +} diff --git a/OpenNest.IO.Tests/DrawingPackageTests.cs b/OpenNest.IO.Tests/DrawingPackageTests.cs new file mode 100644 index 00000000..28430835 --- /dev/null +++ b/OpenNest.IO.Tests/DrawingPackageTests.cs @@ -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(() => 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(() => DrawingPackage.Read(Archive(empty))); + Assert.Throws(() => DrawingPackage.Read(Archive(two))); + Assert.Throws(() => 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(() => 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(() => DrawingPackage.Read(Encoding.UTF8.GetBytes("not a zip"))); + Assert.Throws(() => DrawingPackage.Read(noNestJson)); + Assert.Throws(() => 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 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 rename, Func? 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(); + } +} diff --git a/OpenNest.IO.Tests/ZeroPlateNestTests.cs b/OpenNest.IO.Tests/ZeroPlateNestTests.cs new file mode 100644 index 00000000..fc9bc352 --- /dev/null +++ b/OpenNest.IO.Tests/ZeroPlateNestTests.cs @@ -0,0 +1,45 @@ +using OpenNest.Geometry; + +namespace OpenNest.IO.Tests; + +/// +/// Characterizes a single-drawing, zero-plate .nest archive through the unchanged +/// writer and reader, which drawing packages rely on. +/// +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)); + } +} diff --git a/OpenNest.IO/DrawingPackage.cs b/OpenNest.IO/DrawingPackage.cs new file mode 100644 index 00000000..51e70101 --- /dev/null +++ b/OpenNest.IO/DrawingPackage.cs @@ -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 +{ + /// + /// A drawing library package: a .nest archive holding exactly one drawing and no + /// plates, written and read by the ordinary /. + /// 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. + /// + 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"; + + /// Packages without changing it. + 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()); + } + + /// + /// Reads a package. Throws unless it is a .nest + /// archive with exactly one drawing, no plates and supported units. + /// + 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(out var s) ? s : ""; + if (text.Length == 0 || !text.All(char.IsAsciiLetter) + || !Enum.TryParse(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()) + 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(); + } + } + + /// The drawing read from a package, the units it was drawn in, and reader warnings. + public sealed class DrawingPackageContents + { + public DrawingPackageContents(Drawing drawing, Units units, IReadOnlyList warnings) + { + Drawing = drawing; + Units = units; + Warnings = warnings; + } + + public Drawing Drawing { get; } + + public Units Units { get; } + + public IReadOnlyList Warnings { get; } + } +}