Files
OpenNest/OpenNest.Engine/PlateOptimizer.cs
AJ Isaacs e93523d7a2 perf: optimize best fit computation and plate optimizer
- Try all valid best fit pairs instead of only the first when qty=2,
  picking the best via IsBetterFill comparer (fixes suboptimal plate
  selection during auto-nesting)
- Pre-compute best fits across all plate sizes once via
  BestFitCache.ComputeForSizes instead of per-size GPU evaluation
- Early exit plate optimizer when all items fit (salvage < 100%)
- Trim slide offset sweep range to 50% overlap to reduce candidates
- Use actual geometry (ray-arc/ray-circle intersection) instead of
  tessellated polygons for slide distance computation — eliminates
  the massive line count from circle/arc tessellation
- Add RayArcDistance and RayCircleDistance to SpatialQuery
- Add PartGeometry.GetOffsetPerimeterEntities for non-tessellated
  perimeter extraction
- Disable GPU slide computer (slower than CPU currently)
- Remove dead SelectBestFitPair virtual method and overrides

Reduces best fit computation from 7+ minutes to ~4 seconds for a
73x25" part with 30+ holes on a 48x96 plate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:21:44 -04:00

181 lines
7.0 KiB
C#

using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
namespace OpenNest
{
public static class PlateOptimizer
{
public static PlateOptimizerResult Optimize(
List<NestItem> items,
List<PlateOption> plateOptions,
double salvageRate,
Plate templatePlate,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
{
if (items == null || items.Count == 0 || plateOptions == null || plateOptions.Count == 0)
return null;
// Find the minimum dimension needed to fit the largest part.
var minPartWidth = 0.0;
var minPartLength = 0.0;
foreach (var item in items)
{
if (item.Quantity <= 0) continue;
var bb = item.Drawing.Program.BoundingBox();
var shortSide = System.Math.Min(bb.Width, bb.Length);
var longSide = System.Math.Max(bb.Width, bb.Length);
if (shortSide > minPartWidth) minPartWidth = shortSide;
if (longSide > minPartLength) minPartLength = longSide;
}
// Sort candidates by cost ascending — try cheapest first.
var candidates = plateOptions
.Where(o => FitsPart(o, minPartWidth, minPartLength, templatePlate.EdgeSpacing))
.OrderBy(o => o.Cost)
.ToList();
if (candidates.Count == 0)
return null;
// Pre-compute best fits for all candidate plate sizes at once.
// This runs the expensive GPU evaluation once on the largest plate
// and filters the results for each smaller size.
var plateSizes = candidates
.Select(o => (Width: o.Length, Height: o.Width))
.ToList();
foreach (var item in items)
{
if (item.Quantity <= 0) continue;
BestFitCache.ComputeForSizes(item.Drawing, templatePlate.PartSpacing, plateSizes);
}
PlateOptimizerResult best = null;
foreach (var option in candidates)
{
if (token.IsCancellationRequested)
break;
var result = TryPlateSize(option, items, salvageRate, templatePlate, progress, token);
if (result == null)
continue;
if (IsBetter(result, best))
best = result;
// Early exit: when all items fit, larger plates can only have
// worse utilization and higher cost. With salvage < 100%, the
// remnant credit never offsets the extra plate cost, so skip.
if (salvageRate < 1.0)
{
var allPlaced = items.All(i => i.Quantity <= 0 ||
result.Parts.Count(p => p.BaseDrawing.Name == i.Drawing.Name) >= i.Quantity);
if (allPlaced)
{
Debug.WriteLine($"[PlateOptimizer] Early exit: {option.Width}x{option.Length} placed all items");
break;
}
}
}
return best;
}
private static bool FitsPart(PlateOption option, double minWidth, double minLength, Spacing edgeSpacing)
{
var workW = option.Width - edgeSpacing.Left - edgeSpacing.Right;
var workL = option.Length - edgeSpacing.Top - edgeSpacing.Bottom;
// Part fits in either orientation.
var fitsNormal = workW >= minWidth - Tolerance.Epsilon && workL >= minLength - Tolerance.Epsilon;
var fitsRotated = workW >= minLength - Tolerance.Epsilon && workL >= minWidth - Tolerance.Epsilon;
return fitsNormal || fitsRotated;
}
private static PlateOptimizerResult TryPlateSize(
PlateOption option,
List<NestItem> items,
double salvageRate,
Plate templatePlate,
IProgress<NestProgress> progress,
CancellationToken token)
{
// Create a temporary plate with candidate size + settings from template.
var tempPlate = new Plate(option.Width, option.Length)
{
PartSpacing = templatePlate.PartSpacing,
EdgeSpacing = new Spacing
{
Left = templatePlate.EdgeSpacing.Left,
Right = templatePlate.EdgeSpacing.Right,
Top = templatePlate.EdgeSpacing.Top,
Bottom = templatePlate.EdgeSpacing.Bottom,
},
};
// Clone items so the dry run doesn't mutate originals.
var clonedItems = items.Select(i => new NestItem
{
Drawing = i.Drawing, // share Drawing reference for BestFitCache compatibility
Priority = i.Priority,
Quantity = i.Quantity,
StepAngle = i.StepAngle,
RotationStart = i.RotationStart,
RotationEnd = i.RotationEnd,
}).ToList();
var engine = NestEngineRegistry.Create(tempPlate);
var parts = engine.Nest(clonedItems, progress, token);
if (parts == null || parts.Count == 0)
return null;
var workArea = tempPlate.WorkArea();
var plateArea = workArea.Width * workArea.Length;
var partsArea = 0.0;
foreach (var part in parts)
partsArea += part.BoundingBox.Area();
var remnantArea = plateArea - partsArea;
var costPerSqUnit = option.Cost / option.Area;
var netCost = option.Cost - (remnantArea * costPerSqUnit * salvageRate);
Debug.WriteLine($"[PlateOptimizer] {option.Width}x{option.Length} ${option.Cost}: " +
$"{parts.Count} parts, util={partsArea / plateArea:P1}, net=${netCost:F2}");
return new PlateOptimizerResult
{
Parts = parts,
ChosenSize = option,
NetCost = netCost,
Utilization = plateArea > 0 ? partsArea / plateArea : 0,
};
}
private static bool IsBetter(PlateOptimizerResult candidate, PlateOptimizerResult current)
{
if (current == null) return true;
// 1. More parts placed is always better.
if (candidate.Parts.Count != current.Parts.Count)
return candidate.Parts.Count > current.Parts.Count;
// 2. Lower net cost.
if (!candidate.NetCost.IsEqualTo(current.NetCost))
return candidate.NetCost < current.NetCost;
// 3. Higher utilization (tighter density) as tiebreak.
return candidate.Utilization > current.Utilization;
}
}
}