perf(engine): key fill caches by source drawing so they hit across trials
Every DefaultPlateFiller.Fill makes a fresh canonical copy of the drawing, and BestFitCache/FillResultCache keyed by drawing reference, so fills never shared results and the static caches grew without bound. CanonicalFrame now records which drawing each canonical copy came from. Both caches key weakly on that source drawing, so every canonical copy shares one entry and released drawings can be collected. An entry is dropped when the drawing's Program instance or canonical angle changes. Best-fit candidates are computed once per (drawing, spacing) through BestFitFinder.FindCandidates and filtered per plate size with the same filter FindBestFits uses. FillResultCache keeps canonical and non-canonical callers apart. Adds Debug-only PerfCounters for best-fit runs, offset perimeter builds and Part.Intersects calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -234,6 +234,7 @@ namespace OpenNest
|
|||||||
|
|
||||||
public bool Intersects(Part part, out List<Vector> pts)
|
public bool Intersects(Part part, out List<Vector> pts)
|
||||||
{
|
{
|
||||||
|
PerfCounters.CountPartIntersects();
|
||||||
pts = new List<Vector>();
|
pts = new List<Vector>();
|
||||||
|
|
||||||
var entities1 = ConvertProgram
|
var entities1 = ConvertProgram
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
|
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
|
||||||
{
|
{
|
||||||
|
PerfCounters.CountOffsetPerimeterEntities();
|
||||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||||
var profile = new ShapeProfile(
|
var profile = new ShapeProfile(
|
||||||
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()
|
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,23 @@ using System;
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
namespace OpenNest.Engine.BestFit
|
namespace OpenNest.Engine.BestFit
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Best-fit pair results per drawing. Entries are keyed weakly by the source drawing (canonical
|
||||||
|
/// copies resolve through <see cref="CanonicalFrame.SourceOf"/>), 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
|
||||||
|
/// <see cref="Drawing.Program"/> instance or canonical angle changes.
|
||||||
|
/// </summary>
|
||||||
public static class BestFitCache
|
public static class BestFitCache
|
||||||
{
|
{
|
||||||
private const double StepSize = 0.25;
|
private const double StepSize = 0.25;
|
||||||
|
|
||||||
private static readonly ConcurrentDictionary<CacheKey, List<BestFitResult>> _cache =
|
private static readonly ConditionalWeakTable<Drawing, Entry> _entries = new();
|
||||||
new ConcurrentDictionary<CacheKey, List<BestFitResult>>();
|
private static readonly object _entriesLock = new();
|
||||||
|
|
||||||
public static Func<Drawing, double, IPairEvaluator> CreateEvaluator { get; set; }
|
public static Func<Drawing, double, IPairEvaluator> CreateEvaluator { get; set; }
|
||||||
public static Func<ISlideComputer> CreateSlideComputer { get; set; }
|
public static Func<ISlideComputer> CreateSlideComputer { get; set; }
|
||||||
@@ -22,11 +30,111 @@ namespace OpenNest.Engine.BestFit
|
|||||||
double spacing
|
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;
|
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<BestFitResult> 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<BestFitResult>
|
||||||
|
> GetAllForDrawing(Drawing drawing)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<(double, double, double), List<BestFitResult>>();
|
||||||
|
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<BestFitResult> GetCandidates(Entry entry, Drawing drawing, double spacing)
|
||||||
|
{
|
||||||
|
var lazy = entry.Unfiltered.GetOrAdd(
|
||||||
|
spacing,
|
||||||
|
_ => new Lazy<List<BestFitResult>>(
|
||||||
|
() => ComputeCandidates(drawing, spacing),
|
||||||
|
LazyThreadSafetyMode.ExecutionAndPublication
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return lazy.Value;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Don't cache the failure; the next caller retries.
|
||||||
|
entry.Unfiltered.TryRemove(new KeyValuePair<double, Lazy<List<BestFitResult>>>(spacing, lazy));
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<BestFitResult> ComputeCandidates(Drawing drawing, double spacing)
|
||||||
|
{
|
||||||
// Operate on the canonical frame so cached pair positions are orientation-invariant.
|
// Operate on the canonical frame so cached pair positions are orientation-invariant.
|
||||||
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
@@ -57,11 +165,9 @@ namespace OpenNest.Engine.BestFit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
|
// The plate size only feeds the filter, which FindCandidates skips.
|
||||||
var results = finder.FindBestFits(canonical, spacing, StepSize);
|
var finder = new BestFitFinder(0, 0, evaluator, slideComputer);
|
||||||
|
return finder.FindCandidates(canonical, spacing, StepSize);
|
||||||
_cache.TryAdd(key, results);
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -70,191 +176,57 @@ namespace OpenNest.Engine.BestFit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ComputeForSizes(
|
private static List<BestFitResult> FilterForSize(
|
||||||
Drawing drawing,
|
List<BestFitResult> candidates,
|
||||||
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<BestFitResult>(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,
|
|
||||||
double plateWidth,
|
double plateWidth,
|
||||||
double plateHeight,
|
double plateHeight
|
||||||
double spacing,
|
|
||||||
List<BestFitResult> results
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (results == null || results.Count == 0)
|
var copy = new List<BestFitResult>(candidates.Count);
|
||||||
return;
|
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);
|
BestFitFinder.CreateFilter(plateWidth, plateHeight).Apply(copy);
|
||||||
_cache.TryAdd(key, results);
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Dictionary<
|
private sealed class Entry
|
||||||
(double PlateWidth, double PlateHeight, double Spacing),
|
|
||||||
List<BestFitResult>
|
|
||||||
> GetAllForDrawing(Drawing drawing)
|
|
||||||
{
|
{
|
||||||
var result = new Dictionary<(double, double, double), List<BestFitResult>>();
|
private readonly CNC.Program _program;
|
||||||
foreach (var kvp in _cache)
|
private readonly double _sourceAngle;
|
||||||
|
|
||||||
|
public readonly ConcurrentDictionary<double, Lazy<List<BestFitResult>>> Unfiltered = new();
|
||||||
|
|
||||||
|
public readonly ConcurrentDictionary<
|
||||||
|
(double PlateWidth, double PlateHeight, double Spacing),
|
||||||
|
List<BestFitResult>
|
||||||
|
> Filtered = new();
|
||||||
|
|
||||||
|
public Entry(Drawing source)
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(kvp.Key.Drawing, drawing))
|
_program = source.Program;
|
||||||
result[(kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing)] = kvp.Value;
|
_sourceAngle = source.Source?.Angle ?? 0.0;
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Clear()
|
|
||||||
{
|
|
||||||
_cache.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly struct CacheKey : IEquatable<CacheKey>
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Equals(CacheKey other)
|
public bool IsCurrentFor(Drawing source) =>
|
||||||
{
|
ReferenceEquals(_program, source.Program)
|
||||||
return ReferenceEquals(Drawing, other.Drawing)
|
&& _sourceAngle == (source.Source?.Angle ?? 0.0);
|
||||||
&& 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,19 @@ namespace OpenNest.Engine.BestFit
|
|||||||
slideComputer != null
|
slideComputer != null
|
||||||
? (IDistanceComputer)new GpuDistanceComputer(slideComputer)
|
? (IDistanceComputer)new GpuDistanceComputer(slideComputer)
|
||||||
: new CpuDistanceComputer();
|
: new CpuDistanceComputer();
|
||||||
|
_filter = CreateFilter(maxPlateWidth, maxPlateHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The filter <see cref="FindBestFits"/> applies for a plate of the given size. The
|
||||||
|
/// aspect-ratio limit widens with the plate's own aspect.
|
||||||
|
/// </summary>
|
||||||
|
public static BestFitFilter CreateFilter(double maxPlateWidth, double maxPlateHeight)
|
||||||
|
{
|
||||||
var plateAspect =
|
var plateAspect =
|
||||||
System.Math.Max(maxPlateWidth, maxPlateHeight)
|
System.Math.Max(maxPlateWidth, maxPlateHeight)
|
||||||
/ System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
|
/ System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
|
||||||
_filter = new BestFitFilter
|
return new BestFitFilter
|
||||||
{
|
{
|
||||||
MaxPlateWidth = maxPlateWidth,
|
MaxPlateWidth = maxPlateWidth,
|
||||||
MaxPlateHeight = maxPlateHeight,
|
MaxPlateHeight = maxPlateHeight,
|
||||||
@@ -46,6 +55,39 @@ namespace OpenNest.Engine.BestFit
|
|||||||
BestFitSortField sortBy = BestFitSortField.Area
|
BestFitSortField sortBy = BestFitSortField.Area
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
var results = Evaluate(drawing, spacing, stepSize);
|
||||||
|
|
||||||
|
_filter.Apply(results);
|
||||||
|
|
||||||
|
return Number(SortResults(results, sortBy));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates every pair candidate without the plate-size filter, sorted by area. Applying
|
||||||
|
/// <see cref="CreateFilter"/> for a plate size to shallow copies of these results gives
|
||||||
|
/// the same results <see cref="FindBestFits"/> returns for that size, so one run can
|
||||||
|
/// serve several sizes.
|
||||||
|
/// </summary>
|
||||||
|
public List<BestFitResult> FindCandidates(
|
||||||
|
Drawing drawing,
|
||||||
|
double spacing = 0.25,
|
||||||
|
double stepSize = 0.25
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Number(SortResults(Evaluate(drawing, spacing, stepSize), BestFitSortField.Area));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<BestFitResult> Number(List<BestFitResult> results)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < results.Count; i++)
|
||||||
|
results[i].Candidate.TestNumber = i;
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<BestFitResult> Evaluate(Drawing drawing, double spacing, double stepSize)
|
||||||
|
{
|
||||||
|
PerfCounters.CountFindBestFits();
|
||||||
var strategies = BuildStrategies(drawing, spacing);
|
var strategies = BuildStrategies(drawing, spacing);
|
||||||
|
|
||||||
var candidateBags = new ConcurrentBag<List<PairCandidate>>();
|
var candidateBags = new ConcurrentBag<List<PairCandidate>>();
|
||||||
@@ -64,16 +106,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
$"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates"
|
$"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates"
|
||||||
);
|
);
|
||||||
|
|
||||||
var results = _evaluator.EvaluateAll(allCandidates);
|
return _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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<TileResult> FindAndTile(
|
public List<TileResult> FindAndTile(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
@@ -11,6 +12,22 @@ namespace OpenNest.Engine
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CanonicalFrame
|
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<Drawing, Drawing> sourceOf = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the drawing a canonical copy was made from (following copies of copies back to
|
||||||
|
/// the root), or <paramref name="drawing"/> itself when it is not a canonical copy.
|
||||||
|
/// </summary>
|
||||||
|
internal static Drawing SourceOf(Drawing drawing)
|
||||||
|
{
|
||||||
|
if (drawing == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return sourceOf.TryGetValue(drawing, out var root) ? root : drawing;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
|
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
|
||||||
/// The source drawing is not mutated.
|
/// The source drawing is not mutated.
|
||||||
@@ -44,6 +61,7 @@ namespace OpenNest.Engine
|
|||||||
Angle = 0.0,
|
Angle = 0.0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
sourceOf.AddOrUpdate(copy, SourceOf(drawing));
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ namespace OpenNest.Engine.Fill;
|
|||||||
/// Caches fill results by drawing and box dimensions so repeated fills
|
/// Caches fill results by drawing and box dimensions so repeated fills
|
||||||
/// of the same size don't recompute. Parts are stored normalized to origin
|
/// of the same size don't recompute. Parts are stored normalized to origin
|
||||||
/// and offset to the actual location on retrieval.
|
/// 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
|
||||||
|
/// <see cref="Drawing.Program"/> instance or canonical angle changes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class FillResultCache
|
public static class FillResultCache
|
||||||
{
|
{
|
||||||
private static readonly ConcurrentDictionary<CacheKey, List<Part>> _cache = new();
|
private static readonly ConditionalWeakTable<Drawing, Entry> _entries = new();
|
||||||
|
private static readonly object _entriesLock = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a cached fill result for the given drawing and box dimensions,
|
/// Returns a cached fill result for the given drawing and box dimensions,
|
||||||
@@ -20,9 +26,13 @@ public static class FillResultCache
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<Part> Get(Drawing drawing, Box targetBox, double spacing)
|
public static List<Part> 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;
|
return null;
|
||||||
|
|
||||||
var offset = targetBox.Location;
|
var offset = targetBox.Location;
|
||||||
@@ -42,9 +52,11 @@ public static class FillResultCache
|
|||||||
if (parts == null || parts.Count == 0)
|
if (parts == null || parts.Count == 0)
|
||||||
return;
|
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;
|
return;
|
||||||
|
|
||||||
var offset = new Vector(-sourceBox.X, -sourceBox.Y);
|
var offset = new Vector(-sourceBox.X, -sourceBox.Y);
|
||||||
@@ -53,46 +65,68 @@ public static class FillResultCache
|
|||||||
foreach (var part in parts)
|
foreach (var part in parts)
|
||||||
normalized.Add(part.CloneAtOffset(offset));
|
normalized.Add(part.CloneAtOffset(offset));
|
||||||
|
|
||||||
_cache.TryAdd(key, normalized);
|
entry.Results.TryAdd(key, normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Clear() => _cache.Clear();
|
public static void Clear()
|
||||||
|
|
||||||
public static int Count => _cache.Count;
|
|
||||||
|
|
||||||
private readonly struct CacheKey : System.IEquatable<CacheKey>
|
|
||||||
{
|
{
|
||||||
public readonly Drawing Drawing;
|
lock (_entriesLock)
|
||||||
public readonly double Width;
|
_entries.Clear();
|
||||||
public readonly double Height;
|
}
|
||||||
public readonly double Spacing;
|
|
||||||
|
|
||||||
public CacheKey(Drawing drawing, double width, double height, double spacing)
|
public static int Count
|
||||||
|
{
|
||||||
|
get
|
||||||
{
|
{
|
||||||
Drawing = drawing;
|
var count = 0;
|
||||||
Width = System.Math.Round(width, 2);
|
foreach (var kvp in _entries)
|
||||||
Height = System.Math.Round(height, 2);
|
count += kvp.Value.Results.Count;
|
||||||
Spacing = spacing;
|
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<CacheKey, List<Part>> Results = new();
|
||||||
|
|
||||||
|
public Entry(Drawing source)
|
||||||
|
{
|
||||||
|
program = source.Program;
|
||||||
|
sourceAngle = source.Source?.Angle ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Equals(CacheKey other) =>
|
public bool IsCurrentFor(Drawing source) =>
|
||||||
ReferenceEquals(Drawing, other.Drawing)
|
ReferenceEquals(program, source.Program)
|
||||||
&& Width == other.Width
|
&& sourceAngle == (source.Source?.Angle ?? 0.0);
|
||||||
&& Height == other.Height
|
}
|
||||||
&& Spacing == other.Spacing;
|
|
||||||
|
|
||||||
public override bool Equals(object obj) => obj is CacheKey other && Equals(other);
|
private readonly record struct CacheKey(bool IsCanonical, double Width, double Height, double Spacing)
|
||||||
|
{
|
||||||
public override int GetHashCode()
|
public CacheKey(Drawing drawing, Drawing source, double width, double height, double spacing)
|
||||||
{
|
: this(
|
||||||
unchecked
|
!ReferenceEquals(drawing, source),
|
||||||
{
|
System.Math.Round(width, 2),
|
||||||
var hash = RuntimeHelpers.GetHashCode(Drawing);
|
System.Math.Round(height, 2),
|
||||||
hash = hash * 397 ^ Width.GetHashCode();
|
spacing
|
||||||
hash = hash * 397 ^ Height.GetHashCode();
|
) { }
|
||||||
hash = hash * 397 ^ Spacing.GetHashCode();
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts best-fit computations for one source drawing by wrapping the evaluator factory.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class EvaluatorSpy : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Func<Drawing, double, IPairEvaluator> previous;
|
||||||
|
private readonly WeakReference<Drawing> source;
|
||||||
|
private int count;
|
||||||
|
|
||||||
|
public EvaluatorSpy(Drawing source)
|
||||||
|
{
|
||||||
|
this.source = new WeakReference<Drawing>(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<Part> { 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<BestFitResult>
|
||||||
|
{
|
||||||
|
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<string> Signature(List<BestFitResult> 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user