diff --git a/OpenNest.Api/NestResponse.cs b/OpenNest.Api/NestResponse.cs index 9f78d106..0aa0e7c7 100644 --- a/OpenNest.Api/NestResponse.cs +++ b/OpenNest.Api/NestResponse.cs @@ -5,6 +5,7 @@ using System.IO.Compression; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; +using OpenNest.Engine.BestFit; using OpenNest.Engine.Jobs; using OpenNest.IO; @@ -101,7 +102,7 @@ public class NestResponse var nestEntry = zip.CreateEntry("nest.nest"); using var nestMs = new MemoryStream(); - new NestWriter(Nest).Write(nestMs); + new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(nestMs); nestMs.Position = 0; await using (var stream = nestEntry.Open()) { @@ -158,7 +159,7 @@ public class NestResponse await stream.CopyToAsync(nestMs); } nestMs.Position = 0; - nest = new NestReader(nestMs).Read(); + nest = new NestReader(nestMs) { BestFitStore = BestFitCacheStore.Shared }.Read(); } return new NestResponse diff --git a/OpenNest.Benchmark/BenchmarkRunner.cs b/OpenNest.Benchmark/BenchmarkRunner.cs index 484d4de2..a1981e5e 100644 --- a/OpenNest.Benchmark/BenchmarkRunner.cs +++ b/OpenNest.Benchmark/BenchmarkRunner.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; +using OpenNest.Engine.BestFit; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Adapters; @@ -290,7 +291,7 @@ namespace OpenNest.Benchmark // Manifest jobs have no source nest to copy from, so they keep the job's name. if (job.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase)) { - var source = new OpenNest.IO.NestReader(job.SourceFile).Read(); + var source = new OpenNest.IO.NestReader(job.SourceFile) { BestFitStore = BestFitCacheStore.Shared }.Read(); materialized.Nest.Name = source.Name; materialized.Nest.Units = source.Units; materialized.Nest.Material = source.Material; @@ -316,7 +317,7 @@ namespace OpenNest.Benchmark throw new InvalidOperationException( "Output must not overwrite the source nest." ); - new OpenNest.IO.NestWriter(materialized.Nest).Write(path); + new OpenNest.IO.NestWriter(materialized.Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(path); var report = new { Source = job.SourceFile, diff --git a/OpenNest.Benchmark/JobLoader.cs b/OpenNest.Benchmark/JobLoader.cs index 20e160a3..875bde57 100644 --- a/OpenNest.Benchmark/JobLoader.cs +++ b/OpenNest.Benchmark/JobLoader.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using OpenNest.Engine.BestFit; using OpenNest.Geometry; using OpenNest.IO; @@ -39,7 +40,7 @@ namespace OpenNest.Benchmark try { - nest = new NestReader(file).Read(); + nest = new NestReader(file) { BestFitStore = BestFitCacheStore.Shared }.Read(); } catch (Exception ex) { diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index ddc7bdce..3f5a314e 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -9,6 +9,7 @@ using System.Threading; using OpenNest; using OpenNest.Diagnostics; using OpenNest.Engine; +using OpenNest.Engine.BestFit; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Placement; using OpenNest.Geometry; @@ -273,7 +274,7 @@ static class NestConsole // If we have a nest file, load it and optionally add DXFs. if (nestFile != null) { - var nest = new NestReader(nestFile).Read(); + var nest = new NestReader(nestFile) { BestFitStore = BestFitCacheStore.Shared }.Read(); if (nest.Plates.Count == 0) { @@ -377,7 +378,7 @@ static class NestConsole return; } - var templateNest = new NestReader(options.TemplateFile).Read(); + var templateNest = new NestReader(options.TemplateFile) { BestFitStore = BestFitCacheStore.Shared }.Read(); var templatePlate = templateNest.PlateDefaults.CreateNew(); plate.Quadrant = templatePlate.Quadrant; plate.EdgeSpacing = templatePlate.EdgeSpacing; @@ -599,7 +600,7 @@ static class NestConsole var outputFile = NestOutputPath(options); - new NestWriter(nest).Write(outputFile); + new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }.Write(outputFile); Console.WriteLine($"Saved: {outputFile}"); } diff --git a/OpenNest.Core/BestFit/IBestFitStore.cs b/OpenNest.Core/BestFit/IBestFitStore.cs new file mode 100644 index 00000000..47a2731a --- /dev/null +++ b/OpenNest.Core/BestFit/IBestFitStore.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; + +namespace OpenNest.BestFit +{ + /// One saved best-fit pair result, as persisted in a .nest archive. + public sealed record SavedPairResult( + double Part1Rotation, + double Part2Rotation, + double Part2OffsetX, + double Part2OffsetY, + int StrategyType, + int TestNumber, + double CandidateSpacing, + double RotatedArea, + double BoundingWidth, + double BoundingHeight, + double OptimalRotation, + bool Keep, + string Reason, + double TrueArea, + IReadOnlyList HullAngles + ); + + /// The saved best-fit results for one drawing at one plate size and spacing. + public sealed record SavedPairSet( + double PlateWidth, + double PlateHeight, + double Spacing, + IReadOnlyList Results + ); + + /// + /// Persists best-fit pair caches across .nest round trips. Implementations own their + /// cache; readers and writers never assume one exists, and a file without saved sets is + /// always valid because the data can be recomputed. + /// + public interface IBestFitStore + { + /// + /// Sets to save for , limited to the given + /// (plate width, plate length, part spacing) keys. + /// + IReadOnlyList Export( + Drawing drawing, + IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys + ); + + /// Restores saved sets for . + void Import(Drawing drawing, IReadOnlyList sets); + } +} diff --git a/OpenNest.Engine/BestFit/BestFitCacheStore.cs b/OpenNest.Engine/BestFit/BestFitCacheStore.cs new file mode 100644 index 00000000..d860e425 --- /dev/null +++ b/OpenNest.Engine/BestFit/BestFitCacheStore.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.BestFit; +using OpenNest.Geometry; + +namespace OpenNest.Engine.BestFit +{ + /// + /// Saves and restores entries for .nest archives. Only kept + /// results are exported; imported sets are added without replacing computed ones. + /// + public sealed class BestFitCacheStore : IBestFitStore + { + /// The store over the process-wide . + public static BestFitCacheStore Shared { get; } = new(); + + private BestFitCacheStore() { } + + public IReadOnlyList Export( + Drawing drawing, + IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys + ) + { + var allBestFits = BestFitCache.GetAllForDrawing(drawing); + var sets = new List(); + + foreach (var kvp in allBestFits) + { + if (!plateKeys.Contains((kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing))) + continue; + + var results = kvp + .Value.Where(r => r.Keep) + .Select(r => new SavedPairResult( + r.Candidate.Part1Rotation, + r.Candidate.Part2Rotation, + r.Candidate.Part2Offset.X, + r.Candidate.Part2Offset.Y, + r.Candidate.StrategyIndex, + r.Candidate.TestNumber, + r.Candidate.Spacing, + r.RotatedArea, + r.BoundingWidth, + r.BoundingHeight, + r.OptimalRotation, + r.Keep, + r.Reason, + r.TrueArea, + r.HullAngles + )) + .ToList(); + + sets.Add(new SavedPairSet(kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing, results)); + } + + return sets; + } + + public void Import(Drawing drawing, IReadOnlyList sets) + { + foreach (var set in sets) + { + var results = set + .Results.Select(r => new BestFitResult + { + Candidate = new PairCandidate + { + Drawing = drawing, + Part1Rotation = r.Part1Rotation, + Part2Rotation = r.Part2Rotation, + Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY), + StrategyIndex = r.StrategyType, + TestNumber = r.TestNumber, + Spacing = r.CandidateSpacing, + }, + RotatedArea = r.RotatedArea, + BoundingWidth = r.BoundingWidth, + BoundingHeight = r.BoundingHeight, + OptimalRotation = r.OptimalRotation, + Keep = r.Keep, + Reason = r.Reason, + TrueArea = r.TrueArea, + HullAngles = r.HullAngles?.ToList(), + }) + .ToList(); + + BestFitCache.Populate(drawing, set.PlateWidth, set.PlateHeight, set.Spacing, results); + } + } + } +} diff --git a/OpenNest.IO.Tests/BestFitStorePersistenceTests.cs b/OpenNest.IO.Tests/BestFitStorePersistenceTests.cs new file mode 100644 index 00000000..0cf0d5f8 --- /dev/null +++ b/OpenNest.IO.Tests/BestFitStorePersistenceTests.cs @@ -0,0 +1,136 @@ +using System.IO.Compression; +using System.Text; +using OpenNest.BestFit; +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.IO.Tests; + +public class BestFitStorePersistenceTests +{ + private static readonly SavedPairResult Result = new( + 0, System.Math.PI, 10.25, -0.5, 1, 4, 0.25, 120, 20, 6, 0.5, true, null!, 100, null!); + + [Fact] + public void Write_WithoutStore_WritesNoBestFitEntries() + { + var entries = EntryNames(Save(CreateNest(), store: null)); + + Assert.DoesNotContain(entries, IsBestFitEntry); + } + + [Fact] + public void Write_WithStore_PassesUsedPlateKeysAndWritesExportedSets() + { + var store = new RecordingStore { ToExport = [new SavedPairSet(60, 40, 0.25, [Result])] }; + + var bytes = Save(CreateNest(), store); + + var exported = Assert.Single(store.Exported); + Assert.Equal("Part", exported.Drawing.Name); + Assert.Equal( + new[] { (60.0, 40.0, 0.25), (30.0, 20.0, 0.5) }.OrderBy(k => k), + exported.PlateKeys.OrderBy(k => k)); + Assert.Contains("bestfits/bestfit-1", EntryNames(bytes)); + } + + [Fact] + public void Write_StoreExportsNothing_WritesNoBestFitEntry() + { + var entries = EntryNames(Save(CreateNest(), new RecordingStore())); + + Assert.DoesNotContain(entries, IsBestFitEntry); + } + + [Fact] + public void Read_WithStore_ImportsSavedSetsWithWireDefaults() + { + var bytes = Save(CreateNest(), new RecordingStore { ToExport = [new SavedPairSet(60, 40, 0.25, [Result])] }); + var store = new RecordingStore(); + + var nest = new NestReader(new MemoryStream(bytes)) { BestFitStore = store }.Read(); + + var imported = Assert.Single(store.Imported); + Assert.Same(nest.Drawings.Single(), imported.Drawing); + var set = Assert.Single(imported.Sets); + Assert.Equal((60.0, 40.0, 0.25), (set.PlateWidth, set.PlateHeight, set.Spacing)); + var result = Assert.Single(set.Results); + // A null reason and hull list are written as "" and []. + Assert.Equal(Result with { Reason = "", HullAngles = result.HullAngles }, result); + Assert.Empty(result.HullAngles); + } + + [Fact] + public void Read_WithoutStore_SkipsBestFitEntriesUnparsed() + { + var bytes = AddEntry(Save(CreateNest(), store: null), "bestfits/bestfit-1", "not json"); + + var nest = new NestReader(new MemoryStream(bytes)).Read(); + + Assert.Single(nest.Drawings); + } + + private static bool IsBestFitEntry(string name) => name.StartsWith("bestfits/", StringComparison.Ordinal); + + private static Nest CreateNest() + { + var program = new CNC.Program(); + program.Codes.Add(new LinearMove(10, 0)); + program.Codes.Add(new LinearMove(10, 5)); + program.Codes.Add(new LinearMove(0, 5)); + program.Codes.Add(new LinearMove(0, 0)); + var drawing = new Drawing("Part", program); + var nest = new Nest("best fits"); + nest.Drawings.Add(drawing); + foreach (var (width, length, spacing) in new[] { (60.0, 40.0, 0.25), (30.0, 20.0, 0.5) }) + { + var plate = new Plate(width, length) { PartSpacing = spacing }; + plate.Parts.Add(new Part(drawing, new Vector(1, 1))); + nest.Plates.Add(plate); + } + return nest; + } + + private static byte[] Save(Nest nest, IBestFitStore? store) + { + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest) { BestFitStore = store }.Write(stream)); + return stream.ToArray(); + } + + private static string[] EntryNames(byte[] bytes) + { + using var zip = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read); + return zip.Entries.Select(e => e.FullName).ToArray(); + } + + private static byte[] AddEntry(byte[] bytes, string name, string text) + { + using var stream = new MemoryStream(); + stream.Write(bytes); + using (var zip = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + using var writer = new StreamWriter(zip.CreateEntry(name).Open(), Encoding.UTF8); + writer.Write(text); + } + return stream.ToArray(); + } + + private sealed class RecordingStore : IBestFitStore + { + public IReadOnlyList ToExport { get; init; } = []; + + public List<(Drawing Drawing, IReadOnlySet<(double Width, double Length, double Spacing)> PlateKeys)> Exported { get; } = []; + + public List<(Drawing Drawing, IReadOnlyList Sets)> Imported { get; } = []; + + public IReadOnlyList Export( + Drawing drawing, IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys) + { + Exported.Add((drawing, plateKeys)); + return ToExport; + } + + public void Import(Drawing drawing, IReadOnlyList sets) => Imported.Add((drawing, sets)); + } +} diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index e14c7f1d..006695ac 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -6,9 +6,9 @@ using System.IO.Compression; using System.Linq; using System.Text.Json; using OpenNest.Bending; +using OpenNest.BestFit; using OpenNest.CNC; using OpenNest.Converters; -using OpenNest.Engine.BestFit; using OpenNest.Geometry; using static OpenNest.IO.NestFormat; @@ -24,6 +24,12 @@ namespace OpenNest.IO /// Part programs that could not be restored; other parts still load. public IReadOnlyList Warnings => warnings.AsReadOnly(); + /// + /// Receives saved best-fit sets. Without a store, bestfits/ entries are skipped + /// unread; they are only a cache. + /// + public IBestFitStore BestFitStore { get; init; } + public NestReader(string file) { stream = new FileStream(file, FileMode.Open, FileAccess.Read); @@ -274,6 +280,9 @@ namespace OpenNest.IO private void ReadBestFits(Dictionary drawingMap) { + if (BestFitStore == null) + return; + foreach (var kvp in drawingMap) { var entry = zipArchive.GetEntry($"bestfits/bestfit-{kvp.Key}"); @@ -288,47 +297,33 @@ namespace OpenNest.IO if (sets == null) continue; - PopulateBestFitSets(kvp.Value, sets); + BestFitStore.Import(kvp.Value, sets.Select(FromDto).ToList()); } } - private void PopulateBestFitSets(Drawing drawing, List sets) - { - foreach (var set in sets) - { - var results = set - .Results.Select(r => new BestFitResult - { - Candidate = new PairCandidate - { - Drawing = drawing, - Part1Rotation = r.Part1Rotation, - Part2Rotation = r.Part2Rotation, - Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY), - StrategyIndex = r.StrategyType, - TestNumber = r.TestNumber, - Spacing = r.CandidateSpacing, - }, - RotatedArea = r.RotatedArea, - BoundingWidth = r.BoundingWidth, - BoundingHeight = r.BoundingHeight, - OptimalRotation = r.OptimalRotation, - Keep = r.Keep, - Reason = r.Reason, - TrueArea = r.TrueArea, - HullAngles = r.HullAngles, - }) - .ToList(); - - BestFitCache.Populate( - drawing, - set.PlateWidth, - set.PlateHeight, - set.Spacing, - results - ); - } - } + private static SavedPairSet FromDto(BestFitSetDto set) => + new( + set.PlateWidth, + set.PlateHeight, + set.Spacing, + set.Results.Select(r => new SavedPairResult( + r.Part1Rotation, + r.Part2Rotation, + r.Part2OffsetX, + r.Part2OffsetY, + r.StrategyType, + r.TestNumber, + r.CandidateSpacing, + r.RotatedArea, + r.BoundingWidth, + r.BoundingHeight, + r.OptimalRotation, + r.Keep, + r.Reason, + r.TrueArea, + r.HullAngles + )).ToList() + ); /// /// The saved machine clamp rail, or null when absent, empty or malformed (a bad rail diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index fa965421..4e2413b1 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -7,8 +7,8 @@ using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using OpenNest.BestFit; using OpenNest.CNC; -using OpenNest.Engine.BestFit; using static OpenNest.IO.NestFormat; namespace OpenNest.IO @@ -23,6 +23,12 @@ namespace OpenNest.IO private readonly Dictionary drawingHashes = new(); private readonly Dictionary partPrograms = new(); + /// + /// Supplies best-fit sets to save. Without a store, no bestfits/ entries are + /// written; they are only a cache. + /// + public IBestFitStore BestFitStore { get; init; } + public NestWriter(Nest nest) { this.drawingDict = new Dictionary(); @@ -364,64 +370,19 @@ namespace OpenNest.IO return list; } - private List BuildBestFitDtos(Drawing drawing) - { - var allBestFits = BestFitCache.GetAllForDrawing(drawing); - var sets = new List(); - - // Only save best-fit sets for plate sizes actually used in this nest. - var plateSizes = new HashSet<(double, double, double)>(); - foreach (var plate in nest.Plates) - plateSizes.Add((plate.Size.Width, plate.Size.Length, plate.PartSpacing)); - - foreach (var kvp in allBestFits) - { - if ( - !plateSizes.Contains((kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing)) - ) - continue; - - var results = kvp - .Value.Where(r => r.Keep) - .Select(r => new BestFitResultDto - { - Part1Rotation = r.Candidate.Part1Rotation, - Part2Rotation = r.Candidate.Part2Rotation, - Part2OffsetX = r.Candidate.Part2Offset.X, - Part2OffsetY = r.Candidate.Part2Offset.Y, - StrategyType = r.Candidate.StrategyIndex, - TestNumber = r.Candidate.TestNumber, - CandidateSpacing = r.Candidate.Spacing, - RotatedArea = r.RotatedArea, - BoundingWidth = r.BoundingWidth, - BoundingHeight = r.BoundingHeight, - OptimalRotation = r.OptimalRotation, - Keep = r.Keep, - Reason = r.Reason ?? "", - TrueArea = r.TrueArea, - HullAngles = r.HullAngles ?? new List(), - }) - .ToList(); - - sets.Add( - new BestFitSetDto - { - PlateWidth = kvp.Key.PlateWidth, - PlateHeight = kvp.Key.PlateHeight, - Spacing = kvp.Key.Spacing, - Results = results, - } - ); - } - - return sets; - } - private void WriteBestFits(ZipArchive zipArchive) { + if (BestFitStore == null) + return; + + // Only save best-fit sets for plate sizes actually used in this nest. + var plateKeys = new HashSet<(double Width, double Length, double Spacing)>(); + foreach (var plate in nest.Plates) + plateKeys.Add((plate.Size.Width, plate.Size.Length, plate.PartSpacing)); + foreach (var kvp in drawingDict.OrderBy(k => k.Key)) { - var sets = BuildBestFitDtos(kvp.Value); + var sets = BestFitStore.Export(kvp.Value, plateKeys).Select(ToDto).ToList(); if (sets.Count == 0) continue; @@ -433,6 +394,34 @@ namespace OpenNest.IO } } + private static BestFitSetDto ToDto(SavedPairSet set) => + new() + { + PlateWidth = set.PlateWidth, + PlateHeight = set.PlateHeight, + Spacing = set.Spacing, + Results = set + .Results.Select(r => new BestFitResultDto + { + Part1Rotation = r.Part1Rotation, + Part2Rotation = r.Part2Rotation, + Part2OffsetX = r.Part2OffsetX, + Part2OffsetY = r.Part2OffsetY, + StrategyType = r.StrategyType, + TestNumber = r.TestNumber, + CandidateSpacing = r.CandidateSpacing, + RotatedArea = r.RotatedArea, + BoundingWidth = r.BoundingWidth, + BoundingHeight = r.BoundingHeight, + OptimalRotation = r.OptimalRotation, + Keep = r.Keep, + Reason = r.Reason ?? "", + TrueArea = r.TrueArea, + HullAngles = r.HullAngles?.ToList() ?? new List(), + }) + .ToList(), + }; + private void WritePrograms(ZipArchive zipArchive) { foreach (var kvp in drawingDict.OrderBy(k => k.Key)) diff --git a/OpenNest.Mcp/Tools/InputTools.cs b/OpenNest.Mcp/Tools/InputTools.cs index 3225d5f4..444176ae 100644 --- a/OpenNest.Mcp/Tools/InputTools.cs +++ b/OpenNest.Mcp/Tools/InputTools.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.IO; using System.Text; using ModelContextProtocol.Server; +using OpenNest.Engine.BestFit; using OpenNest.IO; using OpenNest.Shapes; using CncProgram = OpenNest.CNC.Program; @@ -27,7 +28,7 @@ namespace OpenNest.Mcp.Tools if (!File.Exists(path)) return $"Error: file not found: {path}"; - var reader = new NestReader(path); + var reader = new NestReader(path) { BestFitStore = BestFitCacheStore.Shared }; var nest = reader.Read(); _session.Nest = nest; @@ -101,7 +102,7 @@ namespace OpenNest.Mcp.Tools if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - var writer = new NestWriter(nest); + var writer = new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }; if (!writer.Write(path)) return "Error: failed to write nest file"; diff --git a/OpenNest.Tests/BestFit/BestFitCacheStoreTests.cs b/OpenNest.Tests/BestFit/BestFitCacheStoreTests.cs new file mode 100644 index 00000000..6675a493 --- /dev/null +++ b/OpenNest.Tests/BestFit/BestFitCacheStoreTests.cs @@ -0,0 +1,126 @@ +using OpenNest.BestFit; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; +using OpenNest.Shapes; + +namespace OpenNest.Tests.BestFit; + +[Collection(nameof(FillCacheCollection))] +public class BestFitCacheStoreTests +{ + private const double Spacing = 0.25; + + private static Drawing MakeDrawing() => new TShape { Width = 10, Height = 8 }.GetDrawing(); + + private static BestFitResult MakeResult(Drawing drawing, bool keep, string reason = "Valid", List hullAngles = null) => + new() + { + Candidate = new PairCandidate + { + Drawing = drawing, + Part1Rotation = 0.5, + Part2Rotation = System.Math.PI, + Part2Offset = new Vector(3.25, -1.5), + StrategyIndex = 2, + TestNumber = 7, + Spacing = Spacing, + }, + RotatedArea = 90, + BoundingWidth = 12, + BoundingHeight = 7.5, + OptimalRotation = 0.1, + Keep = keep, + Reason = reason, + TrueArea = 70, + HullAngles = hullAngles, + }; + + [Fact] + public void Export_ReturnsOnlyRequestedPlateKeysAndKeptResults() + { + BestFitCache.Clear(); + var drawing = MakeDrawing(); + BestFitCache.Populate(drawing, 60, 40, Spacing, new() + { + MakeResult(drawing, keep: true, hullAngles: new() { 0.25 }), + MakeResult(drawing, keep: false, reason: "Too wide"), + }); + BestFitCache.Populate(drawing, 14, 30, Spacing, new() { MakeResult(drawing, keep: true) }); + + var sets = BestFitCacheStore.Shared.Export( + drawing, new HashSet<(double, double, double)> { (60, 40, Spacing), (99, 99, Spacing) }); + + var set = Assert.Single(sets); + Assert.Equal((60.0, 40.0, Spacing), (set.PlateWidth, set.PlateHeight, set.Spacing)); + var result = Assert.Single(set.Results); + Assert.Equal( + new SavedPairResult(0.5, System.Math.PI, 3.25, -1.5, 2, 7, Spacing, 90, 12, 7.5, 0.1, true, "Valid", 70, result.HullAngles), + result); + Assert.Equal(new[] { 0.25 }, result.HullAngles); + } + + [Fact] + public void Export_DrawingWithoutCacheEntry_ReturnsNoSets() + { + BestFitCache.Clear(); + + var sets = BestFitCacheStore.Shared.Export( + MakeDrawing(), new HashSet<(double, double, double)> { (60, 40, Spacing) }); + + Assert.Empty(sets); + } + + [Fact] + public void Import_PopulatesCacheWithCandidatesBoundToTheDrawing() + { + BestFitCache.Clear(); + var drawing = MakeDrawing(); + var saved = new SavedPairResult(0.5, System.Math.PI, 3.25, -1.5, 2, 7, Spacing, 90, 12, 7.5, 0.1, true, "Valid", 70, new[] { 0.25, -0.5 }); + + BestFitCacheStore.Shared.Import(drawing, new[] { new SavedPairSet(60, 40, Spacing, new[] { saved }) }); + + var restored = Assert.Single(BestFitCache.GetAllForDrawing(drawing)); + Assert.Equal((60.0, 40.0, Spacing), restored.Key); + var result = Assert.Single(restored.Value); + Assert.Same(drawing, result.Candidate.Drawing); + Assert.Equal(0.5, result.Candidate.Part1Rotation); + Assert.Equal(System.Math.PI, result.Candidate.Part2Rotation); + Assert.Equal(new Vector(3.25, -1.5), result.Candidate.Part2Offset); + Assert.Equal(2, result.Candidate.StrategyIndex); + Assert.Equal(7, result.Candidate.TestNumber); + Assert.Equal(Spacing, result.Candidate.Spacing); + Assert.Equal(90, result.RotatedArea); + Assert.Equal(12, result.BoundingWidth); + Assert.Equal(7.5, result.BoundingHeight); + Assert.Equal(0.1, result.OptimalRotation); + Assert.True(result.Keep); + Assert.Equal("Valid", result.Reason); + Assert.Equal(70, result.TrueArea); + Assert.Equal(new[] { 0.25, -0.5 }, result.HullAngles); + } + + [Fact] + public void Import_ExistingSetForTheSameKey_IsKept() + { + BestFitCache.Clear(); + var drawing = MakeDrawing(); + var existing = new List { MakeResult(drawing, keep: true) }; + BestFitCache.Populate(drawing, 60, 40, Spacing, existing); + var saved = new SavedPairResult(0, 0, 1, 1, 0, 1, Spacing, 5, 1, 5, 0, true, "Valid", 4, new double[0]); + + BestFitCacheStore.Shared.Import(drawing, new[] { new SavedPairSet(60, 40, Spacing, new[] { saved }) }); + + Assert.Same(existing, BestFitCache.GetAllForDrawing(drawing)[(60, 40, Spacing)]); + } + + [Fact] + public void Import_EmptySet_AddsNothing() + { + BestFitCache.Clear(); + var drawing = MakeDrawing(); + + BestFitCacheStore.Shared.Import(drawing, new[] { new SavedPairSet(60, 40, Spacing, new SavedPairResult[0]) }); + + Assert.Empty(BestFitCache.GetAllForDrawing(drawing)); + } +} diff --git a/OpenNest.Tests/IO/BestFitPersistenceTests.cs b/OpenNest.Tests/IO/BestFitPersistenceTests.cs index 29cb7ae2..c4ee4d30 100644 --- a/OpenNest.Tests/IO/BestFitPersistenceTests.cs +++ b/OpenNest.Tests/IO/BestFitPersistenceTests.cs @@ -290,11 +290,11 @@ public class BestFitPersistenceTests private static byte[] Save(Nest nest) { using var stream = new MemoryStream(); - new NestWriter(nest).Write(stream); + new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }.Write(stream); return stream.ToArray(); } - private static Nest Load(byte[] bytes) => new NestReader(new MemoryStream(bytes)).Read(); + private static Nest Load(byte[] bytes) => new NestReader(new MemoryStream(bytes)) { BestFitStore = BestFitCacheStore.Shared }.Read(); private static System.Collections.Generic.Dictionary ReadEntries(byte[] bytes) { diff --git a/OpenNest.Training/Program.cs b/OpenNest.Training/Program.cs index b8032ca7..6345a830 100644 --- a/OpenNest.Training/Program.cs +++ b/OpenNest.Training/Program.cs @@ -75,7 +75,7 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin Console.Error.WriteLine($"Error: Template not found: {template}"); return 1; } - templateNest = new NestReader(template).Read(); + templateNest = new NestReader(template) { BestFitStore = BestFitCacheStore.Shared }.Read(); Console.WriteLine($"Using template: {template}"); } @@ -289,7 +289,7 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin plateObj.PartSpacing = s; plateObj.Parts.AddRange(result.PlacedParts); - var writer = new NestWriter(nestObj); + var writer = new NestWriter(nestObj) { BestFitStore = BestFitCacheStore.Shared }; writer.Write(savedFilePath); } diff --git a/OpenNest/Document.cs b/OpenNest/Document.cs index d8b37962..b83e9d90 100644 --- a/OpenNest/Document.cs +++ b/OpenNest/Document.cs @@ -3,6 +3,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; using OpenNest.Data; +using OpenNest.Engine.BestFit; using OpenNest.IO; namespace OpenNest @@ -42,7 +43,7 @@ namespace OpenNest LastSaveDate = DateTime.Now; LastSavePath = path; - var writer = new NestWriter(Nest); + var writer = new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }; writer.Write(path); } @@ -50,7 +51,7 @@ namespace OpenNest public byte[] Serialize() { using var stream = new MemoryStream(); - new NestWriter(Nest).Write(stream); + new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(stream); return stream.ToArray(); } diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index b890288c..6b82e072 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -685,7 +685,7 @@ namespace OpenNest.Forms { try { - var nest = new NestReader(templatePath).Read(); + var nest = new NestReader(templatePath) { BestFitStore = BestFitCacheStore.Shared }.Read(); NestDefaults.FromNest(nest).Save(NestDefaults.DefaultPath); } catch (Exception ex) @@ -737,7 +737,7 @@ namespace OpenNest.Forms if (!string.Equals(Path.GetExtension(path), NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)) continue; - var reader = new NestReader(path); + var reader = new NestReader(path) { BestFitStore = BestFitCacheStore.Shared }; var nest = reader.Read(); LoadNest(nest); ShowNestWarnings(reader); @@ -781,7 +781,7 @@ namespace OpenNest.Forms using var dlg = new OpenFileDialog { Filter = NestFormat.FileFilter, Multiselect = true }; if (dlg.ShowDialog(this) == DialogResult.OK) { - var reader = new NestReader(dlg.FileName); + var reader = new NestReader(dlg.FileName) { BestFitStore = BestFitCacheStore.Shared }; var nest = reader.Read(); LoadNest(nest); ShowNestWarnings(reader); @@ -802,7 +802,7 @@ namespace OpenNest.Forms mnuToolsOptions.Enabled = false; var snapshot = await NestHistorySession.ReadCurrentAsync(repository, dlg.SelectedId, serverUrl); using var stream = new MemoryStream(snapshot.Archive); - var reader = new NestReader(stream); + var reader = new NestReader(stream) { BestFitStore = BestFitCacheStore.Shared }; var nest = reader.Read(); // Metadata edits create revisions without rewriting the archive. Use their // current editable fields rather than silently reverting them on the next save. @@ -957,7 +957,7 @@ namespace OpenNest.Forms try { // Export never changes the document's save path, remote id, or nest name. - new NestWriter(form.Nest).Write(dlg.FileName); + new NestWriter(form.Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(dlg.FileName); statusLabel1.Text = $"Exported .nest to {dlg.FileName}"; } catch (Exception ex) diff --git a/OpenNest/Forms/NestHistoryForm.cs b/OpenNest/Forms/NestHistoryForm.cs index 3fa28050..e56d5ef3 100644 --- a/OpenNest/Forms/NestHistoryForm.cs +++ b/OpenNest/Forms/NestHistoryForm.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.Windows.Forms; using OpenNest.Controls; using OpenNest.Data; +using OpenNest.Engine.BestFit; using OpenNest.IO; namespace OpenNest.Forms; @@ -215,7 +216,7 @@ public sealed class NestHistoryForm : Form var nest = await Task.Run(() => { using var stream = new MemoryStream(bytes); - var reader = new NestReader(stream); + var reader = new NestReader(stream) { BestFitStore = BestFitCacheStore.Shared }; var loaded = reader.Read(); if (reader.Warnings.Count > 0) throw new InvalidDataException(string.Join(Environment.NewLine, reader.Warnings)); diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs index 6524d40f..08d79ad4 100644 --- a/OpenNest/Forms/SavedNestsForm.cs +++ b/OpenNest/Forms/SavedNestsForm.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using System.Windows.Forms; using OpenNest.Controls; using OpenNest.Data; +using OpenNest.Engine.BestFit; using OpenNest.IO; using Timer = System.Windows.Forms.Timer; @@ -673,7 +674,7 @@ public sealed class SavedNestsForm : Form () => { using var stream = new MemoryStream(file); - return NestDetails.FromNest(new NestReader(stream).Read()); + return NestDetails.FromNest(new NestReader(stream) { BestFitStore = BestFitCacheStore.Shared }.Read()); }, cancellationToken); } @@ -814,7 +815,7 @@ public sealed class SavedNestsForm : Form var nest = await Task.Run(() => { using var source = new MemoryStream(snapshot.Archive); - var loaded = new NestReader(source).Read(); + var loaded = new NestReader(source) { BestFitStore = BestFitCacheStore.Shared }.Read(); // Metadata-only revisions can be newer than the archived fields. NestRecordFactory.ApplyMetadata(loaded, snapshot.Metadata); return loaded; @@ -832,7 +833,7 @@ public sealed class SavedNestsForm : Form var bytes = await Task.Run(() => { using var output = new MemoryStream(); - new NestWriter(nest).Write(output); + new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }.Write(output); return output.ToArray(); }); var save = new NestSaveSession();