diff --git a/OpenNest.IO.Tests/NestSnapshotTests.cs b/OpenNest.IO.Tests/NestSnapshotTests.cs new file mode 100644 index 00000000..879c0811 --- /dev/null +++ b/OpenNest.IO.Tests/NestSnapshotTests.cs @@ -0,0 +1,188 @@ +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.IO.Tests; + +public class NestSnapshotTests +{ + [Fact] + public void Restore_KeepsEmptyPlatesInPlace_AndEveryPlacement() + { + var live = Fixture(out var parameters); + + var restored = NestSnapshot.Capture(live).Restore(live); + + Assert.Equal(live.Plates.Count, restored.Plates.Count); + Assert.Equal(new[] { 3, 0, 1, 0 }, restored.Plates.Select(p => p.Parts.Count(x => !x.BaseDrawing.IsCutOff))); + for (var i = 0; i < live.Plates.Count; i++) + { + var expected = live.Plates[i]; + var actual = restored.Plates[i]; + Assert.Equal(expected.Size.Width, actual.Size.Width); + Assert.Equal(expected.Size.Length, actual.Size.Length); + Assert.Equal(expected.CutOffs.Count, actual.CutOffs.Count); + Assert.Equal(expected.Parts.Count, actual.Parts.Count); + for (var k = 0; k < expected.Parts.Count; k++) + { + Assert.Equal(expected.Parts[k].Location.X, actual.Parts[k].Location.X, 9); + Assert.Equal(expected.Parts[k].Location.Y, actual.Parts[k].Location.Y, 9); + Assert.Equal(expected.Parts[k].Rotation, actual.Parts[k].Rotation, 9); + Assert.Equal(expected.Parts[k].HasManualLeadIns, actual.Parts[k].HasManualLeadIns); + Assert.Equal(expected.Parts[k].LeadInsLocked, actual.Parts[k].LeadInsLocked); + } + } + + var leaded = restored.Plates[0].Parts[0]; + Assert.Equal(CuttingParametersSerializer.Serialize(parameters), + CuttingParametersSerializer.Serialize(leaded.CuttingParameters)); + Assert.Null(restored.Plates[0].Parts[1].CuttingParameters); + } + + [Fact] + public void Files_StillOmitEmptyPlates() + { + var live = Fixture(out _); + using var stream = new MemoryStream(); + + new NestWriter(live).Write(stream); + var read = new NestReader(new MemoryStream(stream.ToArray())).Read(); + + Assert.Equal(2, read.Plates.Count); + } + + [Fact] + public void Capture_LeavesTheModifiedDateAlone() + { + var live = Fixture(out _); + var date = new DateTime(2024, 3, 4, 5, 6, 7); + live.DateLastModified = date; + + var snapshot = NestSnapshot.Capture(live); + + Assert.Equal(date, live.DateLastModified); + Assert.Equal(date, snapshot.Restore(live).DateLastModified); + } + + [Fact] + public void Restore_ReusesUnchangedDrawings_AndReplacesChangedOnes() + { + var live = Fixture(out _); + var square = live.Drawings.ElementAt(0); + var bar = live.Drawings.ElementAt(1); + var snapshot = NestSnapshot.Capture(live); + bar.Quantity.Required = 99; // Changed since the copy: the copy's bar comes back. + + var restored = snapshot.Restore(live); + + Assert.Same(square, restored.Drawings.ElementAt(0)); + Assert.NotSame(bar, restored.Drawings.ElementAt(1)); + Assert.Equal(5, restored.Drawings.ElementAt(1).Quantity.Required); + var drawings = restored.Drawings.ToArray(); + Assert.All(restored.Plates.SelectMany(p => p.Parts).Where(p => !p.BaseDrawing.IsCutOff), + part => Assert.Contains(drawings, d => ReferenceEquals(d, part.BaseDrawing))); + Assert.Equal(2, square.Quantity.Nested); // Counted once, from the restored nest. + Assert.Equal(2, drawings[1].Quantity.Nested); + } + + [Fact] + public void Restore_ReplacesADrawingWhoseProgramChanged() + { + var live = Fixture(out _); + var square = live.Drawings.ElementAt(0); + var snapshot = NestSnapshot.Capture(live); + var before = NestWriter.GetProgramText(square.Program); + square.Program = ConvertGeometry.ToProgram(new List + { + new Line(0, 0, 5, 0), + new Line(5, 0, 5, 5), + new Line(5, 5, 0, 5), + new Line(0, 5, 0, 0), + }); + + var restored = snapshot.Restore(live).Drawings.ElementAt(0); + + Assert.NotSame(square, restored); + Assert.Equal(before, NestWriter.GetProgramText(restored.Program)); + } + + [Fact] + public void Restore_WithoutALiveNest_ReadsFreshDrawings() + { + var live = Fixture(out _); + + var restored = NestSnapshot.Capture(live).Restore(); + + Assert.DoesNotContain(restored.Drawings, d => live.Drawings.Any(l => ReferenceEquals(l, d))); + Assert.Equal(live.Drawings.Select(d => d.Name), restored.Drawings.Select(d => d.Name)); + } + + [Fact] + public void Restore_KeepsLeadInProgramsOnReusedDrawings() + { + var live = Fixture(out _); + var expected = NestWriter.GetProgramText(live.Plates[0].Parts[0].Program); + + var restored = NestSnapshot.Capture(live).Restore(live); + + var part = restored.Plates[0].Parts[0]; + Assert.Same(live.Drawings.ElementAt(0), part.BaseDrawing); + Assert.True(part.HasManualLeadIns); + Assert.True(part.LeadInsLocked); + Assert.Equal(expected, NestWriter.GetProgramText(part.Program)); + } + + // Plates: [3 parts, one with locked manual leads] [empty] [1 part + a cut-off] [empty]. + private static Nest Fixture(out CuttingParameters parameters) + { + var nest = new Nest("snapshot"); + nest.PlateDefaults.Size = new Size(40, 20); + var square = Square(); + var bar = new Drawing("Bar", ConvertGeometry.ToProgram(new List + { + new Line(0, 0, 8, 0), + new Line(8, 0, 8, 2), + new Line(8, 2, 0, 2), + new Line(0, 2, 0, 0), + })); + bar.Quantity.Required = 5; + nest.Drawings.Add(square); + nest.Drawings.Add(bar); + + var first = nest.CreatePlate(); + parameters = new CuttingParameters + { + ExternalLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 45 }, + InternalLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 45 }, + ExternalLeadOut = new NoLeadOut(), + InternalLeadOut = new NoLeadOut(), + }; + var leaded = new Part(square, new Vector(1, 1)); + leaded.ApplyLeadIns(parameters, Vector.Zero); + leaded.LeadInsLocked = true; + first.Parts.Add(leaded); + first.Parts.Add(new Part(square, new Vector(6, 1))); + var rotated = new Part(bar, new Vector(12, 1)); + rotated.Rotate(System.Math.PI / 2); + first.Parts.Add(rotated); + + nest.CreatePlate(); + + var third = nest.CreatePlate(); + third.Parts.Add(new Part(bar, new Vector(1, 1))); + third.CutOffs.Add(new CutOff(new Vector(20, 0), CutOffAxis.Vertical)); + third.RegenerateCutOffs(new CutOffSettings()); + + nest.CreatePlate(); + return nest; + } + + private static Drawing Square() => + new("Square", ConvertGeometry.ToProgram(new List + { + new Line(0, 0, 4, 0), + new Line(4, 0, 4, 4), + new Line(4, 4, 0, 4), + new Line(0, 4, 0, 0), + })); +} diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index d3825a38..3965773c 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -5,6 +5,7 @@ using System.IO; using System.IO.Compression; using System.Linq; using System.Text.Json; +using System.Text.Json.Nodes; using OpenNest.Bending; using OpenNest.BestFit; using OpenNest.Breakoffs; @@ -34,6 +35,12 @@ namespace OpenNest.IO /// public IBestFitStore BestFitStore { get; init; } + /// + /// Snapshot restore only: live drawings by , + /// used in place of saved drawings with the same fingerprint. + /// + internal IReadOnlyDictionary ReusableDrawings { get; init; } + public NestReader(string file) { stream = new FileStream(file, FileMode.Open, FileAccess.Read); @@ -51,9 +58,10 @@ namespace OpenNest.IO var nestJson = ReadEntry("nest.json"); var dto = JsonSerializer.Deserialize(nestJson, JsonOptions); - var programs = ReadPrograms(dto.Drawings.Count); - var entitySets = ReadEntitySets(dto.Drawings.Count); - var drawingMap = BuildDrawings(dto, programs, entitySets); + var reused = MatchReusableDrawings(nestJson); + var programs = ReadPrograms(dto.Drawings.Count, reused); + var entitySets = ReadEntitySets(dto.Drawings.Count, reused); + var drawingMap = BuildDrawings(dto, programs, entitySets, reused); ReadBestFits(drawingMap); var nest = BuildNest(dto, drawingMap); // Nests converted from PEP kept etch marks only in the placed programs; give them @@ -76,13 +84,50 @@ namespace OpenNest.IO return reader.ReadToEnd(); } - private Dictionary ReadPrograms(int count) + /// + /// Matches saved drawings to by fingerprint. A matched + /// drawing is used as it is: its program and entities are not parsed, though its + /// program hash is still recorded so saved part programs restore against it. + /// + private Dictionary MatchReusableDrawings(string nestJson) + { + var reused = new Dictionary(); + if (ReusableDrawings == null || ReusableDrawings.Count == 0) + return reused; + + var drawings = JsonNode.Parse(nestJson)?["drawings"]?.AsArray(); + if (drawings == null) + return reused; + + foreach (var node in drawings) + { + var json = node.AsObject(); + var id = json["id"].GetValue(); + var name = $"programs/program-{id}"; + if (zipArchive.GetEntry(name) == null) + continue; + + var text = ReadEntry(name); + var subs = zipArchive.GetEntry(name + "-subs") == null ? "" : ReadEntry(name + "-subs"); + var entitiesName = $"entities/entities-{id}"; + var entities = zipArchive.GetEntry(entitiesName) == null ? null : ReadEntry(entitiesName); + var fingerprint = NestWriter.GetDrawingFingerprint(json, text, subs, entities); + if (ReusableDrawings.TryGetValue(fingerprint, out var live)) + { + reused[id] = live; + drawingHashes[id] = NestWriter.GetDrawingHash(text, subs); + } + } + return reused; + } + + private Dictionary ReadPrograms(int count, Dictionary reused) { var programs = new Dictionary(); for (var i = 1; i <= count; i++) { var name = $"programs/program-{i}"; - if (zipArchive.GetEntry(name) == null) + if (reused.ContainsKey(i) || zipArchive.GetEntry(name) == null) continue; programs[i] = ReadProgram(name, out var hash); drawingHashes[i] = hash; @@ -190,14 +235,15 @@ namespace OpenNest.IO } private Dictionary entities, HashSet suppressed, Dictionary roles)> ReadEntitySets( - int count + int count, + Dictionary reused ) { var result = new Dictionary, HashSet, Dictionary)>(); for (var i = 1; i <= count; i++) { var entry = zipArchive.GetEntry($"entities/entities-{i}"); - if (entry == null) + if (entry == null || reused.ContainsKey(i)) continue; using var entryStream = entry.Open(); @@ -213,12 +259,19 @@ namespace OpenNest.IO private Dictionary BuildDrawings( NestDto dto, Dictionary programs, - Dictionary entities, HashSet suppressed, Dictionary roles)> entitySets + Dictionary entities, HashSet suppressed, Dictionary roles)> entitySets, + Dictionary reused ) { var map = new Dictionary(); foreach (var d in dto.Drawings) { + if (reused.TryGetValue(d.Id, out var live)) + { + map[d.Id] = live; + continue; + } + var drawing = new Drawing(d.Name); drawing.Customer = d.Customer; drawing.Color = Color.FromArgb(d.Color.A, d.Color.R, d.Color.G, d.Color.B); diff --git a/OpenNest.IO/NestSnapshot.cs b/OpenNest.IO/NestSnapshot.cs new file mode 100644 index 00000000..997f3f73 --- /dev/null +++ b/OpenNest.IO/NestSnapshot.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace OpenNest.IO +{ + /// + /// An in-memory copy of a whole nest for undo: the .nest archive a save would + /// write, except that empty plates are kept, best fits are left out (they are a cache) + /// and the nest's modified date is not stamped. + /// + public sealed class NestSnapshot + { + private readonly byte[] bytes; + + private NestSnapshot(byte[] bytes) => this.bytes = bytes; + + public long SizeInBytes => bytes.Length; + + /// The archive, for tests and diagnostics; never written over a user's file. + public ReadOnlySpan Bytes => bytes; + + public static NestSnapshot Capture(Nest nest) + { + ArgumentNullException.ThrowIfNull(nest); + using var stream = new MemoryStream(); + new NestWriter(nest) { PreserveDateLastModified = true, KeepEmptyPlates = true }.Write(stream); + return new NestSnapshot(stream.ToArray()); + } + + /// + /// Reads the copy back as a new nest. Each drawing of whose saved + /// content equals a drawing in the copy is used in its place, so caches keyed by the + /// drawing (best fits, fill results), its colour and its id survive; a drawing whose + /// content changed comes from the copy. Reused drawings' nested counts then follow the + /// returned nest, which replaces . + /// + public Nest Restore(Nest live = null) + { + var reusable = live == null ? null : ReusableDrawings(live); + try + { + var nest = new NestReader(new MemoryStream(bytes, writable: false)) { ReusableDrawings = reusable }.Read(); + nest.UpdateDrawingQuantities(); + return nest; + } + catch when (live != null) + { + // Reading adds parts to reused drawings; give the live nest its counts back. + live.UpdateDrawingQuantities(); + throw; + } + } + + // Fingerprints include the name, and a nest's drawings have distinct names, so each + // fingerprint names one drawing. + private static Dictionary ReusableDrawings(Nest live) + { + var fingerprints = NestWriter.GetDrawingFingerprints(live); + var result = new Dictionary(StringComparer.Ordinal); + var i = 0; + // Same order as the fingerprints: both follow the collection's enumeration. + foreach (var drawing in live.Drawings) + result.TryAdd(fingerprints[i++], drawing); + return result; + } + } +} diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index 9d77b765..ed27b15b 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -37,6 +37,12 @@ namespace OpenNest.IO /// public bool PreserveDateLastModified { get; init; } + /// + /// Snapshots only: writes every plate, empty ones included, so a restored snapshot + /// keeps the plate list and its indices. Files keep omitting empty plates. + /// + internal bool KeepEmptyPlates { get; init; } + public NestWriter(Nest nest) { this.drawingDict = new Dictionary(); @@ -344,7 +350,8 @@ namespace OpenNest.IO // sentinel to new defaults during an edit, so it remains omitted; configured // nondefault clamp sheets are still written. if ( - plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0 + !KeepEmptyPlates + && plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0 && plate.CutOffs.Count == 0 && ( (plate.ClampZones.Count == 0 && plate.RemovedClamps.Count == 0) @@ -570,17 +577,10 @@ namespace OpenNest.IO { foreach (var kvp in drawingDict.OrderBy(k => k.Key)) { - var drawing = kvp.Value; - if (drawing.SourceEntities == null || drawing.SourceEntities.Count == 0) + var json = GetEntitiesJson(kvp.Value); + if (json == null) continue; - var dto = EntitySerializer.ToDto( - drawing.SourceEntities, - drawing.SuppressedEntityIds, - drawing.EntityRoles - ); - var json = JsonSerializer.Serialize(dto, JsonOptions); - var entry = zipArchive.CreateEntry($"entities/entities-{kvp.Key}"); using var stream = entry.Open(); using var writer = new StreamWriter(stream, Encoding.UTF8); @@ -588,6 +588,62 @@ namespace OpenNest.IO } } + private static string GetEntitiesJson(Drawing drawing) + { + if (drawing.SourceEntities == null || drawing.SourceEntities.Count == 0) + return null; + + var dto = EntitySerializer.ToDto( + drawing.SourceEntities, + drawing.SuppressedEntityIds, + drawing.EntityRoles + ); + return JsonSerializer.Serialize(dto, JsonOptions); + } + + /// + /// One fingerprint per drawing of , in order, over everything a + /// save writes for it: its nest.json entry (without the archive id), program, + /// hole sub-programs and source entities. Equal fingerprints mean equal saved content. + /// + internal static List GetDrawingFingerprints(Nest nest) + { + var writer = new NestWriter(nest); + writer.SetDrawingIds(); + var dtos = writer.BuildDrawingDtos(); + var result = new List(dtos.Count); + foreach (var dto in dtos) + { + var drawing = writer.drawingDict[dto.Id]; + var node = System.Text.Json.Nodes.JsonNode.Parse(JsonSerializer.Serialize(dto, JsonOptions)); + result.Add(GetDrawingFingerprint( + node.AsObject(), + GetProgramText(drawing.Program), + GetSubProgramsText(drawing.Program), + GetEntitiesJson(drawing))); + } + return result; + } + + /// + /// Hashes a drawing's saved content. is its + /// nest.json entry as parsed from serializer output; its id is removed. + /// + internal static string GetDrawingFingerprint(System.Text.Json.Nodes.JsonObject drawingJson, + string programText, string subsText, string entitiesJson) + { + drawingJson.Remove("id"); + using var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (var part in new[] { drawingJson.ToJsonString(), programText, subsText, entitiesJson }) + { + // Length-prefixed, so no part can run into the next one. + var text = part ?? ""; + sha.AppendData(Encoding.UTF8.GetBytes( + (part == null ? "-" : text.Length.ToString(CultureInfo.InvariantCulture)) + ":" + text)); + } + return Convert.ToHexString(sha.GetHashAndReset()); + } + /// Serializes a program in its current local frame, without transforming it. public static string GetProgramText(Program program) {