feat: integrate GPU slide computation into best-fit pipeline

Thread ISlideComputer through BestFitCache → BestFitFinder →
RotationSlideStrategy. RotationSlideStrategy now collects all offsets
across 4 push directions and dispatches them in a single batch (GPU or
CPU fallback). Also improves rotation angle extraction: uses raw geometry
(line endpoints + arc cardinal extremes) instead of tessellation to avoid
flooding the hull with near-duplicate edge angles, and adds a 5-degree
deduplication threshold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 20:29:51 -04:00
parent 97dfe27953
commit 183d169cc1
4 changed files with 175 additions and 74 deletions

View File

@@ -13,6 +13,7 @@ namespace OpenNest.Engine.BestFit
new ConcurrentDictionary<CacheKey, List<BestFitResult>>();
public static Func<Drawing, double, IPairEvaluator> CreateEvaluator { get; set; }
public static Func<ISlideComputer> CreateSlideComputer { get; set; }
public static List<BestFitResult> GetOrCompute(
Drawing drawing, double plateWidth, double plateHeight,
@@ -24,6 +25,7 @@ namespace OpenNest.Engine.BestFit
return cached;
IPairEvaluator evaluator = null;
ISlideComputer slideComputer = null;
try
{
@@ -33,7 +35,13 @@ namespace OpenNest.Engine.BestFit
catch { /* fall back to default evaluator */ }
}
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator);
if (CreateSlideComputer != null)
{
try { slideComputer = CreateSlideComputer(); }
catch { /* fall back to CPU slide computation */ }
}
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
var results = finder.FindBestFits(drawing, spacing, StepSize);
_cache.TryAdd(key, results);
@@ -42,6 +50,7 @@ namespace OpenNest.Engine.BestFit
finally
{
(evaluator as IDisposable)?.Dispose();
// Slide computer is managed by the factory as a singleton — don't dispose here
}
}

View File

