From 1c1508bc9e19b15eaab94219c04d71058f0d4d14 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 18:24:39 -0500 Subject: [PATCH] feat: add GpuPairEvaluator with ILGPU bitmap overlap kernel Co-Authored-By: Claude Opus 4.6 --- OpenNest.Gpu/GpuPairEvaluator.cs | 214 +++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 OpenNest.Gpu/GpuPairEvaluator.cs diff --git a/OpenNest.Gpu/GpuPairEvaluator.cs b/OpenNest.Gpu/GpuPairEvaluator.cs new file mode 100644 index 0000000..c88d00e --- /dev/null +++ b/OpenNest.Gpu/GpuPairEvaluator.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ILGPU; +using ILGPU.Runtime; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; + +namespace OpenNest.Gpu +{ + public class GpuPairEvaluator : IPairEvaluator, IDisposable + { + private readonly Context _context; + private readonly Accelerator _accelerator; + private readonly Drawing _drawing; + private readonly double _spacing; + private readonly double _cellSize; + + public GpuPairEvaluator(Drawing drawing, double spacing, double cellSize = PartBitmap.DefaultCellSize) + { + _drawing = drawing; + _spacing = spacing; + _cellSize = cellSize; + _context = Context.CreateDefault(); + _accelerator = _context.GetPreferredDevice(preferCPU: false) + .CreateAccelerator(_context); + } + + public List EvaluateAll(List candidates) + { + if (candidates.Count == 0) + return new List(); + + var dilation = _spacing / 2.0; + var bitmapA = PartBitmap.FromDrawing(_drawing, _cellSize, dilation); + + if (bitmapA.Width == 0 || bitmapA.Height == 0) + return candidates.Select(c => MakeEmptyResult(c)).ToList(); + + // Group candidates by Part2Rotation so we rasterize B once per unique rotation + var groups = candidates + .Select((c, i) => new { Candidate = c, OriginalIndex = i }) + .GroupBy(x => System.Math.Round(x.Candidate.Part2Rotation, 6)); + + var allResults = new BestFitResult[candidates.Count]; + var trueArea = _drawing.Area * 2; + + foreach (var group in groups) + { + var rotation = group.Key; + var groupItems = group.ToList(); + + // Rasterize B at this rotation + var bitmapB = PartBitmap.FromDrawingRotated(_drawing, rotation, _cellSize, dilation); + + if (bitmapB.Width == 0 || bitmapB.Height == 0) + { + foreach (var item in groupItems) + allResults[item.OriginalIndex] = MakeEmptyResult(item.Candidate); + continue; + } + + // Use the max dimensions so both bitmaps fit on the same grid + var gridWidth = System.Math.Max(bitmapA.Width, bitmapB.Width); + var gridHeight = System.Math.Max(bitmapA.Height, bitmapB.Height); + + var paddedA = PadBitmap(bitmapA, gridWidth, gridHeight); + var paddedB = PadBitmap(bitmapB, gridWidth, gridHeight); + + // Pack candidate offsets: convert world offset to cell offset + var candidateCount = groupItems.Count; + var offsets = new float[candidateCount * 3]; + + for (var i = 0; i < candidateCount; i++) + { + var c = groupItems[i].Candidate; + // Convert world-space offset to cell-space offset relative to bitmapA origin + offsets[i * 3 + 0] = (float)((c.Part2Offset.X - bitmapA.OriginX + bitmapB.OriginX) / _cellSize); + offsets[i * 3 + 1] = (float)((c.Part2Offset.Y - bitmapA.OriginY + bitmapB.OriginY) / _cellSize); + offsets[i * 3 + 2] = (float)c.Part2Rotation; + } + + var resultScores = new float[candidateCount]; + + using var gpuPaddedA = _accelerator.Allocate1D(paddedA); + using var gpuPaddedB = _accelerator.Allocate1D(paddedB); + using var gpuOffsets = _accelerator.Allocate1D(offsets); + using var gpuResults = _accelerator.Allocate1D(resultScores); + + var kernel = _accelerator.LoadAutoGroupedStreamKernel< + Index1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + int, int>(NestingKernel); + + kernel(candidateCount, gpuPaddedA.View, gpuPaddedB.View, + gpuOffsets.View, gpuResults.View, gridWidth, gridHeight); + + _accelerator.Synchronize(); + gpuResults.CopyToCPU(resultScores); + + // Map results back + for (var i = 0; i < candidateCount; i++) + { + var item = groupItems[i]; + var score = resultScores[i]; + var hasOverlap = score <= 0f; + + var combinedWidth = gridWidth * _cellSize; + var combinedHeight = gridHeight * _cellSize; + + allResults[item.OriginalIndex] = new BestFitResult + { + Candidate = item.Candidate, + RotatedArea = hasOverlap ? 0 : combinedWidth * combinedHeight, + BoundingWidth = combinedWidth, + BoundingHeight = combinedHeight, + OptimalRotation = 0, + TrueArea = trueArea, + Keep = !hasOverlap, + Reason = hasOverlap ? "Overlap detected" : "Valid" + }; + } + } + + return allResults.ToList(); + } + + private static void NestingKernel( + Index1D index, + ArrayView1D partBitmapA, + ArrayView1D partBitmapB, + ArrayView1D candidateOffsets, + ArrayView1D results, + int gridWidth, + int gridHeight) + { + var candidateIdx = index * 3; + var offsetX = candidateOffsets[candidateIdx]; + var offsetY = candidateOffsets[candidateIdx + 1]; + // rotation is already baked into partBitmapB, offset is what matters + + var overlapCount = 0; + var totalOccupied = 0; + + for (var y = 0; y < gridHeight; y++) + { + for (var x = 0; x < gridWidth; x++) + { + var cellA = partBitmapA[y * gridWidth + x]; + + // Apply offset to look up part B's cell + var bx = (int)(x - offsetX); + var by = (int)(y - offsetY); + + var cellB = 0; + if (bx >= 0 && bx < gridWidth && by >= 0 && by < gridHeight) + cellB = partBitmapB[by * gridWidth + bx]; + + if (cellA == 1 && cellB == 1) + overlapCount++; + if (cellA == 1 || cellB == 1) + totalOccupied++; + } + } + + if (overlapCount > 0) + results[index] = 0f; + else + results[index] = (float)totalOccupied / (gridWidth * gridHeight); + } + + private static int[] PadBitmap(PartBitmap bitmap, int targetWidth, int targetHeight) + { + if (bitmap.Width == targetWidth && bitmap.Height == targetHeight) + return bitmap.Cells; + + var padded = new int[targetWidth * targetHeight]; + + for (var y = 0; y < bitmap.Height; y++) + { + for (var x = 0; x < bitmap.Width; x++) + { + padded[y * targetWidth + x] = bitmap.Cells[y * bitmap.Width + x]; + } + } + + return padded; + } + + private static BestFitResult MakeEmptyResult(PairCandidate candidate) + { + return new BestFitResult + { + Candidate = candidate, + RotatedArea = 0, + BoundingWidth = 0, + BoundingHeight = 0, + OptimalRotation = 0, + TrueArea = 0, + Keep = false, + Reason = "No geometry" + }; + } + + public void Dispose() + { + _accelerator?.Dispose(); + _context?.Dispose(); + } + } +}