Merge branch 'perf/fill-pipeline'

This commit is contained in:
aj
2026-09-23 14:30:46 -04:00
10 changed files with 778 additions and 267 deletions
+1
View File
@@ -234,6 +234,7 @@ namespace OpenNest
public bool Intersects(Part part, out List<Vector> pts)
{
PerfCounters.CountPartIntersects();
pts = new List<Vector>();
var entities1 = ConvertProgram
+1
View File
@@ -53,6 +53,7 @@ namespace OpenNest
/// </summary>
public static List<Entity> 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()
+37
View File
@@ -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);
}
}
}
@@ -0,0 +1,185 @@
using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// The candidate validator flattens each placed contour once and reuses it across pairs. These
/// tests compare its accept/reject decision with a reference that rebuilds every contour and
/// checks every edge pair, on arc-heavy parts placed right around the spacing.
/// </summary>
public class NestJobSpacingValidationTests
{
private const double Spacing = 0.25;
private const double Epsilon = 0.0000001;
private const double Origin = 50;
public static IEnumerable<object[]> Shapes() =>
new[]
{
new object[] { "rounded", new RoundedRectangleShape { Length = 12, Width = 6, Radius = 1.5 }.GetDrawing().Program },
new object[] { "ring", new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing().Program },
};
[Theory]
[MemberData(nameof(Shapes))]
public void SpacingDecisionMatchesBruteForceNearTheLimit(string name, OpenNest.CNC.Program program)
{
var geometry = PartGeometrySnapshot.FromProgram(program);
var part = new NestJobPart("part", geometry, 2);
var job = new NestJob(
new[] { part },
new[] { new NestPlateStock("stock", new Size(200, 200), 1, Spacing) }
);
var bounds = program.BoundingBox();
var random = new Random(name.GetHashCode(StringComparison.Ordinal) & 0x7fff);
var accepted = 0;
var rejected = 0;
for (var sample = 0; sample < 60; sample++)
{
// Second part beside or above the first with a gap near the spacing, shifted
// sideways so corners and arcs meet at varied angles, and sometimes turned.
var gap = Spacing + (random.NextDouble() - 0.5) * 0.06;
var rotation = sample % 3 == 0 ? System.Math.PI : sample % 3 == 1 ? 0.0 : 0.05;
var shift = (random.NextDouble() - 0.5) * 0.5 * bounds.Width;
var beside = sample % 2 == 0;
var second = beside
? new NestJobPlacement("part", 1, Origin + bounds.Length + gap, Origin + shift, rotation)
: new NestJobPlacement("part", 1, Origin + shift, Origin + bounds.Width + gap, rotation);
var first = new NestJobPlacement("part", 0, Origin, Origin, 0);
var expectedValid = !ReferenceViolates(geometry, first, second);
var actualValid = IsValid(job, first, second);
Assert.True(
expectedValid == actualValid,
$"{name} sample {sample}: gap {gap:F5}, shift {shift:F4}, rotation {rotation}: "
+ $"brute force {(expectedValid ? "accepts" : "rejects")}, validator {(actualValid ? "accepts" : "rejects")}"
);
if (actualValid)
accepted++;
else
rejected++;
}
// The sampling has to exercise both outcomes to mean anything.
Assert.True(accepted > 5, $"only {accepted} accepted");
Assert.True(rejected > 5, $"only {rejected} rejected");
}
[Fact]
public void RingsPlacedExactlyAtSpacingPass()
{
var program = new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing().Program;
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 2);
var job = new NestJob(
new[] { part },
new[] { new NestPlateStock("stock", new Size(200, 200), 1, Spacing) }
);
// Inscribed chords never bring arcs closer, so an exact-spacing layout stays valid.
Assert.True(IsValid(
job,
new NestJobPlacement("part", 0, Origin, Origin, 0),
new NestJobPlacement("part", 1, Origin + 8 + Spacing, Origin, 0)
));
Assert.False(IsValid(
job,
new NestJobPlacement("part", 0, Origin, Origin, 0),
new NestJobPlacement("part", 1, Origin + 8 + Spacing - 0.01, Origin, 0)
));
}
private static bool IsValid(NestJob job, params NestJobPlacement[] placements)
{
try
{
new NestJobRunner(_ => new CandidateNester(placements)).Solve(job);
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
private static bool ReferenceViolates(
PartGeometrySnapshot geometry,
NestJobPlacement first,
NestJobPlacement second
)
{
var a = Contours(geometry, first);
var b = Contours(geometry, second);
if (Collision.HasOverlap(a[0], b[0], a.Skip(1).ToList(), b.Skip(1).ToList()))
return true;
var limit = Spacing - Epsilon;
foreach (var left in a)
{
var leftLines = left.ToLines();
foreach (var right in b)
{
var rightLines = right.ToLines();
foreach (var l in leftLines)
foreach (var r in rightLines)
{
if (l.Intersects(r))
return true;
var d = System.Math.Min(
System.Math.Min(
l.ClosestPointTo(r.StartPoint).DistanceTo(r.StartPoint),
l.ClosestPointTo(r.EndPoint).DistanceTo(r.EndPoint)
),
System.Math.Min(
r.ClosestPointTo(l.StartPoint).DistanceTo(l.StartPoint),
r.ClosestPointTo(l.EndPoint).DistanceTo(l.EndPoint)
)
);
if (d < limit)
return true;
}
}
}
return false;
}
/// <summary>Perimeter polygon first, then cutouts, placed the way the validator places them.</summary>
private static List<Polygon> Contours(PartGeometrySnapshot geometry, NestJobPlacement placement)
{
var entities = ConvertProgram
.ToGeometry(DrawingJobMapper.ToProgram(geometry))
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid))
.ToList();
var profile = new ShapeProfile(entities);
profile.NormalizeWinding();
return new[] { profile.Perimeter }
.Concat(profile.Cutouts)
.Select(shape =>
{
var contour = (Shape)shape.Clone();
contour.Rotate(placement.Rotation);
contour.Offset(placement.X, placement.Y);
return contour.ToPolygonWithTolerance(0.001);
})
.ToList();
}
private sealed class CandidateNester(IEnumerable<NestJobPlacement> placements) : IPlateNester
{
public PlateCandidate Place(
PlatePlacementRequest request,
IProgress<NestJobProgress>? progress = null,
CancellationToken token = default
) => new(placements);
}
}
+156 -184
View File
@@ -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
{
/// <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
{
private const double StepSize = 0.25;
private static readonly ConcurrentDictionary<CacheKey, List<BestFitResult>> _cache =
new ConcurrentDictionary<CacheKey, List<BestFitResult>>();
private static readonly ConditionalWeakTable<Drawing, Entry> _entries = new();
private static readonly object _entriesLock = new();
public static Func<Drawing, double, IPairEvaluator> CreateEvaluator { get; set; }
public static Func<ISlideComputer> 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<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.
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<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,
private static List<BestFitResult> FilterForSize(
List<BestFitResult> candidates,
double plateWidth,
double plateHeight,
double spacing,
List<BestFitResult> results
double plateHeight
)
{
if (results == null || results.Count == 0)
return;
var copy = new List<BestFitResult>(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<BestFitResult>
> GetAllForDrawing(Drawing drawing)
private sealed class Entry
{
var result = new Dictionary<(double, double, double), List<BestFitResult>>();
foreach (var kvp in _cache)
private readonly CNC.Program _program;
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))
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<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;
_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);
}
}
}
+44 -11
View File
@@ -28,10 +28,19 @@ namespace OpenNest.Engine.BestFit
slideComputer != null
? (IDistanceComputer)new GpuDistanceComputer(slideComputer)
: 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 =
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));
}
/// <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 candidateBags = new ConcurrentBag<List<PairCandidate>>();
@@ -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<TileResult> FindAndTile(
+18
View File
@@ -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
/// </summary>
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>
/// 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;
}
+72 -38
View File
@@ -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
/// <see cref="Drawing.Program"/> instance or canonical angle changes.
/// </summary>
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>
/// Returns a cached fill result for the given drawing and box dimensions,
@@ -20,9 +26,13 @@ public static class FillResultCache
/// </summary>
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;
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<CacheKey>
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<CacheKey, List<Part>> 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
) { }
}
}
@@ -12,6 +12,10 @@ namespace OpenNest.Engine.Jobs;
internal static class NestJobPlacementValidator
{
private const double Epsilon = 0.0000001;
// Flattening for placement overlap/spacing checks: the same 0.001 the benchmark's
// NestValidator and Part.Intersects use. Arcs are inscribed, so a layout placed exactly at
// the spacing passes; outward arcs may come up to this much closer than the spacing.
private const double PlacementChordTolerance = 0.001;
internal static void ValidateCandidate(
PlateCandidate candidate,
@@ -24,6 +28,7 @@ internal static class NestJobPlacementValidator
throw new InvalidOperationException("The plate nester returned a null candidate.");
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
var placed = new List<ShapeTopology>();
var sources = new Dictionary<string, ShapeTopology>(StringComparer.Ordinal);
foreach (var placement in candidate.Placements)
{
if (
@@ -48,7 +53,9 @@ internal static class NestJobPlacementValidator
"Candidate rotation is not allowed for the requirement."
);
var shape = Transform(CreateShape(part.Geometry), placement);
if (!sources.TryGetValue(placement.PartId, out var source))
sources[placement.PartId] = source = CreateShape(part.Geometry);
var shape = Transform(source, placement);
if (!FitsWorkArea(shape, stock))
throw new InvalidOperationException(
"Candidate placement falls outside the usable stock area."
@@ -296,8 +303,8 @@ internal static class NestJobPlacementValidator
private static bool Overlaps(ShapeTopology left, ShapeTopology right)
{
var leftPoly = ToPolygon(left.Perimeter);
var rightPoly = ToPolygon(right.Perimeter);
var leftPoly = left.Contours[0].Polygon;
var rightPoly = right.Contours[0].Polygon;
if (!leftPoly.BoundingBox.Intersects(rightPoly.BoundingBox))
return false;
// True material overlap requires shared interior area, not boundary touching.
@@ -308,8 +315,8 @@ internal static class NestJobPlacementValidator
return Collision.HasOverlap(
leftPoly,
rightPoly,
ToPolygons(left.Cutouts),
ToPolygons(right.Cutouts)
left.CutoutPolygons,
right.CutoutPolygons
);
}
@@ -365,44 +372,22 @@ internal static class NestJobPlacementValidator
private static double Distance(ShapeTopology left, ShapeTopology right)
{
var result = double.PositiveInfinity;
foreach (var leftContour in AllContours(left))
foreach (var rightContour in AllContours(right))
if (BoundsDistance(leftContour.BoundingBox, rightContour.BoundingBox) < result)
foreach (var leftContour in left.Contours)
foreach (var rightContour in right.Contours)
if (BoundsDistance(leftContour.Bounds, rightContour.Bounds) < result)
result = System.Math.Min(
result,
BoundaryDistance(ToPolygon(leftContour), ToPolygon(rightContour))
BoundaryDistance(leftContour.Lines, rightContour.Lines)
);
return result;
}
private static IEnumerable<Shape> AllContours(ShapeTopology shape)
{
yield return shape.Perimeter;
foreach (var cutout in shape.Cutouts)
yield return cutout;
}
private static List<Polygon> ToPolygons(List<Shape> contours)
{
var polygons = new List<Polygon>(contours.Count);
foreach (var contour in contours)
polygons.Add(ToPolygon(contour));
return polygons;
}
private static Polygon ToPolygon(Shape contour)
{
var polygon = contour.ToPolygon();
polygon.UpdateBounds();
return polygon;
}
private static double BoundaryDistance(Polygon left, Polygon right)
private static double BoundaryDistance(List<Line> left, List<Line> right)
{
var result = double.PositiveInfinity;
foreach (var leftLine in left.ToLines())
foreach (var leftLine in left)
{
foreach (var rightLine in right.ToLines())
foreach (var rightLine in right)
{
if (leftLine.Intersects(rightLine))
return 0;
@@ -429,7 +414,56 @@ internal static class NestJobPlacementValidator
private sealed class ShapeTopology(Shape perimeter, List<Shape> cutouts)
{
private Contour[] contours;
private List<Polygon> cutoutPolygons;
internal Shape Perimeter { get; } = perimeter;
internal List<Shape> Cutouts { get; } = cutouts;
/// <summary>The perimeter first, then the cutouts, each flattened once on first use.</summary>
internal Contour[] Contours
{
get
{
if (contours != null)
return contours;
var result = new Contour[Cutouts.Count + 1];
result[0] = new Contour(Perimeter);
for (var i = 0; i < Cutouts.Count; i++)
result[i + 1] = new Contour(Cutouts[i]);
return contours = result;
}
}
internal List<Polygon> CutoutPolygons
{
get
{
if (cutoutPolygons != null)
return cutoutPolygons;
var result = new List<Polygon>(Cutouts.Count);
for (var i = 1; i < Contours.Length; i++)
result.Add(Contours[i].Polygon);
return cutoutPolygons = result;
}
}
}
/// <summary>
/// A contour flattened once, at <see cref="PlacementChordTolerance"/>, for the overlap and
/// spacing checks against every other placement.
/// </summary>
private sealed class Contour
{
internal Contour(Shape shape)
{
Polygon = shape.ToPolygonWithTolerance(PlacementChordTolerance);
Bounds = Polygon.BoundingBox;
Lines = Polygon.ToLines();
}
internal Box Bounds { get; }
internal Polygon Polygon { get; }
internal List<Line> Lines { get; }
}
}
+196
View File
@@ -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();
}