diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs index 2cf03ec6..487dcb3f 100644 --- a/OpenNest.Core/Drawing.cs +++ b/OpenNest.Core/Drawing.cs @@ -112,6 +112,12 @@ namespace OpenNest public List Bends { get; set; } = new List(); + /// + /// The library revision this drawing was copied from, or null when it did not come + /// from a drawing library. Editing the drawing in a nest keeps the link as provenance. + /// + public DrawingLibraryLink Library { get; set; } + /// /// Complete set of source entities with stable GUIDs. /// Null when the drawing was created from G-code or an older nest file. diff --git a/OpenNest.Core/DrawingLibraryLink.cs b/OpenNest.Core/DrawingLibraryLink.cs new file mode 100644 index 00000000..b0824f7f --- /dev/null +++ b/OpenNest.Core/DrawingLibraryLink.cs @@ -0,0 +1,70 @@ +using System; + +namespace OpenNest +{ + /// + /// Where a nest drawing came from in a server drawing library: the exact revision that + /// was copied in and a snapshot of its library details at that time. The nest owns its + /// copy; the link never changes the library and the library never changes the copy. + /// Immutable, so drawing copies may share one instance. + /// + public sealed class DrawingLibraryLink + { + public DrawingLibraryLink(string serverKey, Guid id, int revision, CutType cutType, double thickness, + string materialName, string materialGrade, Units units, string notes, string packageSha256) + { + if (string.IsNullOrWhiteSpace(serverKey)) + throw new ArgumentException("A library link needs the server it came from.", nameof(serverKey)); + if (id == Guid.Empty) + throw new ArgumentException("A library link needs a drawing id.", nameof(id)); + if (revision < 1) + throw new ArgumentOutOfRangeException(nameof(revision), revision, "Revisions start at 1."); + if (!Enum.IsDefined(cutType)) + throw new ArgumentOutOfRangeException(nameof(cutType), cutType, "Unsupported cut type."); + if (!double.IsFinite(thickness) || thickness < 0) + throw new ArgumentOutOfRangeException(nameof(thickness), thickness, "Thickness must be finite and not negative."); + if (!Enum.IsDefined(units)) + throw new ArgumentOutOfRangeException(nameof(units), units, "Unsupported units."); + + ServerKey = serverKey; + Id = id; + Revision = revision; + CutType = cutType; + Thickness = thickness; + MaterialName = materialName ?? ""; + MaterialGrade = materialGrade ?? ""; + Units = units; + Notes = notes ?? ""; + PackageSha256 = packageSha256 ?? ""; + } + + /// Normalized base URL of the server holding the library (no trailing slash). + public string ServerKey { get; } + + public Guid Id { get; } + + /// The library revision this drawing is a copy of. + public int Revision { get; } + + public CutType CutType { get; } + + /// Library thickness in . + public double Thickness { get; } + + public string MaterialName { get; } + + public string MaterialGrade { get; } + + public Units Units { get; } + + public string Notes { get; } + + /// Lower-case hex SHA-256 of the library package for . + public string PackageSha256 { get; } + + /// True when both links name the same revision of the same library drawing. + public bool IsSameRevision(DrawingLibraryLink other) => + other != null && Id == other.Id && Revision == other.Revision + && string.Equals(ServerKey, other.ServerKey, StringComparison.Ordinal); + } +} diff --git a/OpenNest.Core/Splitting/DrawingSplitter.cs b/OpenNest.Core/Splitting/DrawingSplitter.cs index 5901b21d..e30c5ecb 100644 --- a/OpenNest.Core/Splitting/DrawingSplitter.cs +++ b/OpenNest.Core/Splitting/DrawingSplitter.cs @@ -132,6 +132,8 @@ public static class DrawingSplitter piece.Material = source.Material; piece.Constraints = source.Constraints; piece.Customer = source.Customer; + // A piece is new geometry, not the library revision the source was copied from. + piece.Library = null; piece.Source = source.Source; piece.Quantity.Required = source.Quantity.Required; @@ -269,15 +271,15 @@ public static class DrawingSplitter var regions = new List(); for (var yi = 0; yi < yEdges.Count - 1; yi++) - for (var xi = 0; xi < xEdges.Count - 1; xi++) - regions.Add( - new Box( - xEdges[xi], - yEdges[yi], - xEdges[xi + 1] - xEdges[xi], - yEdges[yi + 1] - yEdges[yi] - ) - ); + for (var xi = 0; xi < xEdges.Count - 1; xi++) + regions.Add( + new Box( + xEdges[xi], + yEdges[yi], + xEdges[xi + 1] - xEdges[xi], + yEdges[yi + 1] - yEdges[yi] + ) + ); return regions; } diff --git a/OpenNest.Engine/CanonicalFrame.cs b/OpenNest.Engine/CanonicalFrame.cs index 09e19445..31e1bafe 100644 --- a/OpenNest.Engine/CanonicalFrame.cs +++ b/OpenNest.Engine/CanonicalFrame.cs @@ -54,6 +54,7 @@ namespace OpenNest.Engine Priority = drawing.Priority, Customer = drawing.Customer, IsCutOff = drawing.IsCutOff, + Library = drawing.Library, Source = new SourceInfo { Path = drawing.Source?.Path, diff --git a/OpenNest.IO.Tests/DrawingLibraryLinkPersistenceTests.cs b/OpenNest.IO.Tests/DrawingLibraryLinkPersistenceTests.cs new file mode 100644 index 00000000..35e7d23d --- /dev/null +++ b/OpenNest.IO.Tests/DrawingLibraryLinkPersistenceTests.cs @@ -0,0 +1,146 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; + +namespace OpenNest.IO.Tests; + +public class DrawingLibraryLinkPersistenceTests +{ + private static readonly Guid LibraryId = Guid.Parse("6d0f1c52-9a43-4f7e-8b8e-2f6f3c1d4a10"); + + private static DrawingLibraryLink Link() => new( + "http://library.test:8090", LibraryId, 3, CutType.Waterjet, 0.1875, + "Synthetic Steel", "Grade A", Units.Inches, "Deburr both sides", new string('a', 64)); + + [Fact] + public void Link_RoundTripsEveryField() + { + var drawing = DrawingPackageFixture.Build(); + drawing.Library = Link(); + + var (restored, warnings) = RoundTrip(drawing); + + Assert.Empty(warnings); + var link = Assert.Single(restored.Drawings).Library; + Assert.NotNull(link); + Assert.Equal( + ("http://library.test:8090", LibraryId, 3, CutType.Waterjet, 0.1875, "Synthetic Steel", "Grade A", + Units.Inches, "Deburr both sides", new string('a', 64)), + (link.ServerKey, link.Id, link.Revision, link.CutType, link.Thickness, link.MaterialName, + link.MaterialGrade, link.Units, link.Notes, link.PackageSha256)); + } + + [Fact] + public void NoLink_IsOmittedFromTheFile() + { + var drawing = DrawingPackageFixture.Build(); + + var json = NestJson(Write(drawing)); + var (restored, warnings) = RoundTrip(drawing); + + Assert.False(json["drawings"]![0]!.AsObject().ContainsKey("library")); + Assert.Empty(warnings); + Assert.Null(Assert.Single(restored.Drawings).Library); + } + + public static TheoryData MalformedLinks => new() + { + "\"not an object\"", + "[]", + "{\"serverKey\":\"http://library.test\",\"id\":\"not-a-guid\",\"revision\":1,\"cutType\":\"Laser\",\"units\":\"Inches\"}", + "{\"serverKey\":\"http://library.test\",\"id\":\"6d0f1c52-9a43-4f7e-8b8e-2f6f3c1d4a10\",\"revision\":\"one\",\"cutType\":\"Laser\",\"units\":\"Inches\"}", + "{\"serverKey\":\"http://library.test\",\"id\":\"6d0f1c52-9a43-4f7e-8b8e-2f6f3c1d4a10\",\"revision\":0,\"cutType\":\"Laser\",\"units\":\"Inches\"}", + "{\"serverKey\":\"\",\"id\":\"6d0f1c52-9a43-4f7e-8b8e-2f6f3c1d4a10\",\"revision\":1,\"cutType\":\"Laser\",\"units\":\"Inches\"}", + "{\"serverKey\":\"http://library.test\",\"id\":\"00000000-0000-0000-0000-000000000000\",\"revision\":1,\"cutType\":\"Laser\",\"units\":\"Inches\"}", + "{\"serverKey\":\"http://library.test\",\"id\":\"6d0f1c52-9a43-4f7e-8b8e-2f6f3c1d4a10\",\"revision\":1,\"cutType\":\"1\",\"units\":\"Inches\"}", + "{\"serverKey\":\"http://library.test\",\"id\":\"6d0f1c52-9a43-4f7e-8b8e-2f6f3c1d4a10\",\"revision\":1,\"cutType\":\"Laser,Plasma\",\"units\":\"Inches\"}", + "{\"serverKey\":\"http://library.test\",\"id\":\"6d0f1c52-9a43-4f7e-8b8e-2f6f3c1d4a10\",\"revision\":1,\"cutType\":\"Oxyfuel\",\"units\":\"Inches\"}", + "{\"serverKey\":\"http://library.test\",\"id\":\"6d0f1c52-9a43-4f7e-8b8e-2f6f3c1d4a10\",\"revision\":1,\"cutType\":\"Laser\",\"units\":\"Furlongs\"}", + "{\"serverKey\":\"http://library.test\",\"id\":\"6d0f1c52-9a43-4f7e-8b8e-2f6f3c1d4a10\",\"revision\":1,\"cutType\":\"Laser\",\"units\":\"Inches\",\"thickness\":-0.25}", + }; + + [Theory] + [MemberData(nameof(MalformedLinks))] + public void MalformedLink_LoadsTheDrawingWithoutItAndWarns(string linkJson) + { + var drawing = DrawingPackageFixture.Build(); + var archive = ReplaceLibrary(Write(drawing), JsonNode.Parse(linkJson)); + + var reader = new NestReader(new MemoryStream(archive)); + var restored = reader.Read(); + + var copy = Assert.Single(restored.Drawings); + Assert.Null(copy.Library); + Assert.Equal(NestWriter.GetProgramText(drawing.Program), NestWriter.GetProgramText(copy.Program)); + var warning = Assert.Single(reader.Warnings); + Assert.Contains("'Synthetic bracket'", warning); + Assert.Contains("library link", warning); + } + + [Fact] + public void NullLink_ReadsAsNoLinkWithoutWarning() + { + var archive = ReplaceLibrary(Write(DrawingPackageFixture.Build()), null); + + var reader = new NestReader(new MemoryStream(archive)); + var restored = reader.Read(); + + Assert.Null(Assert.Single(restored.Drawings).Library); + Assert.Empty(reader.Warnings); + } + + [Fact] + public void Package_LeavesTheLinkOutAndKeepsItsBytes() + { + var drawing = DrawingPackageFixture.Build(); + var plainPackage = DrawingPackage.Write(drawing, Units.Inches); + var link = Link(); + drawing.Library = link; + + var linkedPackage = DrawingPackage.Write(drawing, Units.Inches); + + Assert.Equal(SHA256.HashData(plainPackage), SHA256.HashData(linkedPackage)); + Assert.Null(DrawingPackage.Read(linkedPackage).Drawing.Library); + Assert.Same(link, drawing.Library); + } + + private static byte[] Write(Drawing drawing) + { + var nest = new Nest(drawing.Name) { Units = Units.Inches }; + nest.Drawings.Add(drawing); + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + return stream.ToArray(); + } + + private static (Nest Nest, IReadOnlyList Warnings) RoundTrip(Drawing drawing) + { + var reader = new NestReader(new MemoryStream(Write(drawing))); + return (reader.Read(), reader.Warnings); + } + + private static JsonObject NestJson(byte[] archive) + { + using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read); + using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8); + return JsonNode.Parse(reader.ReadToEnd())!.AsObject(); + } + + private static byte[] ReplaceLibrary(byte[] archive, JsonNode? library) + { + var json = NestJson(archive); + json["drawings"]![0]!["library"] = library; + + using var output = new MemoryStream(); + output.Write(archive); + using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true)) + { + zip.GetEntry("nest.json")!.Delete(); + using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false)); + writer.Write(json.ToJsonString()); + } + + return output.ToArray(); + } +} diff --git a/OpenNest.IO/DrawingPackage.cs b/OpenNest.IO/DrawingPackage.cs index 51e70101..81c0e189 100644 --- a/OpenNest.IO/DrawingPackage.cs +++ b/OpenNest.IO/DrawingPackage.cs @@ -14,7 +14,7 @@ namespace OpenNest.IO /// 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. + /// quantity is zero, and derived best-fit caches and library links are left out. /// public static class DrawingPackage { @@ -123,8 +123,12 @@ namespace OpenNest.IO if (root["drawings"] is JsonArray drawings) { foreach (var drawing in drawings.OfType()) + { if (drawing["quantity"] is JsonObject quantity) quantity["required"] = 0; + // A library link is nest-side provenance; the server's record describes the package. + drawing.Remove("library"); + } } return root.ToJsonString(NestFormat.JsonOptions); diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index 51262f5a..4c4b8964 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -184,6 +184,68 @@ namespace OpenNest.IO public MaterialDto Material { get; init; } = new(); public SourceDto Source { get; init; } = new(); public List Bends { get; init; } = new(); + + /// + /// The drawing library revision this drawing was copied from. Omitted when the + /// drawing did not come from a library. Read leniently: a malformed value reads + /// as and the reader drops it with a warning. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(LenientDrawingLibraryLinkConverter))] + public DrawingLibraryLinkDto Library { get; init; } + } + + public record DrawingLibraryLinkDto + { + public string ServerKey { get; init; } = ""; + public Guid Id { get; init; } + public int Revision { get; init; } + public string CutType { get; init; } = ""; + public double Thickness { get; init; } + public string MaterialName { get; init; } = ""; + public string MaterialGrade { get; init; } = ""; + public string Units { get; init; } = ""; + public string Notes { get; init; } = ""; + public string PackageSha256 { get; init; } = ""; + + /// Set only by the lenient reader for a value it could not read. + [JsonIgnore] + public bool Malformed { get; init; } + } + + /// + /// Reads a wrongly shaped or typed library link as a malformed marker instead of + /// failing the whole nest; the nest reader validates the values it receives. + /// + public sealed class LenientDrawingLibraryLinkConverter : JsonConverter + { + public override DrawingLibraryLinkDto Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + using var document = JsonDocument.ParseValue(ref reader); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return new DrawingLibraryLinkDto { Malformed = true }; + + try + { + return document.RootElement.Deserialize(options) + ?? new DrawingLibraryLinkDto { Malformed = true }; + } + catch (Exception ex) when (ex is JsonException || ex is FormatException || ex is InvalidOperationException) + { + return new DrawingLibraryLinkDto { Malformed = true }; + } + } + + // The converter sits on the property, not the type, so this does not recurse. + public override void Write( + Utf8JsonWriter writer, + DrawingLibraryLinkDto value, + JsonSerializerOptions options + ) => JsonSerializer.Serialize(writer, value, options); } public record PlateDto diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 006695ac..b2a25ac6 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -21,7 +21,7 @@ namespace OpenNest.IO private readonly Dictionary drawingHashes = new(); private readonly List warnings = new(); - /// Part programs that could not be restored; other parts still load. + /// Part programs and library links that could not be restored; everything else still loads. public IReadOnlyList Warnings => warnings.AsReadOnly(); /// @@ -273,11 +273,49 @@ namespace OpenNest.IO } } + drawing.Library = ReadLibraryLink(d); map[d.Id] = drawing; } return map; } + // A link that cannot be fully trusted is dropped: the drawing loads as a plain nest + // drawing, which is what it is without a verifiable library revision. + private DrawingLibraryLink ReadLibraryLink(DrawingDto d) + { + var link = d.Library; + if (link == null) + return null; + + if (!link.Malformed + && !string.IsNullOrWhiteSpace(link.ServerKey) + && link.Id != Guid.Empty + && link.Revision >= 1 + && TryParseName(link.CutType, out var cutType) + && TryParseName(link.Units, out var units) + && double.IsFinite(link.Thickness) + && link.Thickness >= 0) + { + return new DrawingLibraryLink(link.ServerKey, link.Id, link.Revision, cutType, link.Thickness, + link.MaterialName, link.MaterialGrade, units, link.Notes, link.PackageSha256); + } + + warnings.Add($"Drawing '{d.Name}': its drawing library link could not be read; " + + "loaded it as a nest drawing without a library revision."); + return null; + } + + // Single enum names only: Enum.TryParse also accepts numbers and comma lists. + private static bool TryParseName(string text, out T value) + where T : struct, Enum + { + value = default; + return !string.IsNullOrEmpty(text) + && text.All(char.IsAsciiLetter) + && Enum.TryParse(text, ignoreCase: false, out value) + && Enum.IsDefined(value); + } + private void ReadBestFits(Dictionary drawingMap) { if (BestFitStore == null) diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index 4e2413b1..be58f49b 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -198,12 +198,30 @@ namespace OpenNest.IO }) .ToList() ?? new List(), + Library = ToLibraryLinkDto(d.Library), } ); } return list; } + private static DrawingLibraryLinkDto ToLibraryLinkDto(DrawingLibraryLink link) => + link == null + ? null + : new DrawingLibraryLinkDto + { + ServerKey = link.ServerKey, + Id = link.Id, + Revision = link.Revision, + CutType = link.CutType.ToString(), + Thickness = link.Thickness, + MaterialName = link.MaterialName, + MaterialGrade = link.MaterialGrade, + Units = link.Units.ToString(), + Notes = link.Notes, + PackageSha256 = link.PackageSha256, + }; + /// /// True when a plate created from the saved defaults would be this sheet's clamp /// configuration: same size, quadrant, edge and part spacing, no last-resort diff --git a/OpenNest.Tests/DrawingLibraryLinkTests.cs b/OpenNest.Tests/DrawingLibraryLinkTests.cs new file mode 100644 index 00000000..8580e26c --- /dev/null +++ b/OpenNest.Tests/DrawingLibraryLinkTests.cs @@ -0,0 +1,70 @@ +using OpenNest.Engine; +using OpenNest.Shapes; + +namespace OpenNest.Tests; + +public class DrawingLibraryLinkTests +{ + private static readonly Guid LibraryId = Guid.Parse("0b3c5a8e-41d2-4c6f-9e0a-7d1b2c3e4f50"); + + private static DrawingLibraryLink Link(int revision = 2) => new( + "http://library.test:8090", LibraryId, revision, CutType.Laser, 0.25, + "Synthetic Steel", "Grade A", Units.Inches, "", ""); + + [Fact] + public void Constructor_RefusesAnIncompleteLink() + { + Assert.Throws(() => new DrawingLibraryLink( + " ", LibraryId, 1, CutType.Laser, 0, "", "", Units.Inches, "", "")); + Assert.Throws(() => new DrawingLibraryLink( + "http://library.test", Guid.Empty, 1, CutType.Laser, 0, "", "", Units.Inches, "", "")); + Assert.Throws(() => new DrawingLibraryLink( + "http://library.test", LibraryId, 0, CutType.Laser, 0, "", "", Units.Inches, "", "")); + Assert.Throws(() => new DrawingLibraryLink( + "http://library.test", LibraryId, 1, (CutType)99, 0, "", "", Units.Inches, "", "")); + Assert.Throws(() => new DrawingLibraryLink( + "http://library.test", LibraryId, 1, CutType.Laser, double.NaN, "", "", Units.Inches, "", "")); + Assert.Throws(() => new DrawingLibraryLink( + "http://library.test", LibraryId, 1, CutType.Laser, -0.1, "", "", Units.Inches, "", "")); + Assert.Throws(() => new DrawingLibraryLink( + "http://library.test", LibraryId, 1, CutType.Laser, 0, "", "", (Units)99, "", "")); + } + + [Fact] + public void IsSameRevision_ComparesServerIdAndRevision() + { + var link = Link(); + + Assert.True(link.IsSameRevision(Link())); + Assert.False(link.IsSameRevision(Link(revision: 3))); + Assert.False(link.IsSameRevision(new DrawingLibraryLink( + "http://other.test:8090", LibraryId, 2, CutType.Laser, 0.25, "", "", Units.Inches, "", ""))); + Assert.False(link.IsSameRevision(null!)); + } + + [Fact] + public void CanonicalCopy_KeepsTheLink() + { + var drawing = new RectangleShape { Name = "LINKED", Length = 10, Width = 5 }.GetDrawing(); + drawing.Library = Link(); + + var copy = CanonicalFrame.AsCanonicalCopy(drawing); + + Assert.Same(drawing.Library, copy.Library); + } + + [Fact] + public void SplitPieces_AreNotTheLibraryDrawing() + { + var drawing = new RectangleShape { Name = "LINKED", Length = 100, Width = 50 }.GetDrawing(); + drawing.Library = Link(); + + var pieces = DrawingSplitter.Split(drawing, + new List { new(50.0, CutOffAxis.Vertical) }, + new SplitParameters { Type = SplitType.Straight }); + + Assert.Equal(2, pieces.Count); + Assert.All(pieces, piece => Assert.Null(piece.Library)); + Assert.NotNull(drawing.Library); + } +} diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index 0682fbef..9b302cf8 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -145,6 +145,18 @@ without one, a reader skips them unparsed and a writer writes none. The front ends pass the engine's `BestFitCacheStore.Shared`, which adds loaded sets to the process-wide `BestFitCache` without replacing sets already computed. +## Drawing library links + +A drawing copied from a server drawing library carries a `library` object: +`serverKey` (the normalized server URL), `id`, `revision`, and a snapshot of that +revision's `cutType`, `thickness`, `materialName`, `materialGrade`, `units`, `notes` +and `packageSha256`. It is omitted for other drawings. Read leniently: a value that +is not an object, has the wrong types, a blank server key, an empty id, a revision +below 1, a cut type or units that are not one supported name, or a negative +thickness loads the drawing without a link and adds a reader warning. Converter +edits keep the link as provenance; split pieces get none. Drawing packages leave +the link out, so linking a drawing does not change its package bytes. + ## Verification and limits The automated tests cover save/reopen/re-save, rotated/manual/locked parts,