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) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,10 @@ namespace OpenNest
|
|||||||
|
|
||||||
public List<AngleResult> AngleResults { get; } = new();
|
public List<AngleResult> AngleResults { get; } = new();
|
||||||
|
|
||||||
|
// Angles that have produced results across multiple Fill calls.
|
||||||
|
// Populated after each Fill; used to prune subsequent fills.
|
||||||
|
private readonly HashSet<double> knownGoodAngles = new();
|
||||||
|
|
||||||
// --- Public Fill API ---
|
// --- Public Fill API ---
|
||||||
|
|
||||||
public bool Fill(NestItem item)
|
public bool Fill(NestItem item)
|
||||||
@@ -96,50 +100,56 @@ namespace OpenNest
|
|||||||
if (item.Quantity <= 1)
|
if (item.Quantity <= 1)
|
||||||
return Fill(item, workArea, progress, token);
|
return Fill(item, workArea, progress, token);
|
||||||
|
|
||||||
// Full fill to establish upper bound.
|
// Quick capacity check: estimate how many parts fit via bounding box.
|
||||||
var fullResult = Fill(item, workArea, progress, token);
|
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)
|
// Also check rotated orientation.
|
||||||
return fullResult;
|
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.
|
// Binary search: try shrinking each dimension.
|
||||||
var (lengthParts, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token);
|
Debug.WriteLine($"[FillExact] Starting binary search (capacity={capacity} > target={item.Quantity})");
|
||||||
var (widthParts, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token);
|
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.
|
// Pick winner by smallest test box area. Tie-break: prefer shrink-length.
|
||||||
List<Part> winner;
|
|
||||||
Box winnerBox;
|
Box winnerBox;
|
||||||
|
|
||||||
var lengthArea = lengthParts != null ? workArea.Width * lengthDim : double.MaxValue;
|
var lengthArea = lengthFound ? workArea.Width * lengthDim : double.MaxValue;
|
||||||
var widthArea = widthParts != null ? widthDim * workArea.Length : 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);
|
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);
|
winnerBox = new Box(workArea.X, workArea.Y, widthDim, workArea.Length);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Neither search found the exact quantity — return full fill truncated.
|
// Neither search found the exact quantity — do a normal fill.
|
||||||
return fullResult.Take(item.Quantity).ToList();
|
return Fill(item, workArea, progress, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-run the winner with progress so PhaseResults/WinnerPhase are correct
|
Debug.WriteLine($"[FillExact] Winner box: {winnerBox.Width:F1}x{winnerBox.Length:F1}");
|
||||||
// and the progress form shows the final result.
|
|
||||||
var finalResult = Fill(item, winnerBox, progress, token);
|
|
||||||
|
|
||||||
if (finalResult.Count >= item.Quantity)
|
// Run the full Fill on the winning box with progress.
|
||||||
return finalResult.Count > item.Quantity
|
return Fill(item, winnerBox, progress, token);
|
||||||
? finalResult.Take(item.Quantity).ToList()
|
|
||||||
: finalResult;
|
|
||||||
|
|
||||||
// Fallback: return the binary search result if the re-run produced fewer.
|
|
||||||
return winner;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -147,7 +157,7 @@ namespace OpenNest
|
|||||||
/// exactly item.Quantity parts. Returns the best parts list and the dimension
|
/// exactly item.Quantity parts. Returns the best parts list and the dimension
|
||||||
/// value that achieved it.
|
/// value that achieved it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private (List<Part> parts, double usedDim) BinarySearchFill(
|
private (bool found, double usedDim) BinarySearchFill(
|
||||||
NestItem item, Box workArea, bool shrinkWidth,
|
NestItem item, Box workArea, bool shrinkWidth,
|
||||||
CancellationToken token)
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -159,18 +169,23 @@ namespace OpenNest
|
|||||||
var fixedDim = shrinkWidth ? workArea.Length : workArea.Width;
|
var fixedDim = shrinkWidth ? workArea.Length : workArea.Width;
|
||||||
var highDim = shrinkWidth ? workArea.Width : workArea.Length;
|
var highDim = shrinkWidth ? workArea.Width : workArea.Length;
|
||||||
|
|
||||||
// Estimate starting point: target area at 50% utilization.
|
// Estimate search range from part area and utilization assumptions.
|
||||||
var targetArea = partArea * quantity / 0.5;
|
|
||||||
var minPartDim = shrinkWidth
|
var minPartDim = shrinkWidth
|
||||||
? partBox.Width + Plate.PartSpacing
|
? partBox.Width + Plate.PartSpacing
|
||||||
: partBox.Length + Plate.PartSpacing;
|
: partBox.Length + Plate.PartSpacing;
|
||||||
var estimatedDim = System.Math.Max(minPartDim, targetArea / fixedDim);
|
|
||||||
|
|
||||||
var low = estimatedDim;
|
// Low: tight estimate at 70% utilization.
|
||||||
var high = highDim;
|
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<Part> bestParts = null;
|
var low = lowEstimate;
|
||||||
var bestDim = high;
|
var high = highEstimate;
|
||||||
|
|
||||||
|
var found = false;
|
||||||
|
var bestDim = highEstimate;
|
||||||
|
|
||||||
for (var iter = 0; iter < 8; iter++)
|
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, mid, workArea.Length)
|
||||||
: new Box(workArea.X, workArea.Y, workArea.Width, mid);
|
: 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)
|
if (result.Count >= quantity)
|
||||||
{
|
{
|
||||||
bestParts = result.Count > quantity
|
|
||||||
? result.Take(quantity).ToList()
|
|
||||||
: result;
|
|
||||||
bestDim = mid;
|
bestDim = mid;
|
||||||
|
found = true;
|
||||||
high = mid;
|
high = mid;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -202,7 +218,7 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (bestParts, bestDim);
|
return (found, bestDim);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Fill(List<Part> groupParts)
|
public bool Fill(List<Part> groupParts)
|
||||||
@@ -381,6 +397,13 @@ namespace OpenNest
|
|||||||
linearSw.Stop();
|
linearSw.Stop();
|
||||||
PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds));
|
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}");
|
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());
|
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<double> { 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;
|
return angles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user