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();