diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index 8ca73ec..ba39537 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -234,6 +234,7 @@ namespace OpenNest public bool Intersects(Part part, out List pts) { + PerfCounters.CountPartIntersects(); pts = new List(); var entities1 = ConvertProgram diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs index a91c4ca..d81f777 100644 --- a/OpenNest.Core/PartGeometry.cs +++ b/OpenNest.Core/PartGeometry.cs @@ -53,6 +53,7 @@ namespace OpenNest /// public static List GetOffsetPerimeterEntities(Part part, double spacing) { + PerfCounters.CountOffsetPerimeterEntities(); var geoEntities = ConvertProgram.ToGeometry(part.Program); var profile = new ShapeProfile( geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() diff --git a/OpenNest.Core/PerfCounters.cs b/OpenNest.Core/PerfCounters.cs new file mode 100644 index 0000000..8bec7d5 --- /dev/null +++ b/OpenNest.Core/PerfCounters.cs @@ -0,0 +1,37 @@ +using System.Diagnostics; +using System.Threading; + +namespace OpenNest +{ + /// + /// Debug-only call counters for the fill hot paths. They confirm that a cache or shortcut + /// actually removes the work it claims to; Release builds compile the increments away. + /// + public static class PerfCounters + { + private static long findBestFits; + private static long offsetPerimeterEntities; + private static long partIntersects; + + public static long FindBestFits => Interlocked.Read(ref findBestFits); + public static long OffsetPerimeterEntities => Interlocked.Read(ref offsetPerimeterEntities); + public static long PartIntersects => Interlocked.Read(ref partIntersects); + + [Conditional("DEBUG")] + public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits); + + [Conditional("DEBUG")] + public static void CountOffsetPerimeterEntities() => + Interlocked.Increment(ref offsetPerimeterEntities); + + [Conditional("DEBUG")] + public static void CountPartIntersects() => Interlocked.Increment(ref partIntersects); + + public static void Reset() + { + Interlocked.Exchange(ref findBestFits, 0); + Interlocked.Exchange(ref offsetPerimeterEntities, 0); + Interlocked.Exchange(ref partIntersects, 0); + } + } +} diff --git a/OpenNest.Engine/BestFit/BestFitCache.cs b/OpenNest.Engine/BestFit/BestFitCache.cs index 96d0141..b61ba1b 100644 --- a/OpenNest.Engine/BestFit/BestFitCache.cs +++ b/OpenNest.Engine/BestFit/BestFitCache.cs @@ -2,15 +2,23 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Runtime.CompilerServices; +using System.Threading; namespace OpenNest.Engine.BestFit { + /// + /// Best-fit pair results per drawing. Entries are keyed weakly by the source drawing (canonical + /// copies resolve through ), so every fill of one drawing + /// shares them and closed nests or finished solves release theirs. Candidates are computed once + /// per spacing and filtered per plate size. An entry is dropped when the drawing's + /// instance or canonical angle changes. + /// public static class BestFitCache { private const double StepSize = 0.25; - private static readonly ConcurrentDictionary> _cache = - new ConcurrentDictionary>(); + private static readonly ConditionalWeakTable _entries = new(); + private static readonly object _entriesLock = new(); public static Func CreateEvaluator { get; set; } public static Func CreateSlideComputer { get; set; } @@ -22,11 +30,111 @@ namespace OpenNest.Engine.BestFit double spacing ) { - var key = new CacheKey(drawing, plateWidth, plateHeight, spacing); + var entry = GetEntry(drawing); + var key = (plateWidth, plateHeight, spacing); - if (_cache.TryGetValue(key, out var cached)) + if (entry.Filtered.TryGetValue(key, out var cached)) return cached; + var candidates = GetCandidates(entry, drawing, spacing); + return entry.Filtered.GetOrAdd(key, _ => FilterForSize(candidates, plateWidth, plateHeight)); + } + + public static void ComputeForSizes( + Drawing drawing, + double spacing, + IEnumerable<(double Width, double Height)> plateSizes + ) + { + foreach (var size in plateSizes) + GetOrCompute(drawing, size.Width, size.Height, spacing); + } + + public static void Invalidate(Drawing drawing) + { + lock (_entriesLock) + _entries.Remove(CanonicalFrame.SourceOf(drawing)); + } + + public static void Populate( + Drawing drawing, + double plateWidth, + double plateHeight, + double spacing, + List results + ) + { + if (results == null || results.Count == 0) + return; + + GetEntry(drawing).Filtered.TryAdd((plateWidth, plateHeight, spacing), results); + } + + public static Dictionary< + (double PlateWidth, double PlateHeight, double Spacing), + List + > GetAllForDrawing(Drawing drawing) + { + var result = new Dictionary<(double, double, double), List>(); + var source = CanonicalFrame.SourceOf(drawing); + + if (_entries.TryGetValue(source, out var entry) && entry.IsCurrentFor(source)) + { + foreach (var kvp in entry.Filtered) + result[kvp.Key] = kvp.Value; + } + + return result; + } + + public static void Clear() + { + lock (_entriesLock) + _entries.Clear(); + } + + private static Entry GetEntry(Drawing drawing) + { + var source = CanonicalFrame.SourceOf(drawing); + + if (_entries.TryGetValue(source, out var entry) && entry.IsCurrentFor(source)) + return entry; + + lock (_entriesLock) + { + if (_entries.TryGetValue(source, out entry) && entry.IsCurrentFor(source)) + return entry; + + entry = new Entry(source); + _entries.AddOrUpdate(source, entry); + return entry; + } + } + + private static List GetCandidates(Entry entry, Drawing drawing, double spacing) + { + var lazy = entry.Unfiltered.GetOrAdd( + spacing, + _ => new Lazy>( + () => ComputeCandidates(drawing, spacing), + LazyThreadSafetyMode.ExecutionAndPublication + ) + ); + + try + { + return lazy.Value; + } + catch + { + // Don't cache the failure; the next caller retries. + entry.Unfiltered.TryRemove(new KeyValuePair>>(spacing, lazy)); + throw; + } + } + + private static List ComputeCandidates(Drawing drawing, double spacing) + { // Operate on the canonical frame so cached pair positions are orientation-invariant. var canonical = CanonicalFrame.AsCanonicalCopy(drawing); @@ -57,11 +165,9 @@ namespace OpenNest.Engine.BestFit } } - var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer); - var results = finder.FindBestFits(canonical, spacing, StepSize); - - _cache.TryAdd(key, results); - return results; + // The plate size only feeds the filter, which FindCandidates skips. + var finder = new BestFitFinder(0, 0, evaluator, slideComputer); + return finder.FindCandidates(canonical, spacing, StepSize); } finally { @@ -70,191 +176,57 @@ namespace OpenNest.Engine.BestFit } } - public static void ComputeForSizes( - Drawing drawing, - double spacing, - IEnumerable<(double Width, double Height)> plateSizes - ) - { - // Skip sizes that are already cached. - var needed = new List<(double Width, double Height)>(); - foreach (var size in plateSizes) - { - var key = new CacheKey(drawing, size.Width, size.Height, spacing); - if (!_cache.ContainsKey(key)) - needed.Add(size); - } - - if (needed.Count == 0) - return; - - // Find the largest plate to use for the initial computation — this - // keeps the filter maximally permissive so we don't discard results - // that a smaller plate might still use after re-filtering. - var maxWidth = 0.0; - var maxHeight = 0.0; - foreach (var size in needed) - { - if (size.Width > maxWidth) - maxWidth = size.Width; - if (size.Height > maxHeight) - maxHeight = size.Height; - } - - IPairEvaluator evaluator = null; - ISlideComputer slideComputer = null; - - try - { - // Operate on the canonical frame so cached pair positions are orientation-invariant. - var canonical = CanonicalFrame.AsCanonicalCopy(drawing); - - if (CreateEvaluator != null) - { - try - { - evaluator = CreateEvaluator(canonical, spacing); - } - catch - { /* fall back to default evaluator */ - } - } - - if (CreateSlideComputer != null) - { - try - { - slideComputer = CreateSlideComputer(); - } - catch - { /* fall back to CPU slide computation */ - } - } - - // Compute candidates and evaluate once with the largest plate. - var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer); - var baseResults = finder.FindBestFits(canonical, spacing, StepSize); - - // Cache a filtered copy for each plate size. - foreach (var size in needed) - { - var filter = new BestFitFilter - { - MaxPlateWidth = size.Width, - MaxPlateHeight = size.Height, - }; - - var copy = new List(baseResults.Count); - for (var i = 0; i < baseResults.Count; i++) - { - var r = baseResults[i]; - copy.Add( - new BestFitResult - { - Candidate = r.Candidate, - RotatedArea = r.RotatedArea, - BoundingWidth = r.BoundingWidth, - BoundingHeight = r.BoundingHeight, - OptimalRotation = r.OptimalRotation, - TrueArea = r.TrueArea, - HullAngles = r.HullAngles, - Keep = r.Keep, - Reason = r.Reason, - } - ); - } - - filter.Apply(copy); - - var key = new CacheKey(drawing, size.Width, size.Height, spacing); - _cache.TryAdd(key, copy); - } - } - finally - { - (evaluator as IDisposable)?.Dispose(); - } - } - - public static void Invalidate(Drawing drawing) - { - foreach (var key in _cache.Keys) - { - if (ReferenceEquals(key.Drawing, drawing)) - _cache.TryRemove(key, out _); - } - } - - public static void Populate( - Drawing drawing, + private static List FilterForSize( + List candidates, double plateWidth, - double plateHeight, - double spacing, - List results + double plateHeight ) { - if (results == null || results.Count == 0) - return; + var copy = new List(candidates.Count); + for (var i = 0; i < candidates.Count; i++) + { + var r = candidates[i]; + copy.Add( + new BestFitResult + { + Candidate = r.Candidate, + RotatedArea = r.RotatedArea, + BoundingWidth = r.BoundingWidth, + BoundingHeight = r.BoundingHeight, + OptimalRotation = r.OptimalRotation, + TrueArea = r.TrueArea, + HullAngles = r.HullAngles, + Keep = r.Keep, + Reason = r.Reason, + } + ); + } - var key = new CacheKey(drawing, plateWidth, plateHeight, spacing); - _cache.TryAdd(key, results); + BestFitFinder.CreateFilter(plateWidth, plateHeight).Apply(copy); + return copy; } - public static Dictionary< - (double PlateWidth, double PlateHeight, double Spacing), - List - > GetAllForDrawing(Drawing drawing) + private sealed class Entry { - var result = new Dictionary<(double, double, double), List>(); - foreach (var kvp in _cache) + private readonly CNC.Program _program; + private readonly double _sourceAngle; + + public readonly ConcurrentDictionary>> Unfiltered = new(); + + public readonly ConcurrentDictionary< + (double PlateWidth, double PlateHeight, double Spacing), + List + > Filtered = new(); + + public Entry(Drawing source) { - if (ReferenceEquals(kvp.Key.Drawing, drawing)) - result[(kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing)] = kvp.Value; - } - return result; - } - - public static void Clear() - { - _cache.Clear(); - } - - private readonly struct CacheKey : IEquatable - { - public readonly Drawing Drawing; - public readonly double PlateWidth; - public readonly double PlateHeight; - public readonly double Spacing; - - public CacheKey(Drawing drawing, double plateWidth, double plateHeight, double spacing) - { - Drawing = drawing; - PlateWidth = plateWidth; - PlateHeight = plateHeight; - Spacing = spacing; + _program = source.Program; + _sourceAngle = source.Source?.Angle ?? 0.0; } - public bool Equals(CacheKey other) - { - return ReferenceEquals(Drawing, other.Drawing) - && PlateWidth == other.PlateWidth - && PlateHeight == other.PlateHeight - && Spacing == other.Spacing; - } - - public override bool Equals(object obj) => obj is CacheKey other && Equals(other); - - public override int GetHashCode() - { - unchecked - { - var hash = RuntimeHelpers.GetHashCode(Drawing); - hash = hash * 397 ^ PlateWidth.GetHashCode(); - hash = hash * 397 ^ PlateHeight.GetHashCode(); - hash = hash * 397 ^ Spacing.GetHashCode(); - return hash; - } - } + public bool IsCurrentFor(Drawing source) => + ReferenceEquals(_program, source.Program) + && _sourceAngle == (source.Source?.Angle ?? 0.0); } } } diff --git a/OpenNest.Engine/BestFit/BestFitFinder.cs b/OpenNest.Engine/BestFit/BestFitFinder.cs index c9eb3ab..47b9ac0 100644 --- a/OpenNest.Engine/BestFit/BestFitFinder.cs +++ b/OpenNest.Engine/BestFit/BestFitFinder.cs @@ -28,10 +28,19 @@ namespace OpenNest.Engine.BestFit slideComputer != null ? (IDistanceComputer)new GpuDistanceComputer(slideComputer) : new CpuDistanceComputer(); + _filter = CreateFilter(maxPlateWidth, maxPlateHeight); + } + + /// + /// The filter applies for a plate of the given size. The + /// aspect-ratio limit widens with the plate's own aspect. + /// + public static BestFitFilter CreateFilter(double maxPlateWidth, double maxPlateHeight) + { var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) / System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001); - _filter = new BestFitFilter + return new BestFitFilter { MaxPlateWidth = maxPlateWidth, MaxPlateHeight = maxPlateHeight, @@ -46,6 +55,39 @@ namespace OpenNest.Engine.BestFit BestFitSortField sortBy = BestFitSortField.Area ) { + var results = Evaluate(drawing, spacing, stepSize); + + _filter.Apply(results); + + return Number(SortResults(results, sortBy)); + } + + /// + /// Evaluates every pair candidate without the plate-size filter, sorted by area. Applying + /// for a plate size to shallow copies of these results gives + /// the same results returns for that size, so one run can + /// serve several sizes. + /// + public List FindCandidates( + Drawing drawing, + double spacing = 0.25, + double stepSize = 0.25 + ) + { + return Number(SortResults(Evaluate(drawing, spacing, stepSize), BestFitSortField.Area)); + } + + private static List Number(List results) + { + for (var i = 0; i < results.Count; i++) + results[i].Candidate.TestNumber = i; + + return results; + } + + private List Evaluate(Drawing drawing, double spacing, double stepSize) + { + PerfCounters.CountFindBestFits(); var strategies = BuildStrategies(drawing, spacing); var candidateBags = new ConcurrentBag>(); @@ -64,16 +106,7 @@ namespace OpenNest.Engine.BestFit $"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates" ); - var results = _evaluator.EvaluateAll(allCandidates); - - _filter.Apply(results); - - results = SortResults(results, sortBy); - - for (var i = 0; i < results.Count; i++) - results[i].Candidate.TestNumber = i; - - return results; + return _evaluator.EvaluateAll(allCandidates); } public List FindAndTile( diff --git a/OpenNest.Engine/CanonicalFrame.cs b/OpenNest.Engine/CanonicalFrame.cs index 4ac9f15..09e1944 100644 --- a/OpenNest.Engine/CanonicalFrame.cs +++ b/OpenNest.Engine/CanonicalFrame.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Runtime.CompilerServices; using OpenNest.CNC; using OpenNest.Geometry; using OpenNest.Math; @@ -11,6 +12,22 @@ namespace OpenNest.Engine /// public static class CanonicalFrame { + // Maps each canonical copy to the drawing it was ultimately copied from, so caches keyed + // by drawing identity hit across the transient copies each fill makes. + private static readonly ConditionalWeakTable sourceOf = new(); + + /// + /// Returns the drawing a canonical copy was made from (following copies of copies back to + /// the root), or itself when it is not a canonical copy. + /// + internal static Drawing SourceOf(Drawing drawing) + { + if (drawing == null) + return null; + + return sourceOf.TryGetValue(drawing, out var root) ? root : drawing; + } + /// /// Returns a new Drawing whose Program geometry is rotated to the canonical frame. /// The source drawing is not mutated. @@ -44,6 +61,7 @@ namespace OpenNest.Engine Angle = 0.0, }, }; + sourceOf.AddOrUpdate(copy, SourceOf(drawing)); return copy; } diff --git a/OpenNest.Engine/Fill/FillResultCache.cs b/OpenNest.Engine/Fill/FillResultCache.cs index bc5d009..db9c81a 100644 --- a/OpenNest.Engine/Fill/FillResultCache.cs +++ b/OpenNest.Engine/Fill/FillResultCache.cs @@ -9,10 +9,16 @@ namespace OpenNest.Engine.Fill; /// Caches fill results by drawing and box dimensions so repeated fills /// of the same size don't recompute. Parts are stored normalized to origin /// and offset to the actual location on retrieval. +/// +/// Entries are keyed weakly by the source drawing, so every canonical copy of one drawing shares +/// them. Canonical and non-canonical callers are kept apart because cached parts are in the frame +/// of the drawing they were computed for. An entry is dropped when the drawing's +/// instance or canonical angle changes. /// public static class FillResultCache { - private static readonly ConcurrentDictionary> _cache = new(); + private static readonly ConditionalWeakTable _entries = new(); + private static readonly object _entriesLock = new(); /// /// Returns a cached fill result for the given drawing and box dimensions, @@ -20,9 +26,13 @@ public static class FillResultCache /// public static List Get(Drawing drawing, Box targetBox, double spacing) { - var key = new CacheKey(drawing, targetBox.Width, targetBox.Length, spacing); + var source = CanonicalFrame.SourceOf(drawing); + if (!_entries.TryGetValue(source, out var entry) || !entry.IsCurrentFor(source)) + return null; - if (!_cache.TryGetValue(key, out var cached) || cached.Count == 0) + var key = new CacheKey(drawing, source, targetBox.Width, targetBox.Length, spacing); + + if (!entry.Results.TryGetValue(key, out var cached) || cached.Count == 0) return null; var offset = targetBox.Location; @@ -42,9 +52,11 @@ public static class FillResultCache if (parts == null || parts.Count == 0) return; - var key = new CacheKey(drawing, sourceBox.Width, sourceBox.Length, spacing); + var source = CanonicalFrame.SourceOf(drawing); + var entry = GetEntry(source); + var key = new CacheKey(drawing, source, sourceBox.Width, sourceBox.Length, spacing); - if (_cache.ContainsKey(key)) + if (entry.Results.ContainsKey(key)) return; var offset = new Vector(-sourceBox.X, -sourceBox.Y); @@ -53,46 +65,68 @@ public static class FillResultCache foreach (var part in parts) normalized.Add(part.CloneAtOffset(offset)); - _cache.TryAdd(key, normalized); + entry.Results.TryAdd(key, normalized); } - public static void Clear() => _cache.Clear(); - - public static int Count => _cache.Count; - - private readonly struct CacheKey : System.IEquatable + public static void Clear() { - public readonly Drawing Drawing; - public readonly double Width; - public readonly double Height; - public readonly double Spacing; + lock (_entriesLock) + _entries.Clear(); + } - public CacheKey(Drawing drawing, double width, double height, double spacing) + public static int Count + { + get { - Drawing = drawing; - Width = System.Math.Round(width, 2); - Height = System.Math.Round(height, 2); - Spacing = spacing; + var count = 0; + foreach (var kvp in _entries) + count += kvp.Value.Results.Count; + return count; + } + } + + private static Entry GetEntry(Drawing source) + { + if (_entries.TryGetValue(source, out var entry) && entry.IsCurrentFor(source)) + return entry; + + lock (_entriesLock) + { + if (_entries.TryGetValue(source, out entry) && entry.IsCurrentFor(source)) + return entry; + + entry = new Entry(source); + _entries.AddOrUpdate(source, entry); + return entry; + } + } + + private sealed class Entry + { + private readonly CNC.Program program; + private readonly double sourceAngle; + + public readonly ConcurrentDictionary> Results = new(); + + public Entry(Drawing source) + { + program = source.Program; + sourceAngle = source.Source?.Angle ?? 0.0; } - public bool Equals(CacheKey other) => - ReferenceEquals(Drawing, other.Drawing) - && Width == other.Width - && Height == other.Height - && Spacing == other.Spacing; + public bool IsCurrentFor(Drawing source) => + ReferenceEquals(program, source.Program) + && sourceAngle == (source.Source?.Angle ?? 0.0); + } - public override bool Equals(object obj) => obj is CacheKey other && Equals(other); - - public override int GetHashCode() - { - unchecked - { - var hash = RuntimeHelpers.GetHashCode(Drawing); - hash = hash * 397 ^ Width.GetHashCode(); - hash = hash * 397 ^ Height.GetHashCode(); - hash = hash * 397 ^ Spacing.GetHashCode(); - return hash; - } - } + private readonly record struct CacheKey(bool IsCanonical, double Width, double Height, double Spacing) + { + public CacheKey(Drawing drawing, Drawing source, double width, double height, double spacing) + : this( + !ReferenceEquals(drawing, source), + System.Math.Round(width, 2), + System.Math.Round(height, 2), + spacing + ) { } } } diff --git a/OpenNest.Tests/BestFit/BestFitCacheTests.cs b/OpenNest.Tests/BestFit/BestFitCacheTests.cs new file mode 100644 index 0000000..1781f1c --- /dev/null +++ b/OpenNest.Tests/BestFit/BestFitCacheTests.cs @@ -0,0 +1,196 @@ +using System.Runtime.CompilerServices; +using OpenNest.Engine; +using OpenNest.Engine.BestFit; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using OpenNest.Math; +using OpenNest.Shapes; + +namespace OpenNest.Tests.BestFit; + +// BestFitCache is process-wide static state and these tests swap its evaluator factory, +// so they must not run alongside other tests. +[CollectionDefinition(nameof(FillCacheCollection), DisableParallelization = true)] +public class FillCacheCollection { } + +[Collection(nameof(FillCacheCollection))] +public class BestFitCacheTests +{ + private const double Spacing = 0.25; + + private static Drawing MakeRotatedTShape() + { + var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing(); + drawing.Program.Rotate(Angle.ToRadians(30), drawing.Program.BoundingBox().Center); + drawing.RecomputeCanonicalAngle(); + return drawing; + } + + /// + /// Counts best-fit computations for one source drawing by wrapping the evaluator factory. + /// + private sealed class EvaluatorSpy : IDisposable + { + private readonly Func previous; + private readonly WeakReference source; + private int count; + + public EvaluatorSpy(Drawing source) + { + this.source = new WeakReference(source); + previous = BestFitCache.CreateEvaluator; + BestFitCache.CreateEvaluator = (drawing, spacing) => + { + if (this.source.TryGetTarget(out var target) + && ReferenceEquals(CanonicalFrame.SourceOf(drawing), target)) + Interlocked.Increment(ref count); + return new PairEvaluator(); + }; + } + + public int Count => Volatile.Read(ref count); + + public void Dispose() => BestFitCache.CreateEvaluator = previous; + } + + [Fact] + public void GetOrCompute_CanonicalCopiesOfOneDrawing_ShareOneComputation() + { + var drawing = MakeRotatedTShape(); + using var spy = new EvaluatorSpy(drawing); + + var first = BestFitCache.GetOrCompute(CanonicalFrame.AsCanonicalCopy(drawing), 60, 40, Spacing); + var copyOfCopy = CanonicalFrame.AsCanonicalCopy(CanonicalFrame.AsCanonicalCopy(drawing)); + var second = BestFitCache.GetOrCompute(copyOfCopy, 60, 40, Spacing); + var third = BestFitCache.GetOrCompute(drawing, 60, 40, Spacing); + + Assert.Same(first, second); + Assert.Same(first, third); + Assert.Equal(1, spy.Count); + } + + [Fact] + public void GetOrCompute_TwoPlateSizes_RunsFinderOnceAndMatchesPerSizeFinder() + { + var drawing = MakeRotatedTShape(); + using var spy = new EvaluatorSpy(drawing); + + var sizes = new[] { (Width: 60.0, Height: 40.0), (Width: 14.0, Height: 30.0) }; + var cached = sizes + .Select(s => BestFitCache.GetOrCompute(drawing, s.Width, s.Height, Spacing)) + .ToList(); + + Assert.Equal(1, spy.Count); + + var canonical = CanonicalFrame.AsCanonicalCopy(drawing); + for (var i = 0; i < sizes.Length; i++) + { + var expected = new BestFitFinder(sizes[i].Width, sizes[i].Height) + .FindBestFits(canonical, Spacing, 0.25); + + Assert.Equal(Signature(expected), Signature(cached[i])); + } + + // The small plate must actually reject something the large one keeps. + Assert.True(cached[1].Count(r => r.Keep) < cached[0].Count(r => r.Keep)); + } + + [Fact] + public void GetOrCompute_ReplacingProgram_InvalidatesEntry() + { + var drawing = MakeRotatedTShape(); + using var spy = new EvaluatorSpy(drawing); + + var before = BestFitCache.GetOrCompute(drawing, 60, 40, Spacing); + drawing.Program = (OpenNest.CNC.Program)drawing.Program.Clone(); + var after = BestFitCache.GetOrCompute(drawing, 60, 40, Spacing); + + Assert.NotSame(before, after); + Assert.Equal(2, spy.Count); + } + + [Fact] + public void GetOrCompute_ReleasedDrawing_IsCollectable() + { + var weak = ComputeForTransientDrawing(); + + for (var i = 0; i < 3 && weak.IsAlive; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + Assert.False(weak.IsAlive); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference ComputeForTransientDrawing() + { + var drawing = MakeRotatedTShape(); + var canonical = CanonicalFrame.AsCanonicalCopy(drawing); + BestFitCache.GetOrCompute(canonical, 60, 40, Spacing); + FillResultCache.Store(canonical, new Box(0, 0, 60, 40), Spacing, new List { new Part(canonical) }); + return new WeakReference(drawing); + } + + [Fact] + public void Populate_FeedsGetOrComputeAndRoundTripsThroughGetAllForDrawing() + { + var drawing = MakeRotatedTShape(); + using var spy = new EvaluatorSpy(drawing); + var results = new List + { + new BestFitResult { Candidate = new PairCandidate { Drawing = drawing }, Keep = true }, + }; + + BestFitCache.Populate(drawing, 60, 40, Spacing, results); + + Assert.Same(results, BestFitCache.GetOrCompute(CanonicalFrame.AsCanonicalCopy(drawing), 60, 40, Spacing)); + Assert.Equal(0, spy.Count); + + var all = BestFitCache.GetAllForDrawing(drawing); + Assert.Single(all); + Assert.Same(results, all[(60, 40, Spacing)]); + } + + [Fact] + public void FillResultCache_HitThroughSecondCanonicalCopy_RebindsToSameParts() + { + var drawing = MakeRotatedTShape(); + var box = new Box(5, 7, 80, 50); + + var firstCopy = CanonicalFrame.AsCanonicalCopy(drawing); + var computed = new FillLinear(box, Spacing).Fill(firstCopy, 0, NestDirection.Horizontal); + Assert.NotEmpty(computed); + FillResultCache.Store(firstCopy, box, Spacing, computed); + + var secondCopy = CanonicalFrame.AsCanonicalCopy(drawing); + var hit = FillResultCache.Get(secondCopy, box, Spacing); + Assert.NotNull(hit); + + // A non-canonical caller must not be served canonical-frame parts. + Assert.Null(FillResultCache.Get(drawing, box, Spacing)); + + var expected = CanonicalFrame.RebindToOriginal(computed.Select(p => (Part)p.Clone()).ToList(), drawing); + var actual = CanonicalFrame.RebindToOriginal(hit, drawing); + + Assert.Equal(expected.Count, actual.Count); + for (var i = 0; i < expected.Count; i++) + { + Assert.Same(drawing, actual[i].BaseDrawing); + Assert.Equal(expected[i].Rotation, actual[i].Rotation, 9); + Assert.Equal(expected[i].BoundingBox.X, actual[i].BoundingBox.X, 6); + Assert.Equal(expected[i].BoundingBox.Y, actual[i].BoundingBox.Y, 6); + } + } + + private static List Signature(List results) => + results + .Select(r => + FormattableString.Invariant( + $"{r.Candidate.Part2Rotation:F6}|{r.Candidate.Part2Offset.X:F6}|{r.Candidate.Part2Offset.Y:F6}|{r.RotatedArea:F6}|{r.Keep}|{r.Reason}" + ) + ) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); +}