mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 11:31:07 -04:00
Saved best-fit pairs are in the drawing's canonical frame, but the canonical angle is recomputed on load and was not saved with the set. When that computation changes (RotatingCalipers checking every hull edge, marks no longer counted as material), old pairs were replayed in a turned frame and could overlap. Each saved set now records canonicalAngle, and BestFitCacheStore.Import skips sets whose angle no longer matches the recomputed one (1e-9 rad, modulo a full turn). Sets from older files have no angle and are dropped; their pairs are recomputed when next needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
15 KiB
C#
373 lines
15 KiB
C#
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
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, plus the
|
|
// canonical angle each set now records.
|
|
private const string GoldenBestFitJson = """
|
|
[
|
|
{
|
|
"plateWidth": 60,
|
|
"plateHeight": 40,
|
|
"spacing": 0.25,
|
|
"canonicalAngle": 1.5707963267948966,
|
|
"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 Read_SetSavedUnderAnotherCanonicalAngle_IsDropped()
|
|
{
|
|
// As if saved before a change to the canonical-angle computation turned this drawing's
|
|
// frame: replaying those pairs in the new frame could overlap them.
|
|
BestFitCache.Clear();
|
|
var nest = CreateNest(MakeRotatedTShape());
|
|
var source = nest.Drawings.First();
|
|
BestFitCache.GetOrCompute(source, UsedWidth, UsedLength, Spacing);
|
|
var bytes = RewriteBestFits(Save(nest), sets =>
|
|
{
|
|
foreach (var set in sets)
|
|
set["canonicalAngle"] = source.Source.Angle + 0.35;
|
|
});
|
|
|
|
BestFitCache.Clear();
|
|
var drawing = Load(bytes).Drawings.Single();
|
|
|
|
Assert.Empty(BestFitCache.GetAllForDrawing(drawing));
|
|
}
|
|
|
|
[Fact]
|
|
public void Read_SetFromAnOlderFileWithoutACanonicalAngle_IsDropped()
|
|
{
|
|
BestFitCache.Clear();
|
|
var nest = CreateNest(MakeTShape());
|
|
PopulateHandBuiltSets(nest.Drawings.First());
|
|
var bytes = RewriteBestFits(Save(nest), sets =>
|
|
{
|
|
foreach (var set in sets)
|
|
set.Remove("canonicalAngle");
|
|
});
|
|
|
|
BestFitCache.Clear();
|
|
var drawing = Load(bytes).Drawings.Single();
|
|
|
|
Assert.Empty(BestFitCache.GetAllForDrawing(drawing));
|
|
}
|
|
|
|
[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();
|
|
|
|
/// <summary>Edits every saved set of drawing 1 in place, as an older or foreign writer might have saved it.</summary>
|
|
private static byte[] RewriteBestFits(byte[] bytes, System.Action<System.Collections.Generic.List<JsonObject>> edit)
|
|
{
|
|
var entries = ReadEntries(bytes);
|
|
var sets = JsonNode.Parse(new MemoryStream(entries["bestfits/bestfit-1"]))!.AsArray();
|
|
edit(sets.Select(n => n!.AsObject()).ToList());
|
|
entries["bestfits/bestfit-1"] = Encoding.UTF8.GetBytes(sets.ToJsonString());
|
|
|
|
using var stream = new MemoryStream();
|
|
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
|
|
{
|
|
foreach (var (name, content) in entries)
|
|
{
|
|
using var entryStream = zip.CreateEntry(name).Open();
|
|
entryStream.Write(content);
|
|
}
|
|
}
|
|
return stream.ToArray();
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|
|
}
|