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.Tests/Jobs/NestJobSpacingValidationTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobSpacingValidationTests.cs new file mode 100644 index 0000000..523a09e --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestJobSpacingValidationTests.cs @@ -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; + +/// +/// 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. +/// +public class NestJobSpacingValidationTests +{ + private const double Spacing = 0.25; + private const double Epsilon = 0.0000001; + private const double Origin = 50; + + public static IEnumerable 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; + } + + /// Perimeter polygon first, then cutouts, placed the way the validator places them. + private static List 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 placements) : IPlateNester + { + public PlateCandidate Place( + PlatePlacementRequest request, + IProgress? progress = null, + CancellationToken token = default + ) => new(placements); + } +} 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.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs index 4ef0c39..97426aa 100644 --- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs @@ -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(StringComparer.Ordinal); var placed = new List(); + var sources = new Dictionary(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 AllContours(ShapeTopology shape) - { - yield return shape.Perimeter; - foreach (var cutout in shape.Cutouts) - yield return cutout; - } - - private static List ToPolygons(List contours) - { - var polygons = new List(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 left, List 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 cutouts) { + private Contour[] contours; + private List cutoutPolygons; + internal Shape Perimeter { get; } = perimeter; internal List Cutouts { get; } = cutouts; + + /// The perimeter first, then the cutouts, each flattened once on first use. + 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 CutoutPolygons + { + get + { + if (cutoutPolygons != null) + return cutoutPolygons; + var result = new List(Cutouts.Count); + for (var i = 1; i < Contours.Length; i++) + result.Add(Contours[i].Polygon); + return cutoutPolygons = result; + } + } + } + + /// + /// A contour flattened once, at , for the overlap and + /// spacing checks against every other placement. + /// + 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 Lines { get; } } } 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(); +}