@@ -12,11 +12,14 @@ namespace OpenNest.Engine.BestFit
public class BestFitFinder
{
private readonly IPairEvaluator _evaluator;
private readonly ISlideComputer _slideComputer;
private readonly BestFitFilter _filter;
public BestFitFinder(double maxPlateWidth, double maxPlateHeight, IPairEvaluator evaluator = null)
public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
{
_evaluator = evaluator ?? new PairEvaluator();
_slideComputer = slideComputer;
_filter = new BestFitFilter
{
MaxPlateWidth = maxPlateWidth,
@@ -78,7 +81,7 @@ namespace OpenNest.Engine.BestFit
foreach (var angle in angles)
{
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
strategies.Add(new RotationSlideStrategy(angle, type++, desc));
strategies.Add(new RotationSlideStrategy(angle, type++, desc, _slideComputer));
}
return strategies;
@@ -102,6 +105,7 @@ namespace OpenNest.Engine.BestFit
AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI));
}
angles.Sort();
return angles;
}
@@ -115,8 +119,24 @@ namespace OpenNest.Engine.BestFit
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(0.01);
points.AddRange(polygon.Vertices);
// Extract key points from original geometry — line endpoints
// plus arc endpoints and cardinal extreme points. This avoids
// tessellating arcs into many chords that flood the hull with
// near-duplicate edge angles.
foreach (var entity in shape.Entities)
{
if (entity is Line line)
{
points.Add(line.StartPoint);
points.Add(line.EndPoint);
}
else if (entity is Arc arc)
{
points.Add(arc.StartPoint());
points.Add(arc.EndPoint());
AddArcExtremes(points, arc);
}
}
}
if (points.Count < 3)
@@ -143,13 +163,49 @@ namespace OpenNest.Engine.BestFit
return hullAngles;
}
/// <summary>
/// Adds the cardinal extreme points of an arc (0°, 90°, 180°, 270°)
/// if they fall within the arc's angular span.
/// </summary>
private static void AddArcExtremes(List<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
if (arc.IsReversed)
Generic.Swap(ref a1, ref a2);
// Right (0°)
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
// Top (90°)
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
// Left (180°)
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
// Bottom (270°)
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
}
/// <summary>
/// Minimum angular separation (radians) between hull-derived rotation candidates.
/// Tessellated arcs produce many hull edges with nearly identical angles;
/// a 1° threshold collapses those into a single representative.
/// </summary>
private const double AngleTolerance = System.Math.PI / 36; // 5 degrees
private static void AddUniqueAngle(List<double> angles, double angle)
{
angle = Angle.NormalizeRad(angle);
foreach (var existing in angles)
{
if (existing.IsEqualTo(angle))
if (existing.IsEqualTo(angle, AngleTolerance))
return;
}

View File

@@ -5,11 +5,20 @@ namespace OpenNest.Engine.BestFit
{
public class RotationSlideStrategy : IBestFitStrategy
{
public RotationSlideStrategy(double part2Rotation, int type, string description)
private readonly ISlideComputer _slideComputer;
private static readonly PushDirection[] AllDirections =
{
PushDirection.Left, PushDirection.Down, PushDirection.Right, PushDirection.Up
};
public RotationSlideStrategy(double part2Rotation, int type, string description,
ISlideComputer slideComputer = null)
{
Part2Rotation = part2Rotation;
Type = type;
Description = description;
_slideComputer = slideComputer;
}
public double Part2Rotation { get; }
@@ -23,43 +32,66 @@ namespace OpenNest.Engine.BestFit
var part1 = Part.CreateAtOrigin(drawing);
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
var halfSpacing = spacing / 2;
var part1Lines = Helper.GetOffsetPartLines(part1, halfSpacing);
var part2TemplateLines = Helper.GetOffsetPartLines(part2Template, halfSpacing);
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
// Collect offsets and directions across all 4 axes
var allDx = new List<double>();
var allDy = new List<double>();
var allDirs = new List<PushDirection>();
foreach (var pushDir in AllDirections)
BuildOffsets(bbox1, bbox2, spacing, stepSize, pushDir, allDx, allDy, allDirs);
if (allDx.Count == 0)
return candidates;
// Compute all distances — single GPU dispatch or CPU loop
var distances = ComputeAllDistances(
part1Lines, part2TemplateLines, allDx, allDy, allDirs);
// Create candidates from valid results
var testNumber = 0;
// Try pushing left (horizontal slide)
GenerateCandidatesForAxis(
part1, part2Template, drawing, spacing, stepSize,
PushDirection.Left, candidates, ref testNumber);
for (var i = 0; i < allDx.Count; i++)
{
var slideDist = distances[i];
if (slideDist >= double.MaxValue || slideDist < 0)
continue;
// Try pushing down (vertical slide)
GenerateCandidatesForAxis(
part1, part2Template, drawing, spacing, stepSize,
PushDirection.Down, candidates, ref testNumber);
var dx = allDx[i];
var dy = allDy[i];
var pushVector = GetPushVector(allDirs[i], slideDist);
var finalPosition = new Vector(
part2Template.Location.X + dx + pushVector.X,
part2Template.Location.Y + dy + pushVector.Y);
// Try pushing right (approach from left — finds concave interlocking)
GenerateCandidatesForAxis(
part1, part2Template, drawing, spacing, stepSize,
PushDirection.Right, candidates, ref testNumber);
// Try pushing up (approach from below — finds concave interlocking)
GenerateCandidatesForAxis(
part1, part2Template, drawing, spacing, stepSize,
PushDirection.Up, candidates, ref testNumber);
candidates.Add(new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = Part2Rotation,
Part2Offset = finalPosition,
StrategyType = Type,
TestNumber = testNumber++,
Spacing = spacing
});
}
return candidates;
}
private void GenerateCandidatesForAxis(
Part part1, Part part2Template, Drawing drawing,
double spacing, double stepSize, PushDirection pushDir,
List<PairCandidate> candidates, ref int testNumber)
private static void BuildOffsets(
Box bbox1, Box bbox2, double spacing, double stepSize,
PushDirection pushDir, List<double> allDx, List<double> allDy,
List<PushDirection> allDirs)
{
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
var halfSpacing = spacing / 2;
var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right;
// Perpendicular range: part2 slides across the full extent of part1
double perpMin, perpMax, pushStartOffset;
if (isHorizontalPush)
@@ -75,54 +107,55 @@ namespace OpenNest.Engine.BestFit
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
}
// Pre-compute part1's offset lines (half-spacing outward)
var part1Lines = Helper.GetOffsetPartLines(part1, halfSpacing);
// Align sweep start to a multiple of stepSize so that offset=0 is always
// included. This ensures perfect grid arrangements (side-by-side, stacked)
// are generated for rectangular parts.
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
{
var part2 = (Part)part2Template.Clone();
// Place part2 far away along push axis, at perpendicular offset.
// Left/Down: start on the positive side; Right/Up: start on the negative side.
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
if (isHorizontalPush)
part2.Offset(startPos, offset);
else
part2.Offset(offset, startPos);
// Get part2's offset lines (half-spacing outward)
var part2Lines = Helper.GetOffsetPartLines(part2, halfSpacing);
// Find contact distance
var slideDist = Helper.DirectionalDistance(part2Lines, part1Lines, pushDir);
if (slideDist >= double.MaxValue || slideDist < 0)
continue;
// Move part2 to contact position
var pushVector = GetPushVector(pushDir, slideDist);
var finalPosition = part2.Location + pushVector;
candidates.Add(new PairCandidate
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = Part2Rotation,
Part2Offset = finalPosition,
StrategyType = Type,
TestNumber = testNumber++,
Spacing = spacing
});
allDx.Add(isHorizontalPush ? startPos : offset);
allDy.Add(isHorizontalPush ? offset : startPos);
allDirs.Add(pushDir);
}
}
private double[] ComputeAllDistances(
List<Line> part1Lines, List<Line> part2TemplateLines,
List<double> allDx, List<double> allDy, List<PushDirection> allDirs)
{
var count = allDx.Count;
if (_slideComputer != null)
{
var stationarySegments = Helper.FlattenLines(part1Lines);
var movingSegments = Helper.FlattenLines(part2TemplateLines);
var offsets = new double[count * 2];
var directions = new int[count];
for (var i = 0; i < count; i++)
{
offsets[i * 2] = allDx[i];
offsets[i * 2 + 1] = allDy[i];
directions[i] = (int)allDirs[i];
}
return _slideComputer.ComputeBatchMultiDir(
stationarySegments, part1Lines.Count,
movingSegments, part2TemplateLines.Count,
offsets, count, directions);
}
var results = new double[count];
for (var i = 0; i < count; i++)
{
results[i] = Helper.DirectionalDistance(
part2TemplateLines, allDx[i], allDy[i], part1Lines, allDirs[i]);
}
return results;
}
private static Vector GetPushVector(PushDirection direction, double distance)
{
switch (direction)

View File

@@ -50,6 +50,9 @@ namespace OpenNest.Forms
//if (GpuEvaluatorFactory.GpuAvailable)
// BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);
if (GpuEvaluatorFactory.GpuAvailable)
BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer();
}
private Nest CreateDefaultNest()