From 183d169cc11dd4fe62d72f7c60fbf65a6163d0eb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 20:29:51 -0400 Subject: [PATCH] feat: integrate GPU slide computation into best-fit pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- OpenNest.Engine/BestFit/BestFitCache.cs | 11 +- OpenNest.Engine/BestFit/BestFitFinder.cs | 66 ++++++- .../BestFit/RotationSlideStrategy.cs | 169 +++++++++++------- OpenNest/Forms/MainForm.cs | 3 + 4 files changed, 175 insertions(+), 74 deletions(-) diff --git a/OpenNest.Engine/BestFit/BestFitCache.cs b/OpenNest.Engine/BestFit/BestFitCache.cs index 04b7613..b4318a2 100644 --- a/OpenNest.Engine/BestFit/BestFitCache.cs +++ b/OpenNest.Engine/BestFit/BestFitCache.cs @@ -13,6 +13,7 @@ namespace OpenNest.Engine.BestFit new ConcurrentDictionary>(); public static Func CreateEvaluator { get; set; } + public static Func CreateSlideComputer { get; set; } public static List 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 } } diff --git a/OpenNest.Engine/BestFit/BestFitFinder.cs b/OpenNest.Engine/BestFit/BestFitFinder.cs index 89d0681..b456bf6 100644 --- a/OpenNest.Engine/BestFit/BestFitFinder.cs +++ b/OpenNest.Engine/BestFit/BestFitFinder.cs @@ -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; } + /// + /// Adds the cardinal extreme points of an arc (0°, 90°, 180°, 270°) + /// if they fall within the arc's angular span. + /// + private static void AddArcExtremes(List 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)); + } + + /// + /// 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. + /// + private const double AngleTolerance = System.Math.PI / 36; // 5 degrees + private static void AddUniqueAngle(List angles, double angle) { angle = Angle.NormalizeRad(angle); foreach (var existing in angles) { - if (existing.IsEqualTo(angle)) + if (existing.IsEqualTo(angle, AngleTolerance)) return; } diff --git a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs index f0e0771..7da14dd 100644 --- a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs +++ b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs @@ -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(); + var allDy = new List(); + var allDirs = new List(); + + 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 candidates, ref int testNumber) + private static void BuildOffsets( + Box bbox1, Box bbox2, double spacing, double stepSize, + PushDirection pushDir, List allDx, List allDy, + List 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; + var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down; + var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset; 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 - { - 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 part1Lines, List part2TemplateLines, + List allDx, List allDy, List 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) diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index d98568b..6f526e0 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -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()