docs: add GPU bitmap best fit implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 18:06:01 -05:00
parent 443556d2e3
commit 688b00b3e4

View File

@@ -0,0 +1,769 @@
# GPU Bitmap Best Fit Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add GPU-accelerated bitmap overlap testing to the best fit pair evaluator using ILGPU, alongside the existing geometry evaluator.
**Architecture:** New `OpenNest.Gpu` project holds `PartBitmap` and `GpuPairEvaluator`. Engine gets an `IPairEvaluator` interface that both geometry and GPU paths implement. `BestFitFinder` accepts the interface; `NestEngine` selects which evaluator via a `UseGpu` flag.
**Tech Stack:** .NET 8, ILGPU 1.5+, ILGPU.Algorithms
---
### Task 1: Add `Polygon.ContainsPoint` to Core
**Files:**
- Modify: `OpenNest.Core/Geometry/Polygon.cs:610` (before closing brace)
**Step 1: Add ContainsPoint method**
Insert before the closing `}` of the `Polygon` class (line 611):
```csharp
public bool ContainsPoint(Vector pt)
{
var n = IsClosed() ? Vertices.Count - 1 : Vertices.Count;
if (n < 3)
return false;
var inside = false;
for (var i = 0, j = n - 1; i < n; j = i++)
{
var vi = Vertices[i];
var vj = Vertices[j];
if ((vi.Y > pt.Y) != (vj.Y > pt.Y) &&
pt.X < (vj.X - vi.X) * (pt.Y - vi.Y) / (vj.Y - vi.Y) + vi.X)
{
inside = !inside;
}
}
return inside;
}
```
This is the standard even-odd ray casting algorithm. Casts a ray rightward from `pt`, toggles `inside` at each edge crossing.
**Step 2: Build to verify**
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
Expected: Build succeeded
**Step 3: Commit**
```bash
git add OpenNest.Core/Geometry/Polygon.cs
git commit -m "feat: add Polygon.ContainsPoint using ray casting"
```
---
### Task 2: Extract `IPairEvaluator` interface in Engine
**Files:**
- Create: `OpenNest.Engine/BestFit/IPairEvaluator.cs`
- Modify: `OpenNest.Engine/BestFit/PairEvaluator.cs`
**Step 1: Create the interface**
```csharp
using System.Collections.Generic;
namespace OpenNest.Engine.BestFit
{
public interface IPairEvaluator
{
List<BestFitResult> EvaluateAll(List<PairCandidate> candidates);
}
}
```
**Step 2: Make `PairEvaluator` implement the interface**
In `PairEvaluator.cs`, change the class declaration (line 9) to:
```csharp
public class PairEvaluator : IPairEvaluator
```
Add the `EvaluateAll` method. This wraps the existing per-candidate `Evaluate` in a `Parallel.ForEach`, matching the current behavior in `BestFitFinder.FindBestFits()`:
```csharp
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
{
var resultBag = new System.Collections.Concurrent.ConcurrentBag<BestFitResult>();
System.Threading.Tasks.Parallel.ForEach(candidates, c =>
{
resultBag.Add(Evaluate(c));
});
return resultBag.ToList();
}
```
Add `using System.Linq;` if not already present (it is — line 2).
**Step 3: Update `BestFitFinder` to use `IPairEvaluator`**
In `BestFitFinder.cs`:
Change the field and constructor to accept an optional evaluator:
```csharp
public class BestFitFinder
{
private readonly IPairEvaluator _evaluator;
private readonly BestFitFilter _filter;
public BestFitFinder(double maxPlateWidth, double maxPlateHeight, IPairEvaluator evaluator = null)
{
_evaluator = evaluator ?? new PairEvaluator();
_filter = new BestFitFilter
{
MaxPlateWidth = maxPlateWidth,
MaxPlateHeight = maxPlateHeight
};
}
```
Replace the evaluation `Parallel.ForEach` block in `FindBestFits()` (lines 44-52) with:
```csharp
var results = _evaluator.EvaluateAll(allCandidates);
```
Remove the `ConcurrentBag<BestFitResult>` and the second `Parallel.ForEach` — those lines (44-52) are fully replaced by the single call above.
**Step 4: Build to verify**
Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj`
Expected: Build succeeded
**Step 5: Build full solution to verify nothing broke**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded (NestEngine still creates BestFitFinder with 2 args — still valid)
**Step 6: Commit**
```bash
git add OpenNest.Engine/BestFit/IPairEvaluator.cs OpenNest.Engine/BestFit/PairEvaluator.cs OpenNest.Engine/BestFit/BestFitFinder.cs
git commit -m "refactor: extract IPairEvaluator interface from PairEvaluator"
```
---
### Task 3: Create `OpenNest.Gpu` project with `PartBitmap`
**Files:**
- Create: `OpenNest.Gpu/OpenNest.Gpu.csproj`
- Create: `OpenNest.Gpu/PartBitmap.cs`
- Modify: `OpenNest.sln` (add project)
**Step 1: Create project**
```bash
cd "C:\Users\aisaacs\Desktop\Projects\OpenNest"
dotnet new classlib -n OpenNest.Gpu --framework net8.0-windows
rm OpenNest.Gpu/Class1.cs
dotnet sln OpenNest.sln add OpenNest.Gpu/OpenNest.Gpu.csproj
```
**Step 2: Edit csproj**
Replace the generated `OpenNest.Gpu.csproj` with:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Gpu</RootNamespace>
<AssemblyName>OpenNest.Gpu</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ILGPU" Version="1.5.1" />
<PackageReference Include="ILGPU.Algorithms" Version="1.5.1" />
</ItemGroup>
</Project>
```
**Step 3: Create `PartBitmap.cs`**
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Gpu
{
public class PartBitmap
{
public int[] Cells { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public double CellSize { get; set; }
public double OriginX { get; set; }
public double OriginY { get; set; }
public static PartBitmap FromDrawing(Drawing drawing, double cellSize, double spacingDilation = 0)
{
var polygons = GetClosedPolygons(drawing);
if (polygons.Count == 0)
return new PartBitmap { Cells = Array.Empty<int>(), Width = 0, Height = 0, CellSize = cellSize };
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var poly in polygons)
{
poly.UpdateBounds();
var bb = poly.BoundingBox;
if (bb.Left < minX) minX = bb.Left;
if (bb.Bottom < minY) minY = bb.Bottom;
if (bb.Right > maxX) maxX = bb.Right;
if (bb.Top > maxY) maxY = bb.Top;
}
// Expand bounds by dilation amount
minX -= spacingDilation;
minY -= spacingDilation;
maxX += spacingDilation;
maxY += spacingDilation;
var width = (int)System.Math.Ceiling((maxX - minX) / cellSize);
var height = (int)System.Math.Ceiling((maxY - minY) / cellSize);
if (width <= 0 || height <= 0)
return new PartBitmap { Cells = Array.Empty<int>(), Width = 0, Height = 0, CellSize = cellSize };
var cells = new int[width * height];
var dilationCells = (int)System.Math.Ceiling(spacingDilation / cellSize);
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var px = minX + (x + 0.5) * cellSize;
var py = minY + (y + 0.5) * cellSize;
var pt = new Vector(px, py);
foreach (var poly in polygons)
{
if (poly.ContainsPoint(pt))
{
cells[y * width + x] = 1;
break;
}
}
}
}
// Dilate: expand filled cells outward by dilationCells
if (dilationCells > 0)
Dilate(cells, width, height, dilationCells);
return new PartBitmap
{
Cells = cells,
Width = width,
Height = height,
CellSize = cellSize,
OriginX = minX,
OriginY = minY
};
}
private static List<Polygon> GetClosedPolygons(Drawing drawing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities);
var polygons = new List<Polygon>();
foreach (var shape in shapes)
{
if (!shape.IsClosed())
continue;
var polygon = shape.ToPolygonWithTolerance(0.05);
polygon.Close();
polygons.Add(polygon);
}
return polygons;
}
private static void Dilate(int[] cells, int width, int height, int radius)
{
var source = (int[])cells.Clone();
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
if (source[y * width + x] != 1)
continue;
for (var dy = -radius; dy <= radius; dy++)
{
for (var dx = -radius; dx <= radius; dx++)
{
var nx = x + dx;
var ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height)
cells[ny * width + nx] = 1;
}
}
}
}
}
}
}
```
**Step 4: Build**
Run: `dotnet build OpenNest.Gpu/OpenNest.Gpu.csproj`
Expected: Build succeeded (ILGPU NuGet restored)
**Step 5: Commit**
```bash
git add OpenNest.Gpu/ OpenNest.sln
git commit -m "feat: add OpenNest.Gpu project with PartBitmap rasterizer"
```
---
### Task 4: Implement `GpuPairEvaluator` with ILGPU kernel
**Files:**
- Create: `OpenNest.Gpu/GpuPairEvaluator.cs`
**Step 1: Create the evaluator**
```csharp
using System;
using System.Collections.Generic;
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 PartBitmap _bitmap;
private readonly double _spacing;
public const double DefaultCellSize = 0.05;
public GpuPairEvaluator(Drawing drawing, double spacing, double cellSize = DefaultCellSize)
{
_drawing = drawing;
_spacing = spacing;
_context = Context.CreateDefault();
_accelerator = _context.GetPreferredDevice(preferCPU: false)
.CreateAccelerator(_context);
var dilation = spacing / 2.0;
_bitmap = PartBitmap.FromDrawing(drawing, cellSize, dilation);
}
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
{
if (_bitmap.Width == 0 || _bitmap.Height == 0 || candidates.Count == 0)
return new List<BestFitResult>();
var bitmapWidth = _bitmap.Width;
var bitmapHeight = _bitmap.Height;
var cellSize = (float)_bitmap.CellSize;
var candidateCount = candidates.Count;
// Pack candidate parameters: offsetX, offsetY, rotation, unused
var candidateParams = new float[candidateCount * 4];
for (var i = 0; i < candidateCount; i++)
{
candidateParams[i * 4 + 0] = (float)candidates[i].Part2Offset.X;
candidateParams[i * 4 + 1] = (float)candidates[i].Part2Offset.Y;
candidateParams[i * 4 + 2] = (float)candidates[i].Part2Rotation;
candidateParams[i * 4 + 3] = 0f;
}
// Results: overlapCount, minX, minY, maxX, maxY per candidate
var resultData = new int[candidateCount * 5];
// Initialize min to large, max to small
for (var i = 0; i < candidateCount; i++)
{
resultData[i * 5 + 0] = 0; // overlapCount
resultData[i * 5 + 1] = int.MaxValue; // minX
resultData[i * 5 + 2] = int.MaxValue; // minY
resultData[i * 5 + 3] = int.MinValue; // maxX
resultData[i * 5 + 4] = int.MinValue; // maxY
}
using var gpuBitmap = _accelerator.Allocate1D(_bitmap.Cells);
using var gpuParams = _accelerator.Allocate1D(candidateParams);
using var gpuResults = _accelerator.Allocate1D(resultData);
var kernel = _accelerator.LoadAutoGroupedStreamKernel<
Index1D,
ArrayView<int>,
ArrayView<float>,
ArrayView<int>,
int, int, float, float, float>(EvaluateKernel);
kernel(
candidateCount,
gpuBitmap.View,
gpuParams.View,
gpuResults.View,
bitmapWidth,
bitmapHeight,
cellSize,
(float)_bitmap.OriginX,
(float)_bitmap.OriginY);
_accelerator.Synchronize();
gpuResults.CopyToCPU(resultData);
var trueArea = _drawing.Area * 2;
var results = new List<BestFitResult>(candidateCount);
for (var i = 0; i < candidateCount; i++)
{
var overlapCount = resultData[i * 5 + 0];
var minX = resultData[i * 5 + 1];
var minY = resultData[i * 5 + 2];
var maxX = resultData[i * 5 + 3];
var maxY = resultData[i * 5 + 4];
var hasOverlap = overlapCount > 0;
var hasBounds = minX <= maxX && minY <= maxY;
double boundingWidth = 0, boundingHeight = 0, area = 0;
if (hasBounds)
{
boundingWidth = (maxX - minX + 1) * _bitmap.CellSize;
boundingHeight = (maxY - minY + 1) * _bitmap.CellSize;
area = boundingWidth * boundingHeight;
}
results.Add(new BestFitResult
{
Candidate = candidates[i],
RotatedArea = area,
BoundingWidth = boundingWidth,
BoundingHeight = boundingHeight,
OptimalRotation = 0,
TrueArea = trueArea,
Keep = !hasOverlap && hasBounds,
Reason = hasOverlap ? "Overlap detected" : hasBounds ? "Valid" : "No bounds"
});
}
return results;
}
private static void EvaluateKernel(
Index1D index,
ArrayView<int> bitmap,
ArrayView<float> candidateParams,
ArrayView<int> results,
int bitmapWidth, int bitmapHeight,
float cellSize, float originX, float originY)
{
var paramIdx = index * 4;
var offsetX = candidateParams[paramIdx + 0];
var offsetY = candidateParams[paramIdx + 1];
var rotation = candidateParams[paramIdx + 2];
// Convert world offset to cell offset relative to bitmap origin
var offsetCellsX = (offsetX - originX) / cellSize;
var offsetCellsY = (offsetY - originY) / cellSize;
var cosR = IntrinsicMath.Cos(rotation);
var sinR = IntrinsicMath.Sin(rotation);
var halfW = bitmapWidth * 0.5f;
var halfH = bitmapHeight * 0.5f;
var overlapCount = 0;
var minX = int.MaxValue;
var minY = int.MaxValue;
var maxX = int.MinValue;
var maxY = int.MinValue;
for (var y = 0; y < bitmapHeight; y++)
{
for (var x = 0; x < bitmapWidth; x++)
{
var cell1 = bitmap[y * bitmapWidth + x];
// Transform (x,y) to part2 space: rotate around center then offset
var cx = x - halfW;
var cy = y - halfH;
var rx = cx * cosR - cy * sinR;
var ry = cx * sinR + cy * cosR;
var bx = (int)(rx + halfW + offsetCellsX - x);
var by = (int)(ry + halfH + offsetCellsY - y);
// Lookup part2 bitmap cell at transformed position
var bx2 = x + bx;
var by2 = y + by;
var cell2 = 0;
if (bx2 >= 0 && bx2 < bitmapWidth && by2 >= 0 && by2 < bitmapHeight)
cell2 = bitmap[by2 * bitmapWidth + bx2];
if (cell1 == 1 && cell2 == 1)
overlapCount++;
if (cell1 == 1 || cell2 == 1)
{
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
var resultIdx = index * 5;
results[resultIdx + 0] = overlapCount;
results[resultIdx + 1] = minX;
results[resultIdx + 2] = minY;
results[resultIdx + 3] = maxX;
results[resultIdx + 4] = maxY;
}
public void Dispose()
{
_accelerator?.Dispose();
_context?.Dispose();
}
}
}
```
Note: The kernel uses `IntrinsicMath.Cos`/`Sin` which ILGPU compiles to GPU intrinsics. The `int.MaxValue`/`int.MinValue` initialization for bounds tracking is done CPU-side before upload.
**Step 2: Build**
Run: `dotnet build OpenNest.Gpu/OpenNest.Gpu.csproj`
Expected: Build succeeded
**Step 3: Commit**
```bash
git add OpenNest.Gpu/GpuPairEvaluator.cs
git commit -m "feat: add GpuPairEvaluator with ILGPU bitmap overlap kernel"
```
---
### Task 5: Wire GPU evaluator into `NestEngine`
**Files:**
- Modify: `OpenNest.Engine/NestEngine.cs`
- Modify: `OpenNest/OpenNest.csproj` (add reference to OpenNest.Gpu)
**Step 1: Add `UseGpu` property to `NestEngine`**
At the top of the `NestEngine` class (after the existing properties around line 23), add:
```csharp
public bool UseGpu { get; set; }
```
**Step 2: Update `FillWithPairs` to use GPU evaluator when enabled**
In `NestEngine.cs`, the `FillWithPairs(NestItem item, Box workArea)` method (line 268) creates a `BestFitFinder`. Change it to optionally pass a GPU evaluator.
Add at the top of the file:
```csharp
using OpenNest.Engine.BestFit;
```
(Already present — line 6.)
Replace the `FillWithPairs(NestItem item, Box workArea)` method body. The key change is lines 270-271 where the finder is created:
```csharp
private List<Part> FillWithPairs(NestItem item, Box workArea)
{
IPairEvaluator evaluator = null;
if (UseGpu)
{
try
{
evaluator = new Gpu.GpuPairEvaluator(item.Drawing, Plate.PartSpacing);
}
catch
{
// GPU not available, fall back to geometry
}
}
var finder = new BestFitFinder(Plate.Size.Width, Plate.Size.Height, evaluator);
var bestFits = finder.FindBestFits(item.Drawing, Plate.PartSpacing, stepSize: 0.25);
var keptResults = bestFits.Where(r => r.Keep).Take(50).ToList();
Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {keptResults.Count}");
var resultBag = new System.Collections.Concurrent.ConcurrentBag<(int count, List<Part> parts)>();
System.Threading.Tasks.Parallel.For(0, keptResults.Count, i =>
{
var result = keptResults[i];
var pairParts = BuildPairParts(result, item.Drawing);
var angles = FindHullEdgeAngles(pairParts);
var engine = new FillLinear(workArea, Plate.PartSpacing);
var filled = FillPattern(engine, pairParts, angles);
if (filled != null && filled.Count > 0)
resultBag.Add((filled.Count, filled));
});
List<Part> best = null;
foreach (var (count, parts) in resultBag)
{
if (best == null || count > best.Count)
best = parts;
}
(evaluator as IDisposable)?.Dispose();
Debug.WriteLine($"[FillWithPairs] Best pair result: {best?.Count ?? 0} parts");
return best ?? new List<Part>();
}
```
**Step 3: Add OpenNest.Gpu reference to UI project**
In `OpenNest/OpenNest.csproj`, add to the `<ItemGroup>` with other project references:
```xml
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
```
**Step 4: Build full solution**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded
**Step 5: Commit**
```bash
git add OpenNest.Engine/NestEngine.cs OpenNest/OpenNest.csproj
git commit -m "feat: wire GpuPairEvaluator into NestEngine with UseGpu flag"
```
---
### Task 6: Add UI toggle for GPU mode
**Files:**
- Modify: `OpenNest/Forms/MainForm.cs`
- Modify: `OpenNest/Forms/MainForm.Designer.cs`
This task adds a "Use GPU" checkbox menu item under the Tools menu. The exact placement depends on the existing menu structure.
**Step 1: Check existing menu structure**
Read `MainForm.Designer.cs` to find the Tools menu items and their initialization to determine where to add the GPU toggle. Look for `mnuTools` items.
**Step 2: Add menu item field**
In `MainForm.Designer.cs`, add a field declaration near the other menu fields:
```csharp
private System.Windows.Forms.ToolStripMenuItem mnuToolsUseGpu;
```
**Step 3: Initialize menu item**
In the `InitializeComponent()` method, initialize the item and add it to the Tools menu `DropDownItems`:
```csharp
this.mnuToolsUseGpu = new System.Windows.Forms.ToolStripMenuItem();
this.mnuToolsUseGpu.Name = "mnuToolsUseGpu";
this.mnuToolsUseGpu.Text = "Use GPU for Best Fit";
this.mnuToolsUseGpu.CheckOnClick = true;
this.mnuToolsUseGpu.Click += new System.EventHandler(this.UseGpu_Click);
```
Add `this.mnuToolsUseGpu` to the Tools menu's `DropDownItems` array.
**Step 4: Add click handler in MainForm.cs**
```csharp
private void UseGpu_Click(object sender, EventArgs e)
{
// The CheckOnClick property handles toggling automatically
}
```
**Step 5: Pass the flag when creating NestEngine**
Find where `NestEngine` is created in the codebase (likely in auto-nest or fill actions) and set `UseGpu = mnuToolsUseGpu.Checked` on the engine after creation.
This requires reading the code to find the exact creation points. Search for `new NestEngine(` in the codebase.
**Step 6: Build and verify**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeded
**Step 7: Commit**
```bash
git add OpenNest/Forms/MainForm.cs OpenNest/Forms/MainForm.Designer.cs
git commit -m "feat: add Use GPU toggle in Tools menu"
```
---
### Task 7: Smoke test
**Step 1: Run the application**
Run: `dotnet run --project OpenNest/OpenNest.csproj`
**Step 2: Manual verification**
1. Open a nest file with parts
2. Verify the geometry path still works (GPU unchecked) — auto-nest a plate
3. Enable "Use GPU for Best Fit" in Tools menu
4. Auto-nest the same plate with GPU enabled
5. Compare part counts — GPU results should be close to geometry results (not exact due to bitmap approximation)
6. Check Debug output for `[FillWithPairs]` timing differences
**Step 3: Commit any fixes**
If any issues found, fix and commit with appropriate message.