Files
OpenNest/OpenNest.Tests/BestFit/BestFitCacheStoreTests.cs
T
ajandClaude Opus 5.5 0ca4e64189 fix(bestfit): drop saved pair sets whose canonical frame changed
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>
2026-10-20 22:04:23 -04:00

202 lines
7.6 KiB
C#

using OpenNest.BestFit;
using OpenNest.Engine;
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<double> 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));
Assert.Equal(drawing.Source.Angle, set.CanonicalAngle);
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_PopulatesCacheWithCandidatesOnACanonicalCopyOfTheDrawing()
{
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 }, drawing.Source.Angle) });
var restored = Assert.Single(BestFitCache.GetAllForDrawing(drawing));
Assert.Equal((60.0, 40.0, Spacing), restored.Key);
var result = Assert.Single(restored.Value);
Assert.NotSame(drawing, result.Candidate.Drawing);
Assert.Same(drawing, CanonicalFrame.SourceOf(result.Candidate.Drawing));
Assert.Equal(0.0, result.Candidate.Drawing.Source.Angle);
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<BestFitResult> { 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 }, drawing.Source.Angle) });
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], drawing.Source.Angle) });
Assert.Empty(BestFitCache.GetAllForDrawing(drawing));
}
[Fact]
public void Import_SetSavedUnderTheCurrentCanonicalAngle_IsKept()
{
BestFitCache.Clear();
var drawing = MakeRotatedDrawing();
BestFitCache.Populate(drawing, 60, 40, Spacing, new() { MakeResult(drawing, keep: true) });
var sets = BestFitCacheStore.Shared.Export(drawing, new HashSet<(double, double, double)> { (60, 40, Spacing) });
BestFitCache.Clear();
BestFitCacheStore.Shared.Import(drawing, sets);
Assert.Single(Assert.Single(BestFitCache.GetAllForDrawing(drawing)).Value);
}
[Fact]
public void Import_SetSavedUnderAnotherCanonicalAngle_IsDropped()
{
// A changed canonical-angle computation turns the drawing's frame; pairs saved in the
// old frame would be replayed turned and could overlap.
BestFitCache.Clear();
var drawing = MakeRotatedDrawing();
BestFitCache.Populate(drawing, 60, 40, Spacing, new() { MakeResult(drawing, keep: true) });
var sets = BestFitCacheStore.Shared.Export(drawing, new HashSet<(double, double, double)> { (60, 40, Spacing) });
BestFitCache.Clear();
drawing.Source.Angle += 0.35;
BestFitCacheStore.Shared.Import(drawing, sets);
Assert.Empty(BestFitCache.GetAllForDrawing(drawing));
}
[Theory]
[InlineData(null)]
[InlineData(double.NaN)]
public void Import_SetWithoutAUsableCanonicalAngle_IsDropped(double? angle)
{
BestFitCache.Clear();
var drawing = MakeDrawing();
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 }, angle) });
Assert.Empty(BestFitCache.GetAllForDrawing(drawing));
}
[Fact]
public void Import_AngleAWholeTurnAway_MatchesTheSameFrame()
{
BestFitCache.Clear();
var drawing = MakeRotatedDrawing();
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 }, drawing.Source.Angle - 2 * System.Math.PI) });
Assert.Single(BestFitCache.GetAllForDrawing(drawing));
}
private static Drawing MakeRotatedDrawing()
{
var drawing = MakeDrawing();
drawing.Program.Rotate(0.5, drawing.Program.BoundingBox().Center);
drawing.RecomputeCanonicalAngle();
Assert.NotEqual(0.0, drawing.Source.Angle);
return drawing;
}
}