From 86f6c9efa129baac5da9174a20f2c458c82daa8e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 22 Sep 2026 09:51:06 -0400 Subject: [PATCH] refactor(engine): extract PairFiller candidate selection and remnant filling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits PairCandidateSelector (strip-mode candidate ranking) and PairRemnantFiller (leftover L-shaped area fill) out of PairFiller into their own classes. Pure extraction — logic is unchanged, just relocated and given dedicated unit boundaries so each piece can be tested and reasoned about on its own. --- OpenNest.Engine/Fill/PairCandidateSelector.cs | 96 ++++ OpenNest.Engine/Fill/PairFiller.cs | 413 +++++++++--------- OpenNest.Engine/Fill/PairRemnantFiller.cs | 123 ++++++ 3 files changed, 414 insertions(+), 218 deletions(-) create mode 100644 OpenNest.Engine/Fill/PairCandidateSelector.cs create mode 100644 OpenNest.Engine/Fill/PairRemnantFiller.cs diff --git a/OpenNest.Engine/Fill/PairCandidateSelector.cs b/OpenNest.Engine/Fill/PairCandidateSelector.cs new file mode 100644 index 0000000..5031dab --- /dev/null +++ b/OpenNest.Engine/Fill/PairCandidateSelector.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Fill +{ + /// + /// Selects and ranks BestFitCache pair candidates for a work area, favoring + /// narrow "strip" candidates when the work area is much smaller than the plate. + /// + public class PairCandidateSelector + { + private const int MaxTopCandidates = 50; + private const int MaxStripCandidates = 100; + private const double MinStripUtilization = 0.3; + + private readonly Size plateSize; + private readonly double partSpacing; + + public PairCandidateSelector(Size plateSize, double partSpacing) + { + this.plateSize = plateSize; + this.partSpacing = partSpacing; + } + + public List Select(List bestFits, Box workArea) + { + var kept = bestFits.Where(r => r.Keep).ToList(); + + var workShortSide = System.Math.Min(workArea.Width, workArea.Length); + var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length); + + if (workShortSide < plateShortSide * 0.5) + { + // Strip mode: prioritize candidates that fit the narrow dimension. + var stripCandidates = kept.Where(r => + r.ShortestSide <= workShortSide + Tolerance.Epsilon + && r.Utilization >= MinStripUtilization + ) + .ToList(); + + SortByEstimatedCount(stripCandidates, workArea); + + var top = stripCandidates.Take(MaxStripCandidates).ToList(); + + Debug.WriteLine( + $"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})" + ); + return top; + } + + var result = kept.Take(MaxTopCandidates).ToList(); + SortByEstimatedCount(result, workArea); + + return result; + } + + private void SortByEstimatedCount(List candidates, Box workArea) + { + var w = workArea.Width; + var l = workArea.Length; + + candidates.Sort( + (a, b) => + { + var aCount = EstimateTileCount(a, w, l); + var bCount = EstimateTileCount(b, w, l); + + if (aCount != bCount) + return bCount.CompareTo(aCount); + + return b.Utilization.CompareTo(a.Utilization); + } + ); + } + + private int EstimateTileCount(BestFitResult r, double areaW, double areaL) + { + var h = EstimateCount(r.BoundingWidth, r.BoundingHeight, areaW, areaL); + var v = EstimateCount(r.BoundingHeight, r.BoundingWidth, areaW, areaL); + return System.Math.Max(h, v); + } + + private int EstimateCount(double pairW, double pairH, double areaW, double areaL) + { + if (pairW <= 0 || pairH <= 0) + return 0; + var cols = (int)((areaW + partSpacing) / (pairW + partSpacing)); + var rows = (int)((areaL + partSpacing) / (pairH + partSpacing)); + return cols * rows * 2; + } + } +} diff --git a/OpenNest.Engine/Fill/PairFiller.cs b/OpenNest.Engine/Fill/PairFiller.cs index 8181e3b..fe3577b 100644 --- a/OpenNest.Engine/Fill/PairFiller.cs +++ b/OpenNest.Engine/Fill/PairFiller.cs @@ -12,29 +12,78 @@ using OpenNest.Math; namespace OpenNest.Engine.Fill { + /// + /// Defines the + /// public class PairFillResult { + /// + /// Gets or sets the Parts + /// public List Parts { get; set; } = new List(); + + /// + /// Gets or sets the BestFits + /// public List BestFits { get; set; } } /// - /// Fills a work area using interlocking part pairs from BestFitCache. + /// Fills a work area using interlocking part pairs from BestFitCache /// public class PairFiller { - private const int MaxTopCandidates = 50; - private const int MaxStripCandidates = 100; - private const double MinStripUtilization = 0.3; + /// + /// Defines the EarlyExitMinTried + /// private const int EarlyExitMinTried = 10; + + /// + /// Defines the EarlyExitStaleLimit + /// private const int EarlyExitStaleLimit = 10; + /// + /// Defines the plate + /// private readonly Plate plate; + + /// + /// Defines the plateSize + /// private readonly Size plateSize; + + /// + /// Defines the partSpacing + /// private readonly double partSpacing; + + /// + /// Defines the comparer + /// private readonly IFillComparer comparer; + + /// + /// Defines the dedup + /// private readonly GridDedup dedup; + /// + /// Defines the candidateSelector + /// + private readonly PairCandidateSelector candidateSelector; + + /// + /// Defines the remnantFiller + /// + private readonly PairRemnantFiller remnantFiller; + + /// + /// Initializes a new instance of the class. + /// + /// The plate + /// The comparer + /// The dedup public PairFiller(Plate plate, IFillComparer comparer = null, GridDedup dedup = null) { this.plate = plate; @@ -42,8 +91,18 @@ namespace OpenNest.Engine.Fill this.partSpacing = plate.PartSpacing; this.comparer = comparer ?? new DefaultFillComparer(); this.dedup = dedup ?? new GridDedup(); + this.candidateSelector = new PairCandidateSelector(plateSize, partSpacing); + this.remnantFiller = new PairRemnantFiller(partSpacing, this.comparer); } + /// + /// The Fill + /// + /// The item + /// The workArea + /// The token + /// The reportProgress + /// The public PairFillResult Fill( NestItem item, Box workArea, @@ -58,7 +117,7 @@ namespace OpenNest.Engine.Fill partSpacing ); - var candidates = SelectPairCandidates(bestFits, workArea); + var candidates = candidateSelector.Select(bestFits, workArea); Debug.WriteLine( $"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}" ); @@ -79,6 +138,16 @@ namespace OpenNest.Engine.Fill return new PairFillResult { Parts = parts, BestFits = bestFits }; } + /// + /// The EvaluateCandidates + /// + /// The candidates + /// The drawing + /// The workArea + /// The targetCount + /// The token + /// The reportProgress + /// The private List EvaluateCandidates( List candidates, Drawing drawing, @@ -104,52 +173,29 @@ namespace OpenNest.Engine.Fill token.ThrowIfCancellationRequested(); var batchEnd = System.Math.Min(batchStart + batchSize, candidates.Count); - var batchCount = batchEnd - batchStart; - var batchWorkArea = effectiveWorkArea; - var minCountToBeat = best?.Count ?? 0; - - 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 - ); - } + var results = EvaluateBatch( + candidates, + drawing, + effectiveWorkArea, + batchStart, + batchEnd, + best?.Count ?? 0, + maxUtilization, + partArea, + token ); - for (var j = 0; j < batchCount; j++) - { - if (comparer.IsBetter(results[j], best, effectiveWorkArea)) - { - best = results[j]; - sinceImproved = 0; - effectiveWorkArea = TryReduceWorkArea( - best, - targetCount, - workArea, - effectiveWorkArea - ); - } - else - { - sinceImproved++; - } - - reportProgress?.Invoke( - best, - $"Pairs: {batchStart + j + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts" - ); - } + (best, effectiveWorkArea, sinceImproved) = ProcessBatchResults( + results, + best, + sinceImproved, + workArea, + effectiveWorkArea, + targetCount, + candidates.Count, + batchStart, + reportProgress + ); if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit) { @@ -169,6 +215,82 @@ namespace OpenNest.Engine.Fill return best ?? new List(); } + private List[] EvaluateBatch( + List candidates, + Drawing drawing, + Box workArea, + int batchStart, + int batchEnd, + int minCountToBeat, + double maxUtilization, + double partArea, + CancellationToken token + ) + { + var batchCount = batchEnd - batchStart; + var results = new List[batchCount]; + Parallel.For( + 0, + batchCount, + new ParallelOptions { CancellationToken = token }, + j => + { + results[j] = EvaluateCandidate( + candidates[batchStart + j], + drawing, + workArea, + minCountToBeat, + maxUtilization, + partArea, + token + ); + } + ); + return results; + } + + private (List Best, Box EffectiveWorkArea, int SinceImproved) ProcessBatchResults( + List[] results, + List best, + int sinceImproved, + Box workArea, + Box effectiveWorkArea, + int targetCount, + int totalCandidates, + int batchStart, + Action, string> reportProgress + ) + { + for (var j = 0; j < results.Length; j++) + { + if (comparer.IsBetter(results[j], best, effectiveWorkArea)) + { + best = results[j]; + sinceImproved = 0; + effectiveWorkArea = TryReduceWorkArea(best, targetCount, workArea, effectiveWorkArea); + } + else + { + sinceImproved++; + } + + reportProgress?.Invoke( + best, + $"Pairs: {batchStart + j + 1}/{totalCandidates} candidates, best = {best?.Count ?? 0} parts" + ); + } + + return (best, effectiveWorkArea, sinceImproved); + } + + /// + /// The TryReduceWorkArea + /// + /// The parts + /// The targetCount + /// The workArea + /// The effectiveWorkArea + /// The private static Box TryReduceWorkArea( List parts, int targetCount, @@ -192,8 +314,12 @@ namespace OpenNest.Engine.Fill /// /// Given parts that exceed targetCount, sorts by BoundingBox.Top descending, /// removes parts from the top until exactly targetCount remain, then returns - /// the Top of the remaining parts as the new work area height to beat. + /// the Top of the remaining parts as the new work area height to beat /// + /// The parts + /// The targetCount + /// The workArea + /// The private static Box ReduceWorkArea(List parts, int targetCount, Box workArea) { if (parts.Count <= targetCount) @@ -214,6 +340,17 @@ namespace OpenNest.Engine.Fill ); } + /// + /// The EvaluateCandidate + /// + /// The candidate + /// The drawing + /// The workArea + /// The minCountToBeat + /// The maxUtilization + /// The partArea + /// The token + /// The private List EvaluateCandidate( BestFitResult candidate, Drawing drawing, @@ -258,7 +395,7 @@ namespace OpenNest.Engine.Fill if (minCountToBeat > 0) { var topCount = grids[0].Parts.Count; - var optimisticRemnant = EstimateRemnantUpperBound( + var optimisticRemnant = remnantFiller.EstimateUpperBound( grids[0].Parts, workArea, maxUtilization, @@ -283,7 +420,7 @@ namespace OpenNest.Engine.Fill // If this grid + max possible remnant can't beat current best, skip if (best != null) { - var remnantBound = EstimateRemnantUpperBound( + var remnantBound = remnantFiller.EstimateUpperBound( gridParts, workArea, maxUtilization, @@ -293,7 +430,7 @@ namespace OpenNest.Engine.Fill break; // sorted descending, so remaining are even smaller } - var remnantParts = FillRemnant(gridParts, drawing, workArea, token); + var remnantParts = remnantFiller.Fill(gridParts, drawing, workArea, token); List total; if (remnantParts != null && remnantParts.Count > 0) { @@ -313,104 +450,11 @@ namespace OpenNest.Engine.Fill return best; } - private int EstimateRemnantUpperBound( - List gridParts, - Box workArea, - double maxUtilization, - double partArea - ) - { - 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.Length * topHeight; - var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Width); - var remnantArea = topArea + rightArea; - - return (int)(remnantArea * maxUtilization / partArea) + 1; - } - - private List FillRemnant( - List gridParts, - Drawing drawing, - Box workArea, - CancellationToken token - ) - { - var gridBox = ((IEnumerable)gridParts).GetBoundingBox(); - var partBox = drawing.Program.BoundingBox(); - var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing; - - List bestRemnant = null; - - // Try top remnant (full width, above grid) - var topY = gridBox.Top + partSpacing; - var topLength = workArea.Top - topY; - if (topLength >= minDim) - { - var topBox = new Box(workArea.X, topY, workArea.Length, topLength); - var parts = FillRemnantBox(drawing, topBox, token); - if (parts != null && parts.Count > (bestRemnant?.Count ?? 0)) - bestRemnant = parts; - } - - // Try right remnant (full height, right of grid) - var rightX = gridBox.Right + partSpacing; - var rightWidth = workArea.Right - rightX; - if (rightWidth >= minDim) - { - var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Width); - var parts = FillRemnantBox(drawing, rightBox, token); - if (parts != null && parts.Count > (bestRemnant?.Count ?? 0)) - bestRemnant = parts; - } - - return bestRemnant; - } - - private List FillRemnantBox(Drawing drawing, Box remnantBox, CancellationToken token) - { - var cachedResult = FillResultCache.Get(drawing, remnantBox, partSpacing); - if (cachedResult != null) - { - Debug.WriteLine($"[PairFiller] Remnant CACHE HIT: {cachedResult.Count} parts"); - return cachedResult; - } - - var filler = new FillLinear(remnantBox, partSpacing) { Label = "Pairs-Remnant" }; - List parts = null; - - foreach (var angle in new[] { 0.0, Angle.HalfPI }) - { - token.ThrowIfCancellationRequested(); - var result = FillHelpers.FillWithDirectionPreference( - dir => filler.Fill(drawing, angle, dir), - null, - comparer, - remnantBox - ); - - if (result != null && result.Count > (parts?.Count ?? 0)) - parts = result; - } - - 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; - } - + /// + /// The BuildTilingAngles + /// + /// The candidate + /// The private static List BuildTilingAngles(BestFitResult candidate) { var angles = new List(candidate.HullAngles); @@ -425,72 +469,5 @@ namespace OpenNest.Engine.Fill return angles; } - - private List SelectPairCandidates(List bestFits, Box workArea) - { - var kept = bestFits.Where(r => r.Keep).ToList(); - - var workShortSide = System.Math.Min(workArea.Width, workArea.Length); - var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length); - - if (workShortSide < plateShortSide * 0.5) - { - // Strip mode: prioritize candidates that fit the narrow dimension. - var stripCandidates = kept.Where(r => - r.ShortestSide <= workShortSide + Tolerance.Epsilon - && r.Utilization >= MinStripUtilization - ) - .ToList(); - - SortByEstimatedCount(stripCandidates, workArea); - - var top = stripCandidates.Take(MaxStripCandidates).ToList(); - - Debug.WriteLine( - $"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})" - ); - return top; - } - - var result = kept.Take(MaxTopCandidates).ToList(); - SortByEstimatedCount(result, workArea); - - return result; - } - - private void SortByEstimatedCount(List candidates, Box workArea) - { - var w = workArea.Width; - var l = workArea.Length; - - candidates.Sort( - (a, b) => - { - var aCount = EstimateTileCount(a, w, l); - var bCount = EstimateTileCount(b, w, l); - - if (aCount != bCount) - return bCount.CompareTo(aCount); - - return b.Utilization.CompareTo(a.Utilization); - } - ); - } - - private int EstimateTileCount(BestFitResult r, double areaW, double areaL) - { - var h = EstimateCount(r.BoundingWidth, r.BoundingHeight, areaW, areaL); - var v = EstimateCount(r.BoundingHeight, r.BoundingWidth, areaW, areaL); - return System.Math.Max(h, v); - } - - private int EstimateCount(double pairW, double pairH, double areaW, double areaL) - { - if (pairW <= 0 || pairH <= 0) - return 0; - var cols = (int)((areaW + partSpacing) / (pairW + partSpacing)); - var rows = (int)((areaL + partSpacing) / (pairH + partSpacing)); - return cols * rows * 2; - } } } diff --git a/OpenNest.Engine/Fill/PairRemnantFiller.cs b/OpenNest.Engine/Fill/PairRemnantFiller.cs new file mode 100644 index 0000000..4ae1791 --- /dev/null +++ b/OpenNest.Engine/Fill/PairRemnantFiller.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using OpenNest.Engine.Strategies; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Fill +{ + /// + /// Fills the leftover L-shaped area (top strip + right strip) around a pair grid + /// with the same drawing, caching results by drawing/box/spacing. + /// + public class PairRemnantFiller + { + private readonly double partSpacing; + private readonly IFillComparer comparer; + + public PairRemnantFiller(double partSpacing, IFillComparer comparer) + { + this.partSpacing = partSpacing; + this.comparer = comparer; + } + + public int EstimateUpperBound( + List gridParts, + Box workArea, + double maxUtilization, + double partArea + ) + { + 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.Length * topHeight; + var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Width); + var remnantArea = topArea + rightArea; + + return (int)(remnantArea * maxUtilization / partArea) + 1; + } + + public List Fill( + List gridParts, + Drawing drawing, + Box workArea, + CancellationToken token + ) + { + var gridBox = ((IEnumerable)gridParts).GetBoundingBox(); + var partBox = drawing.Program.BoundingBox(); + var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing; + + List bestRemnant = null; + + // Try top remnant (full width, above grid) + var topY = gridBox.Top + partSpacing; + var topLength = workArea.Top - topY; + if (topLength >= minDim) + { + var topBox = new Box(workArea.X, topY, workArea.Length, topLength); + var parts = FillRemnantBox(drawing, topBox, token); + if (parts != null && parts.Count > (bestRemnant?.Count ?? 0)) + bestRemnant = parts; + } + + // Try right remnant (full height, right of grid) + var rightX = gridBox.Right + partSpacing; + var rightWidth = workArea.Right - rightX; + if (rightWidth >= minDim) + { + var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Width); + var parts = FillRemnantBox(drawing, rightBox, token); + if (parts != null && parts.Count > (bestRemnant?.Count ?? 0)) + bestRemnant = parts; + } + + return bestRemnant; + } + + private List FillRemnantBox(Drawing drawing, Box remnantBox, CancellationToken token) + { + var cachedResult = FillResultCache.Get(drawing, remnantBox, partSpacing); + if (cachedResult != null) + { + Debug.WriteLine($"[PairFiller] Remnant CACHE HIT: {cachedResult.Count} parts"); + return cachedResult; + } + + var filler = new FillLinear(remnantBox, partSpacing) { Label = "Pairs-Remnant" }; + List parts = null; + + foreach (var angle in new[] { 0.0, Angle.HalfPI }) + { + token.ThrowIfCancellationRequested(); + var result = FillHelpers.FillWithDirectionPreference( + dir => filler.Fill(drawing, angle, dir), + null, + comparer, + remnantBox + ); + + if (result != null && result.Count > (parts?.Count ?? 0)) + parts = result; + } + + 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; + } + } +}