From 744062152e2885a9c164a03db8f009617c8d6fab Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 13:21:28 -0400 Subject: [PATCH] feat(engine): optimize FillExact with angle pruning and tight search range - Track productive angles across Fill calls; subsequent fills skip angles that never produced results (knownGoodAngles) - Binary search uses utilization-based range estimates (70%-25%) instead of starting from the full work area dimension - Quick bounding-box capacity check skips binary search entirely when the plate can't fit more than the requested quantity - Use full Fill (not rect-only) for binary search iterations so the search benefits from pairs/linear strategies Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 116 +++++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 38 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index f1dc1bf..4bb8792 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -33,6 +33,10 @@ namespace OpenNest public List AngleResults { get; } = new(); + // Angles that have produced results across multiple Fill calls. + // Populated after each Fill; used to prune subsequent fills. + private readonly HashSet knownGoodAngles = new(); + // --- Public Fill API --- public bool Fill(NestItem item) @@ -96,50 +100,56 @@ namespace OpenNest if (item.Quantity <= 1) return Fill(item, workArea, progress, token); - // Full fill to establish upper bound. - var fullResult = Fill(item, workArea, progress, token); + // Quick capacity check: estimate how many parts fit via bounding box. + var partBox = item.Drawing.Program.BoundingBox(); + var cols = (int)(workArea.Width / (partBox.Width + Plate.PartSpacing)); + var rows = (int)(workArea.Length / (partBox.Length + Plate.PartSpacing)); + var capacity = System.Math.Max(cols * rows, 1); - if (fullResult.Count <= item.Quantity) - return fullResult; + // Also check rotated orientation. + var colsR = (int)(workArea.Width / (partBox.Length + Plate.PartSpacing)); + var rowsR = (int)(workArea.Length / (partBox.Width + Plate.PartSpacing)); + capacity = System.Math.Max(capacity, colsR * rowsR); + + Debug.WriteLine($"[FillExact] Capacity estimate: {capacity}, target: {item.Quantity}, workArea: {workArea.Width:F1}x{workArea.Length:F1}"); + + if (capacity <= item.Quantity) + { + // Plate can't fit more than requested — do a normal fill. + return Fill(item, workArea, progress, token); + } // Binary search: try shrinking each dimension. - var (lengthParts, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token); - var (widthParts, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token); + Debug.WriteLine($"[FillExact] Starting binary search (capacity={capacity} > target={item.Quantity})"); + var (lengthFound, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token); + Debug.WriteLine($"[FillExact] Shrink-length: found={lengthFound}, dim={lengthDim:F1}"); + var (widthFound, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token); + Debug.WriteLine($"[FillExact] Shrink-width: found={widthFound}, dim={widthDim:F1}"); // Pick winner by smallest test box area. Tie-break: prefer shrink-length. - List winner; Box winnerBox; - var lengthArea = lengthParts != null ? workArea.Width * lengthDim : double.MaxValue; - var widthArea = widthParts != null ? widthDim * workArea.Length : double.MaxValue; + var lengthArea = lengthFound ? workArea.Width * lengthDim : double.MaxValue; + var widthArea = widthFound ? widthDim * workArea.Length : double.MaxValue; - if (lengthParts != null && lengthArea <= widthArea) + if (lengthFound && lengthArea <= widthArea) { - winner = lengthParts; winnerBox = new Box(workArea.X, workArea.Y, workArea.Width, lengthDim); } - else if (widthParts != null) + else if (widthFound) { - winner = widthParts; winnerBox = new Box(workArea.X, workArea.Y, widthDim, workArea.Length); } else { - // Neither search found the exact quantity — return full fill truncated. - return fullResult.Take(item.Quantity).ToList(); + // Neither search found the exact quantity — do a normal fill. + return Fill(item, workArea, progress, token); } - // Re-run the winner with progress so PhaseResults/WinnerPhase are correct - // and the progress form shows the final result. - var finalResult = Fill(item, winnerBox, progress, token); + Debug.WriteLine($"[FillExact] Winner box: {winnerBox.Width:F1}x{winnerBox.Length:F1}"); - if (finalResult.Count >= item.Quantity) - return finalResult.Count > item.Quantity - ? finalResult.Take(item.Quantity).ToList() - : finalResult; - - // Fallback: return the binary search result if the re-run produced fewer. - return winner; + // Run the full Fill on the winning box with progress. + return Fill(item, winnerBox, progress, token); } /// @@ -147,7 +157,7 @@ namespace OpenNest /// exactly item.Quantity parts. Returns the best parts list and the dimension /// value that achieved it. /// - private (List parts, double usedDim) BinarySearchFill( + private (bool found, double usedDim) BinarySearchFill( NestItem item, Box workArea, bool shrinkWidth, CancellationToken token) { @@ -159,18 +169,23 @@ namespace OpenNest var fixedDim = shrinkWidth ? workArea.Length : workArea.Width; var highDim = shrinkWidth ? workArea.Width : workArea.Length; - // Estimate starting point: target area at 50% utilization. - var targetArea = partArea * quantity / 0.5; + // Estimate search range from part area and utilization assumptions. var minPartDim = shrinkWidth ? partBox.Width + Plate.PartSpacing : partBox.Length + Plate.PartSpacing; - var estimatedDim = System.Math.Max(minPartDim, targetArea / fixedDim); - var low = estimatedDim; - var high = highDim; + // Low: tight estimate at 70% utilization. + var lowEstimate = System.Math.Max(minPartDim, partArea * quantity / (0.7 * fixedDim)); + // High: generous estimate at 25% utilization, capped to work area. + var highEstimate = System.Math.Min(highDim, partArea * quantity / (0.25 * fixedDim)); + // Ensure high is at least low. + highEstimate = System.Math.Max(highEstimate, lowEstimate + Plate.PartSpacing); - List bestParts = null; - var bestDim = high; + var low = lowEstimate; + var high = highEstimate; + + var found = false; + var bestDim = highEstimate; for (var iter = 0; iter < 8; iter++) { @@ -186,14 +201,15 @@ namespace OpenNest ? new Box(workArea.X, workArea.Y, mid, workArea.Length) : new Box(workArea.X, workArea.Y, workArea.Width, mid); - var result = Fill(item, testBox, null, token); + // Fill with unlimited qty to get the true count for this box size. + // After the first iteration, angle pruning kicks in and makes this fast. + var searchItem = new NestItem { Drawing = item.Drawing, Quantity = 0 }; + var result = Fill(searchItem, testBox, null, token); if (result.Count >= quantity) { - bestParts = result.Count > quantity - ? result.Take(quantity).ToList() - : result; bestDim = mid; + found = true; high = mid; } else @@ -202,7 +218,7 @@ namespace OpenNest } } - return (bestParts, bestDim); + return (found, bestDim); } public bool Fill(List groupParts) @@ -381,6 +397,13 @@ namespace OpenNest linearSw.Stop(); PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds)); + // Record productive angles for future fills. + foreach (var ar in AngleResults) + { + if (ar.PartCount > 0) + knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg)); + } + Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary()); @@ -461,6 +484,23 @@ namespace OpenNest } } + // If we have known-good angles from previous fills, use only those + // plus the defaults (bestRotation + 90°). This prunes the expensive + // angle sweep after the first fill. + if (knownGoodAngles.Count > 0 && !ForceFullAngleSweep) + { + var pruned = new List { bestRotation, bestRotation + Angle.HalfPI }; + + foreach (var a in knownGoodAngles) + { + if (!pruned.Any(existing => existing.IsEqualTo(a))) + pruned.Add(a); + } + + Debug.WriteLine($"[BuildCandidateAngles] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)"); + return pruned; + } + return angles; }