Files
OpenNest/OpenNest.Tests/IO/BestFitPersistenceTests.cs
T
ajandClaude Opus 5.5 7a5c06bc99 fix(bestfit): bind loaded pair candidates to a canonical copy
Saved pairs are in the drawing's canonical frame, but loaded candidates
held the source drawing, so BuildCanonicalParts/BuildSourceParts rebuilt
a different pair for any drawing with a non-zero canonical angle (a 30
degree T-shape came back 16.57 x 10.39 instead of its saved 16.92 x 8).
That reached the Irregular engine's pair catalog and the best-fit viewer
after opening a saved nest. Import now binds every candidate to one
canonical copy, as computed candidates are; fills already passed the
canonical drawing explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:43 -04:00

311 lines
12 KiB
C#

using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
using OpenNest.Shapes;
using OpenNest.Tests.BestFit;
namespace OpenNest.Tests.IO;
/// <summary>
/// Pins how the best-fit pair cache round-trips through the <c>bestfits/</c> entries of a
/// .nest archive: which sets are written, their exact bytes, and what a read puts back.
/// </summary>
[Collection(nameof(FillCacheCollection))]
public class BestFitPersistenceTests
{
private const double Spacing = 0.25;
private const double UsedWidth = 60;
private const double UsedLength = 40;
private const double UnusedWidth = 14;
private const double UnusedLength = 30;
// Narrow enough that the filter drops some computed pairs (the T-shape pairs are ~8 x 17).
private const double NarrowWidth = 8.1;
// Captured from the writer before the best-fit mapping moved out of OpenNest.IO.
private const string GoldenBestFitJson = """
[
{
"plateWidth": 60,
"plateHeight": 40,
"spacing": 0.25,
"results": [
{
"part1Rotation": 0,
"part2Rotation": 3.141592653589793,
"part2OffsetX": 10.3,
"part2OffsetY": -0.1,
"strategyType": 2,
"testNumber": 17,
"candidateSpacing": 0.25,
"rotatedArea": 123.456,
"boundingWidth": 20.5,
"boundingHeight": 6.0225,
"optimalRotation": 0.7853981633974483,
"keep": true,
"reason": "",
"trueArea": 100.125,
"hullAngles": []
},
{
"part1Rotation": 1.5707963267948966,
"part2Rotation": 4.71238898038469,
"part2OffsetX": 0,
"part2OffsetY": 8.125,
"strategyType": 0,
"testNumber": 3,
"candidateSpacing": 0.25,
"rotatedArea": 98.5,
"boundingWidth": 8.25,
"boundingHeight": 11.9,
"optimalRotation": 0,
"keep": true,
"reason": "Valid",
"trueArea": 80,
"hullAngles": [
0,
0.5235987755982988,
-1E-12
]
}
]
}
]
""";
[Fact]
public void Write_PopulatedCache_WritesGoldenBytesForUsedSizesAndKeptResultsOnly()
{
BestFitCache.Clear();
var nest = CreateNest(MakeTShape());
PopulateHandBuiltSets(nest.Drawings.First());
var entries = ReadEntries(Save(nest));
Assert.Equal(new[] { "bestfits/bestfit-1" }, entries.Keys.Where(IsBestFitEntry).ToArray());
var expected = Encoding.UTF8.GetPreamble()
.Concat(Encoding.UTF8.GetBytes(GoldenBestFitJson.ReplaceLineEndings(System.Environment.NewLine)))
.ToArray();
Assert.Equal(expected, entries["bestfits/bestfit-1"]);
}
[Fact]
public void Read_HandBuiltSets_RestoresKeptResultsForUsedSizeFieldByField()
{
BestFitCache.Clear();
var nest = CreateNest(MakeTShape());
var source = nest.Drawings.First();
PopulateHandBuiltSets(source);
var expected = BestFitCache.GetAllForDrawing(source)[(UsedWidth, UsedLength, Spacing)]
.Where(r => r.Keep)
.ToList();
var bytes = Save(nest);
BestFitCache.Clear();
var loaded = Load(bytes);
var drawing = loaded.Drawings.Single();
var restored = BestFitCache.GetAllForDrawing(drawing);
Assert.Equal(new[] { (UsedWidth, UsedLength, Spacing) }, restored.Keys.ToArray());
AssertSameResults(expected, restored[(UsedWidth, UsedLength, Spacing)], drawing);
}
[Fact]
public void RoundTrip_ComputedSets_RestoresUsedSizeOnlyAndKeptResultsOnly()
{
BestFitCache.Clear();
var nest = CreateNest(MakeTShape(), NarrowWidth, UsedLength);
var source = nest.Drawings.First();
var used = BestFitCache.GetOrCompute(source, NarrowWidth, UsedLength, Spacing);
BestFitCache.GetOrCompute(source, UnusedWidth, UnusedLength, Spacing);
Assert.Contains(used, r => r.Keep);
Assert.Contains(used, r => !r.Keep);
var bytes = Save(nest);
BestFitCache.Clear();
var drawing = Load(bytes).Drawings.Single();
var restored = BestFitCache.GetAllForDrawing(drawing);
Assert.Equal(new[] { (NarrowWidth, UsedLength, Spacing) }, restored.Keys.ToArray());
AssertSameResults(used.Where(r => r.Keep).ToList(), restored[(NarrowWidth, UsedLength, Spacing)], drawing);
}
[Fact]
public void RoundTrip_RotatedSource_LoadedCandidatesRebuildTheSavedPair()
{
// Saved pairs are in the canonical frame. Loaded candidates must hold a canonical copy,
// as computed ones do, or the source drawing rebuilds a different pair.
BestFitCache.Clear();
var nest = CreateNest(MakeRotatedTShape());
var source = nest.Drawings.First();
var computed = BestFitCache.GetOrCompute(source, UsedWidth, UsedLength, Spacing).First(r => r.Keep);
Assert.NotSame(source, computed.Candidate.Drawing);
Assert.Equal(0.0, computed.Candidate.Drawing.Source.Angle);
var bytes = Save(nest);
BestFitCache.Clear();
var drawing = Load(bytes).Drawings.Single();
var loaded = BestFitCache.GetAllForDrawing(drawing)[(UsedWidth, UsedLength, Spacing)].First();
Assert.False(Tolerance.IsEqualTo(drawing.Source.Angle, 0));
Assert.Equal(source.Source.Angle, drawing.Source.Angle, 9);
Assert.NotSame(drawing, loaded.Candidate.Drawing);
Assert.Same(drawing, CanonicalFrame.SourceOf(loaded.Candidate.Drawing));
Assert.Equal(0.0, loaded.Candidate.Drawing.Source.Angle);
foreach (var (result, owner) in new[] { (computed, source), (loaded, drawing) })
{
var parts = result.BuildSourceParts(owner);
var bounds = result.GetCutBounds(parts);
Assert.All(parts, p => Assert.Same(owner, p.BaseDrawing));
Assert.Equal(result.BoundingWidth, bounds.Length, 2);
Assert.Equal(result.BoundingHeight, bounds.Width, 2);
Assert.False(parts[0].Intersects(parts[1], out _));
}
}
[Fact]
public void RoundTrip_NoBestFits_LoadsAndSavesUnchanged()
{
BestFitCache.Clear();
var original = ReadEntries(Save(CreateNest(MakeTShape())));
Assert.DoesNotContain(original.Keys, IsBestFitEntry);
BestFitCache.Clear();
var loaded = Load(Save(CreateNest(MakeTShape())));
Assert.Empty(BestFitCache.GetAllForDrawing(loaded.Drawings.Single()));
var resaved = ReadEntries(Save(loaded));
Assert.Equal(original.Keys.OrderBy(k => k), resaved.Keys.OrderBy(k => k));
foreach (var name in original.Keys.Where(k => k != "nest.json"))
Assert.Equal(original[name], resaved[name]);
}
private static bool IsBestFitEntry(string name) => name.StartsWith("bestfits/", System.StringComparison.Ordinal);
private static Drawing MakeTShape() => new TShape { Width = 10, Height = 8 }.GetDrawing();
private static Drawing MakeRotatedTShape()
{
var drawing = MakeTShape();
drawing.Program.Rotate(Angle.ToRadians(30), drawing.Program.BoundingBox().Center);
drawing.RecomputeCanonicalAngle();
return drawing;
}
private static Nest CreateNest(Drawing drawing, double width = UsedWidth, double length = UsedLength)
{
var nest = new Nest { Name = "BestFits" };
nest.Drawings.Add(drawing);
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 void PopulateHandBuiltSets(Drawing drawing)
{
BestFitCache.Populate(drawing, UsedWidth, UsedLength, Spacing, new()
{
MakeResult(drawing, 0, System.Math.PI, new Vector(0.1 + 0.2 + 10, -0.1), 2, 17,
123.456, 20.5, 6.0225, System.Math.PI / 4, true, null, 100.125, null),
MakeResult(drawing, 0, 0, new Vector(5, 5), 1, 9,
50, 5, 10, 0, false, "Too wide", 40, new() { 1.0 }),
MakeResult(drawing, System.Math.PI / 2, 3 * System.Math.PI / 2, new Vector(0, 8.125), 0, 3,
98.5, 8.25, 11.9, 0, true, "Valid", 80, new() { 0.0, System.Math.PI / 6, -1e-12 }),
});
BestFitCache.Populate(drawing, UnusedWidth, UnusedLength, Spacing, new()
{
MakeResult(drawing, 0, System.Math.PI, new Vector(3, 4), 0, 1,
10, 2, 5, 0, true, "Valid", 8, new()),
});
}
private static BestFitResult MakeResult(
Drawing drawing, double rotation1, double rotation2, Vector offset, int strategy, int test,
double rotatedArea, double width, double height, double optimal, bool keep, string reason,
double trueArea, System.Collections.Generic.List<double> hullAngles) =>
new()
{
Candidate = new PairCandidate
{
Drawing = drawing,
Part1Rotation = rotation1,
Part2Rotation = rotation2,
Part2Offset = offset,
StrategyIndex = strategy,
TestNumber = test,
Spacing = Spacing,
},
RotatedArea = rotatedArea,
BoundingWidth = width,
BoundingHeight = height,
OptimalRotation = optimal,
Keep = keep,
Reason = reason,
TrueArea = trueArea,
HullAngles = hullAngles,
};
private static void AssertSameResults(
System.Collections.Generic.List<BestFitResult> expected,
System.Collections.Generic.List<BestFitResult> actual,
Drawing loadedDrawing)
{
Assert.Equal(expected.Count, actual.Count);
for (var i = 0; i < expected.Count; i++)
{
var e = expected[i];
var a = actual[i];
Assert.Same(loadedDrawing, CanonicalFrame.SourceOf(a.Candidate.Drawing));
AssertBits(e.Candidate.Part1Rotation, a.Candidate.Part1Rotation);
AssertBits(e.Candidate.Part2Rotation, a.Candidate.Part2Rotation);
AssertBits(e.Candidate.Part2Offset.X, a.Candidate.Part2Offset.X);
AssertBits(e.Candidate.Part2Offset.Y, a.Candidate.Part2Offset.Y);
Assert.Equal(e.Candidate.StrategyIndex, a.Candidate.StrategyIndex);
Assert.Equal(e.Candidate.TestNumber, a.Candidate.TestNumber);
AssertBits(e.Candidate.Spacing, a.Candidate.Spacing);
AssertBits(e.RotatedArea, a.RotatedArea);
AssertBits(e.BoundingWidth, a.BoundingWidth);
AssertBits(e.BoundingHeight, a.BoundingHeight);
AssertBits(e.OptimalRotation, a.OptimalRotation);
Assert.Equal(e.Keep, a.Keep);
Assert.Equal(e.Reason ?? "", a.Reason);
AssertBits(e.TrueArea, a.TrueArea);
Assert.Equal(
(e.HullAngles ?? new()).Select(System.BitConverter.DoubleToInt64Bits),
a.HullAngles.Select(System.BitConverter.DoubleToInt64Bits));
}
}
private static void AssertBits(double expected, double actual) =>
Assert.Equal(System.BitConverter.DoubleToInt64Bits(expected), System.BitConverter.DoubleToInt64Bits(actual));
private static byte[] Save(Nest nest)
{
using var stream = new MemoryStream();
new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }.Write(stream);
return stream.ToArray();
}
private static Nest Load(byte[] bytes) => new NestReader(new MemoryStream(bytes)) { BestFitStore = BestFitCacheStore.Shared }.Read();
private static System.Collections.Generic.Dictionary<string, byte[]> ReadEntries(byte[] bytes)
{
using var zip = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read);
return zip.Entries.ToDictionary(e => e.FullName, e =>
{
using var entryStream = e.Open();
using var copy = new MemoryStream();
entryStream.CopyTo(copy);
return copy.ToArray();
});
}
}