From 92b17b296353f0ffe97eddc340f07c2f112937d1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 21 Mar 2026 23:08:55 -0400 Subject: [PATCH] perf: parallelize PairFiller candidates and add GridDedup - Evaluate pair candidates in parallel batches instead of sequentially - Add GridDedup to skip duplicate pattern/direction/workArea combos across PairFiller and StripeFiller strategies - Replace crude 30% remnant area estimate with L-shaped geometry calculation using actual grid extents and max utilization - Move FillStrategyRegistry.SetEnabled to outer evaluation loop to avoid repeated enable/disable per remnant fill Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/Fill/GridDedup.cs | 75 +++++++++ OpenNest.Engine/Fill/PairFiller.cs | 152 ++++++++++++------ OpenNest.Engine/Fill/StripeFiller.cs | 6 + .../Strategies/PairsFillStrategy.cs | 3 +- 4 files changed, 184 insertions(+), 52 deletions(-) create mode 100644 OpenNest.Engine/Fill/GridDedup.cs diff --git a/OpenNest.Engine/Fill/GridDedup.cs b/OpenNest.Engine/Fill/GridDedup.cs new file mode 100644 index 0000000..90eaf24 --- /dev/null +++ b/OpenNest.Engine/Fill/GridDedup.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Concurrent; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Fill; + +/// +/// Tracks evaluated grid configurations so duplicate pattern/direction/workArea +/// combinations can be skipped across fill strategies. +/// +public class GridDedup +{ + public const string SharedStateKey = "GridDedup"; + + private readonly ConcurrentDictionary _seen = new(); + + /// + /// Returns true if this configuration has NOT been seen before (i.e., should be evaluated). + /// Returns false if it's a duplicate. + /// + public bool TryAdd(Box patternBox, Box workArea, NestDirection dir) + { + var key = new GridKey(patternBox, workArea, dir); + return _seen.TryAdd(key, 0); + } + + public int Count => _seen.Count; + + /// + /// Gets or creates a GridDedup from FillContext.SharedState. + /// + public static GridDedup GetOrCreate(System.Collections.Generic.Dictionary sharedState) + { + if (sharedState.TryGetValue(SharedStateKey, out var existing)) + return (GridDedup)existing; + + var dedup = new GridDedup(); + sharedState[SharedStateKey] = dedup; + return dedup; + } + + private readonly struct GridKey : IEquatable + { + private readonly int _patternW, _patternL, _workW, _workL, _dir; + + public GridKey(Box patternBox, Box workArea, NestDirection dir) + { + _patternW = (int)System.Math.Round(patternBox.Width * 10); + _patternL = (int)System.Math.Round(patternBox.Length * 10); + _workW = (int)System.Math.Round(workArea.Width * 10); + _workL = (int)System.Math.Round(workArea.Length * 10); + _dir = (int)dir; + } + + public bool Equals(GridKey other) => + _patternW == other._patternW && _patternL == other._patternL && + _workW == other._workW && _workL == other._workL && + _dir == other._dir; + + public override bool Equals(object obj) => obj is GridKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = _patternW; + hash = hash * 397 ^ _patternL; + hash = hash * 397 ^ _workW; + hash = hash * 397 ^ _workL; + hash = hash * 397 ^ _dir; + return hash; + } + } + } +} diff --git a/OpenNest.Engine/Fill/PairFiller.cs b/OpenNest.Engine/Fill/PairFiller.cs index bef8a90..32c71dd 100644 --- a/OpenNest.Engine/Fill/PairFiller.cs +++ b/OpenNest.Engine/Fill/PairFiller.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; +using System.Threading.Tasks; using OpenNest.Engine; namespace OpenNest.Engine.Fill @@ -32,13 +33,15 @@ namespace OpenNest.Engine.Fill private readonly Size plateSize; private readonly double partSpacing; private readonly IFillComparer comparer; + private readonly GridDedup dedup; - public PairFiller(Plate plate, IFillComparer comparer = null) + public PairFiller(Plate plate, IFillComparer comparer = null, GridDedup dedup = null) { this.plate = plate; this.plateSize = plate.Size; this.partSpacing = plate.PartSpacing; this.comparer = comparer ?? new DefaultFillComparer(); + this.dedup = dedup ?? new GridDedup(); } public PairFillResult Fill(NestItem item, Box workArea, @@ -68,38 +71,60 @@ namespace OpenNest.Engine.Fill List best = null; var sinceImproved = 0; var effectiveWorkArea = workArea; + var batchSize = System.Math.Max(2, Environment.ProcessorCount); + var maxUtilization = candidates.Count > 0 ? candidates.Max(c => c.Utilization) : 1.0; + var partBox = drawing.Program.BoundingBox(); + var partArea = System.Math.Max(partBox.Width * partBox.Length, 1); + + FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear"); try { - for (var i = 0; i < candidates.Count; i++) + for (var batchStart = 0; batchStart < candidates.Count; batchStart += batchSize) { token.ThrowIfCancellationRequested(); - var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea, token); + var batchEnd = System.Math.Min(batchStart + batchSize, candidates.Count); + var batchCount = batchEnd - batchStart; + var batchWorkArea = effectiveWorkArea; + var minCountToBeat = best?.Count ?? 0; - if (comparer.IsBetter(filled, best, effectiveWorkArea)) + var results = new List[batchCount]; + Parallel.For(0, batchCount, + new ParallelOptions { CancellationToken = token }, + j => + { + results[j] = EvaluateCandidate( + candidates[batchStart + j], drawing, batchWorkArea, + minCountToBeat, maxUtilization, partArea, token); + }); + + for (var j = 0; j < batchCount; j++) { - best = filled; - sinceImproved = 0; - effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea); - } - else - { - sinceImproved++; + if (comparer.IsBetter(results[j], best, effectiveWorkArea)) + { + best = results[j]; + sinceImproved = 0; + effectiveWorkArea = TryReduceWorkArea(best, targetCount, workArea, effectiveWorkArea); + } + else + { + sinceImproved++; + } + + NestEngineBase.ReportProgress(progress, new ProgressReport + { + Phase = NestPhase.Pairs, + PlateNumber = plateNumber, + Parts = best, + WorkArea = workArea, + Description = $"Pairs: {batchStart + j + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts", + }); } - NestEngineBase.ReportProgress(progress, new ProgressReport + if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit) { - Phase = NestPhase.Pairs, - PlateNumber = plateNumber, - Parts = best, - WorkArea = workArea, - Description = $"Pairs: {i + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts", - }); - - if (i + 1 >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit) - { - Debug.WriteLine($"[PairFiller] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates"); + Debug.WriteLine($"[PairFiller] Early exit at {batchEnd}/{candidates.Count} — no improvement in last {sinceImproved} candidates"); break; } } @@ -108,6 +133,10 @@ namespace OpenNest.Engine.Fill { Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far"); } + finally + { + FillStrategyRegistry.SetEnabled(null); + } Debug.WriteLine($"[PairFiller] Best pair result: {best?.Count ?? 0} parts"); return best ?? new List(); @@ -151,7 +180,8 @@ namespace OpenNest.Engine.Fill } private List EvaluateCandidate(BestFitResult candidate, Drawing drawing, - Box workArea, CancellationToken token) + Box workArea, int minCountToBeat, double maxUtilization, double partArea, + CancellationToken token) { var pairParts = candidate.BuildParts(drawing); var angles = BuildTilingAngles(candidate); @@ -168,6 +198,9 @@ namespace OpenNest.Engine.Fill var engine = new FillLinear(workArea, partSpacing); foreach (var dir in new[] { NestDirection.Horizontal, NestDirection.Vertical }) { + if (!dedup.TryAdd(pattern.BoundingBox, workArea, dir)) + continue; + var gridParts = engine.Fill(pattern, dir); if (gridParts != null && gridParts.Count > 0) grids.Add((gridParts, dir)); @@ -180,17 +213,34 @@ namespace OpenNest.Engine.Fill // Sort by count descending so we try the best grids first grids.Sort((a, b) => b.Parts.Count.CompareTo(a.Parts.Count)); + // Early abort: if the best grid + optimistic remnant can't beat the global best, skip Phase 2 + if (minCountToBeat > 0) + { + var topCount = grids[0].Parts.Count; + var optimisticRemnant = EstimateRemnantUpperBound( + grids[0].Parts, workArea, maxUtilization, partArea); + if (topCount + optimisticRemnant <= minCountToBeat) + { + Debug.WriteLine($"[PairFiller] Skipping candidate: grid {topCount} + estimate {optimisticRemnant} <= best {minCountToBeat}"); + return null; + } + } + // Phase 2: try remnant for each grid, skip if grid is too far behind List best = null; - var maxRemnantEstimate = EstimateMaxRemnantParts(drawing, workArea); foreach (var (gridParts, dir) in grids) { token.ThrowIfCancellationRequested(); // If this grid + max possible remnant can't beat current best, skip - if (best != null && gridParts.Count + maxRemnantEstimate <= best.Count) - break; // sorted descending, so remaining are even smaller + if (best != null) + { + var remnantBound = EstimateRemnantUpperBound( + gridParts, workArea, maxUtilization, partArea); + if (gridParts.Count + remnantBound <= best.Count) + break; // sorted descending, so remaining are even smaller + } var remnantParts = FillRemnant(gridParts, drawing, workArea, token); List total; @@ -212,12 +262,20 @@ namespace OpenNest.Engine.Fill return best; } - private static int EstimateMaxRemnantParts(Drawing drawing, Box workArea) + private int EstimateRemnantUpperBound(List gridParts, Box workArea, + double maxUtilization, double partArea) { - var partBox = drawing.Program.BoundingBox(); - var partArea = System.Math.Max(partBox.Width * partBox.Length, 1); - var remnantArea = workArea.Area() * 0.3; // remnant is at most ~30% of work area - return (int)(remnantArea / partArea) + 1; + var gridBox = ((IEnumerable)gridParts).GetBoundingBox(); + + // L-shaped remnant: top strip (full width) + right strip (grid height only) + var topHeight = System.Math.Max(0, workArea.Top - gridBox.Top); + var rightWidth = System.Math.Max(0, workArea.Right - gridBox.Right); + + var topArea = workArea.Width * topHeight; + var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Length); + var remnantArea = topArea + rightArea; + + return (int)(remnantArea * maxUtilization / partArea) + 1; } private List FillRemnant(List gridParts, Drawing drawing, @@ -263,28 +321,20 @@ namespace OpenNest.Engine.Fill return cachedResult; } - FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear"); - try + var remnantEngine = NestEngineRegistry.Create(plate); + var item = new NestItem { Drawing = drawing }; + var parts = remnantEngine.Fill(item, remnantBox, null, token); + + Debug.WriteLine($"[PairFiller] Remnant: {parts?.Count ?? 0} parts in " + + $"{remnantBox.Width:F2}x{remnantBox.Length:F2}"); + + if (parts != null && parts.Count > 0) { - var remnantEngine = NestEngineRegistry.Create(plate); - var item = new NestItem { Drawing = drawing }; - var parts = remnantEngine.Fill(item, remnantBox, null, token); - - Debug.WriteLine($"[PairFiller] Remnant: {parts?.Count ?? 0} parts in " + - $"{remnantBox.Width:F2}x{remnantBox.Length:F2}"); - - if (parts != null && parts.Count > 0) - { - FillResultCache.Store(drawing, remnantBox, partSpacing, parts); - return parts; - } - - return null; - } - finally - { - FillStrategyRegistry.SetEnabled(null); + FillResultCache.Store(drawing, remnantBox, partSpacing, parts); + return parts; } + + return null; } private static List BuildTilingAngles(BestFitResult candidate) diff --git a/OpenNest.Engine/Fill/StripeFiller.cs b/OpenNest.Engine/Fill/StripeFiller.cs index 66b7728..7fb60f3 100644 --- a/OpenNest.Engine/Fill/StripeFiller.cs +++ b/OpenNest.Engine/Fill/StripeFiller.cs @@ -20,6 +20,7 @@ public class StripeFiller private readonly FillContext _context; private readonly NestDirection _primaryAxis; private readonly IFillComparer _comparer; + private readonly GridDedup _dedup; /// /// When true, only complete stripes are placed — no partial rows/columns. @@ -38,6 +39,7 @@ public class StripeFiller _context = context; _primaryAxis = primaryAxis; _comparer = context.Policy?.Comparer ?? new DefaultFillComparer(); + _dedup = GridDedup.GetOrCreate(context.SharedState); } public List Fill() @@ -115,6 +117,10 @@ public class StripeFiller var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle); var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis); var stripeBox = MakeStripeBox(workArea, perpDim, primaryAxis); + + if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis)) + return null; + var stripeEngine = new FillLinear(stripeBox, spacing); var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis); diff --git a/OpenNest.Engine/Strategies/PairsFillStrategy.cs b/OpenNest.Engine/Strategies/PairsFillStrategy.cs index 121c7e3..c73a9fe 100644 --- a/OpenNest.Engine/Strategies/PairsFillStrategy.cs +++ b/OpenNest.Engine/Strategies/PairsFillStrategy.cs @@ -12,7 +12,8 @@ namespace OpenNest.Engine.Strategies public List Fill(FillContext context) { var comparer = context.Policy?.Comparer; - var filler = new PairFiller(context.Plate, comparer); + var dedup = GridDedup.GetOrCreate(context.SharedState); + var filler = new PairFiller(context.Plate, comparer, dedup); var result = filler.Fill(context.Item, context.WorkArea, context.PlateNumber, context.Token, context.Progress);