mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-09 03:25:44 -05:00
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:
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user