From b55aa7ab4224cd5f2f6a620f95c2ba5381c6b321 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 10 Mar 2026 07:00:21 -0400 Subject: [PATCH] fix: correct GPU overlap detection coordinate system mismatch The GPU pair evaluator reported false-positive overlaps for all candidates because the bitmap coordinate system didn't account for Part.CreateAtOrigin's Location offset. When rotation produced negative coordinates, CreateAtOrigin sets Location = -bbox.Location (non-zero), but the offset formula assumed Location was always (0,0). Two fixes: - Rasterize bitmaps from Part.CreateAtOrigin directly (new FromPart method) instead of separately rotating polygons and computing bbox, eliminating any Polygon.Rotate vs Program.Rotate mismatch - Correct offset formula to include the Location shift: (Part2Offset - partB.Location) instead of raw Part2Offset Also optimized post-kernel bounding computation: pre-compute vertices once per rotation group and process results with Parallel.For, matching the CPU evaluator's concurrency. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Gpu/GpuPairEvaluator.cs | 126 ++++++++++++++++--------------- OpenNest.Gpu/PartBitmap.cs | 102 +++++++++++++++++++++++++ OpenNest/Forms/MainForm.cs | 4 + 3 files changed, 171 insertions(+), 61 deletions(-) diff --git a/OpenNest.Gpu/GpuPairEvaluator.cs b/OpenNest.Gpu/GpuPairEvaluator.cs index bb985c4..17c2871 100644 --- a/OpenNest.Gpu/GpuPairEvaluator.cs +++ b/OpenNest.Gpu/GpuPairEvaluator.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using ILGPU; using ILGPU.Runtime; using OpenNest.Converters; @@ -33,13 +34,17 @@ namespace OpenNest.Gpu if (candidates.Count == 0) return new List(); - // No dilation — candidate positions already include spacing - // (baked in by RotationSlideStrategy via half-spacing offset lines). - var bitmapA = PartBitmap.FromDrawing(_drawing, _cellSize); + // Rasterize A from a Part created at the origin — guarantees the + // bitmap coordinate system exactly matches Part.CreateAtOrigin. + var partA = Part.CreateAtOrigin(_drawing); + var bitmapA = PartBitmap.FromPart(partA, _cellSize); if (bitmapA.Width == 0 || bitmapA.Height == 0) return candidates.Select(c => MakeEmptyResult(c)).ToList(); + // Pre-compute A vertices once for all rotation groups + var verticesA = GetPartVertices(partA); + // Group candidates by Part2Rotation so we rasterize B once per unique rotation var groups = candidates .Select((c, i) => new { Candidate = c, OriginalIndex = i }) @@ -53,8 +58,9 @@ namespace OpenNest.Gpu var rotation = group.Key; var groupItems = group.ToList(); - // Rasterize B at this rotation - var bitmapB = PartBitmap.FromDrawingRotated(_drawing, rotation, _cellSize); + // Rasterize B at this rotation from a Part created at origin + var partB = Part.CreateAtOrigin(_drawing, rotation); + var bitmapB = PartBitmap.FromPart(partB, _cellSize); if (bitmapB.Width == 0 || bitmapB.Height == 0) { @@ -63,6 +69,10 @@ namespace OpenNest.Gpu continue; } + // Pre-compute B vertices at origin for this rotation group + var verticesB = GetPartVertices(partB); + var locationB = partB.Location; + // 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); @@ -70,20 +80,23 @@ namespace OpenNest.Gpu var paddedA = PadBitmap(bitmapA, gridWidth, gridHeight); var paddedB = PadBitmap(bitmapB, gridWidth, gridHeight); - // Pack candidate offsets: convert world offset to cell offset + // The CPU evaluator replaces partB.Location with Part2Offset, + // so the world-space shift from B's bitmap position is + // (Part2Offset - partB.Location). We convert that shift to + // pixel coordinates, adjusting for the bitmap origin difference. var candidateCount = groupItems.Count; - var offsets = new float[candidateCount * 3]; + var offsets = new int[candidateCount * 2]; 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 shiftX = c.Part2Offset.X - locationB.X; + var shiftY = c.Part2Offset.Y - locationB.Y; + offsets[i * 2 + 0] = (int)System.Math.Round((shiftX + bitmapB.OriginX - bitmapA.OriginX) / _cellSize); + offsets[i * 2 + 1] = (int)System.Math.Round((shiftY + bitmapB.OriginY - bitmapA.OriginY) / _cellSize); } - var resultScores = new float[candidateCount]; + var resultScores = new int[candidateCount]; using var gpuPaddedA = _accelerator.Allocate1D(paddedA); using var gpuPaddedB = _accelerator.Allocate1D(paddedB); @@ -94,9 +107,9 @@ namespace OpenNest.Gpu Index1D, ArrayView1D, ArrayView1D, - ArrayView1D, - ArrayView1D, - int, int>(NestingKernel); + ArrayView1D, + ArrayView1D, + int, int>(OverlapKernel); kernel(candidateCount, gpuPaddedA.View, gpuPaddedB.View, gpuOffsets.View, gpuResults.View, gridWidth, gridHeight); @@ -104,12 +117,13 @@ namespace OpenNest.Gpu _accelerator.Synchronize(); gpuResults.CopyToCPU(resultScores); - // Map results back — compute proper bounding metrics for valid candidates - for (var i = 0; i < candidateCount; i++) + // Process results in parallel — pre-computed vertices avoid + // per-candidate Part creation, and Parallel.For matches the + // CPU evaluator's Parallel.ForEach concurrency. + Parallel.For(0, candidateCount, i => { var item = groupItems[i]; - var score = resultScores[i]; - var hasOverlap = score <= 0f; + var hasOverlap = resultScores[i] > 0; if (hasOverlap) { @@ -127,56 +141,53 @@ namespace OpenNest.Gpu } else { - allResults[item.OriginalIndex] = ComputeBoundingResult(item.Candidate, trueArea); + allResults[item.OriginalIndex] = ComputeBoundingResult( + item.Candidate, trueArea, verticesA, verticesB, locationB); } - } + }); } return allResults.ToList(); } - private static void NestingKernel( + /// + /// Overlap kernel using integer cell offsets pre-rounded on the CPU. + /// Shares one A and one B bitmap across all candidates — only the + /// integer offset varies per candidate. + /// + private static void OverlapKernel( Index1D index, ArrayView1D partBitmapA, ArrayView1D partBitmapB, - ArrayView1D candidateOffsets, - ArrayView1D results, + 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 offsetX = candidateOffsets[index * 2]; + var offsetY = candidateOffsets[index * 2 + 1]; 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]; + if (cellA != 1) continue; - // Apply offset to look up part B's cell - var bx = (int)(x - offsetX); - var by = (int)(y - offsetY); + var bx = x - offsetX; + var by = 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 (partBitmapB[by * gridWidth + bx] == 1) + overlapCount++; + } } } - if (overlapCount > 0) - results[index] = 0f; - else - results[index] = (float)totalOccupied / (gridWidth * gridHeight); + results[index] = overlapCount; } private static int[] PadBitmap(PartBitmap bitmap, int targetWidth, int targetHeight) @@ -199,23 +210,17 @@ namespace OpenNest.Gpu private const double ChordTolerance = 0.01; - private BestFitResult ComputeBoundingResult(PairCandidate candidate, double trueArea) + private static BestFitResult ComputeBoundingResult( + PairCandidate candidate, double trueArea, + List verticesA, List verticesB, Vector locationB) { - var part1 = new Part(_drawing); - var bbox1 = part1.Program.BoundingBox(); - part1.Offset(-bbox1.Location.X, -bbox1.Location.Y); - part1.UpdateBounds(); + var shift = candidate.Part2Offset - locationB; - var part2 = new Part(_drawing); - if (!candidate.Part2Rotation.IsEqualTo(0)) - part2.Rotate(candidate.Part2Rotation); - var bbox2 = part2.Program.BoundingBox(); - part2.Offset(-bbox2.Location.X, -bbox2.Location.Y); - part2.Location = candidate.Part2Offset; - part2.UpdateBounds(); + var allPoints = new List(verticesA.Count + verticesB.Count); + allPoints.AddRange(verticesA); - var allPoints = GetPartVertices(part1); - allPoints.AddRange(GetPartVertices(part2)); + foreach (var v in verticesB) + allPoints.Add(v + shift); double bestArea, bestWidth, bestHeight, bestRotation; @@ -230,10 +235,9 @@ namespace OpenNest.Gpu } else { - var combinedBox = ((IEnumerable)new IBoundable[] { part1, part2 }).GetBoundingBox(); - bestArea = combinedBox.Area(); - bestWidth = combinedBox.Width; - bestHeight = combinedBox.Height; + bestArea = 0; + bestWidth = 0; + bestHeight = 0; bestRotation = 0; } diff --git a/OpenNest.Gpu/PartBitmap.cs b/OpenNest.Gpu/PartBitmap.cs index b141280..97b3af3 100644 --- a/OpenNest.Gpu/PartBitmap.cs +++ b/OpenNest.Gpu/PartBitmap.cs @@ -37,6 +37,34 @@ namespace OpenNest.Gpu return Rasterize(polygons, cellSize, spacingDilation); } + /// + /// Rasterizes a Part that was created with Part.CreateAtOrigin. + /// Extracts polygons from the Part's program and offsets by Location, + /// guaranteeing the bitmap matches the exact coordinate space used + /// by the CPU PairEvaluator. + /// + public static PartBitmap FromPart(Part part, double cellSize = DefaultCellSize) + { + var entities = ConvertProgram.ToGeometry(part.Program) + .Where(e => e.Layer != SpecialLayers.Rapid); + var shapes = Helper.GetShapes(entities); + + var polygons = new List(); + + foreach (var shape in shapes) + { + if (!shape.IsClosed()) + continue; + + var polygon = shape.ToPolygonWithTolerance(0.05); + polygon.Close(); + polygon.Offset(part.Location); + polygons.Add(polygon); + } + + return Rasterize(polygons, cellSize, 0); + } + private static PartBitmap Rasterize(List polygons, double cellSize, double spacingDilation) { if (polygons.Count == 0) @@ -126,6 +154,80 @@ namespace OpenNest.Gpu return polygons; } + /// + /// Blits two locally-rasterized bitmaps into a shared world-space grid. + /// B is placed at the given world-space offset. Returns two aligned int[] + /// arrays of identical dimensions with no fractional offset math. + /// + public static (int[] cellsA, int[] cellsB, int width, int height) BlitPair( + PartBitmap bitmapA, PartBitmap bitmapB, double offsetX, double offsetY) + { + var cellSize = bitmapA.CellSize; + + // B's world-space origin when placed at the offset + var bWorldOriginX = bitmapB.OriginX + offsetX; + var bWorldOriginY = bitmapB.OriginY + offsetY; + + // Combined world-space bounds + var combinedMinX = System.Math.Min(bitmapA.OriginX, bWorldOriginX); + var combinedMinY = System.Math.Min(bitmapA.OriginY, bWorldOriginY); + var combinedMaxX = System.Math.Max( + bitmapA.OriginX + bitmapA.Width * cellSize, + bWorldOriginX + bitmapB.Width * cellSize); + var combinedMaxY = System.Math.Max( + bitmapA.OriginY + bitmapA.Height * cellSize, + bWorldOriginY + bitmapB.Height * cellSize); + + var sharedWidth = (int)System.Math.Ceiling((combinedMaxX - combinedMinX) / cellSize); + var sharedHeight = (int)System.Math.Ceiling((combinedMaxY - combinedMinY) / cellSize); + + if (sharedWidth <= 0 || sharedHeight <= 0) + return (Array.Empty(), Array.Empty(), 0, 0); + + var cellsA = new int[sharedWidth * sharedHeight]; + var cellsB = new int[sharedWidth * sharedHeight]; + + // Integer cell offsets for A within the shared grid + var aOffX = (int)System.Math.Round((bitmapA.OriginX - combinedMinX) / cellSize); + var aOffY = (int)System.Math.Round((bitmapA.OriginY - combinedMinY) / cellSize); + + // Integer cell offsets for B within the shared grid + var bOffX = (int)System.Math.Round((bWorldOriginX - combinedMinX) / cellSize); + var bOffY = (int)System.Math.Round((bWorldOriginY - combinedMinY) / cellSize); + + // Blit A into the shared grid + for (var y = 0; y < bitmapA.Height; y++) + { + for (var x = 0; x < bitmapA.Width; x++) + { + if (bitmapA.Cells[y * bitmapA.Width + x] == 1) + { + var sx = aOffX + x; + var sy = aOffY + y; + if (sx >= 0 && sx < sharedWidth && sy >= 0 && sy < sharedHeight) + cellsA[sy * sharedWidth + sx] = 1; + } + } + } + + // Blit B into the shared grid + for (var y = 0; y < bitmapB.Height; y++) + { + for (var x = 0; x < bitmapB.Width; x++) + { + if (bitmapB.Cells[y * bitmapB.Width + x] == 1) + { + var sx = bOffX + x; + var sy = bOffY + y; + if (sx >= 0 && sx < sharedWidth && sy >= 0 && sy < sharedHeight) + cellsB[sy * sharedWidth + sx] = 1; + } + } + } + + return (cellsA, cellsB, sharedWidth, sharedHeight); + } + private static void Dilate(int[] cells, int width, int height, int radius) { var source = (int[])cells.Clone(); diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 1b39959..9a4341c 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Windows.Forms; using OpenNest.Actions; using OpenNest.Collections; +using OpenNest.Engine.BestFit; using OpenNest.Gpu; using OpenNest.Geometry; using OpenNest.IO; @@ -41,6 +42,9 @@ namespace OpenNest.Forms EnableCheck(); UpdateStatus(); UpdateGpuStatus(); + + if (GpuEvaluatorFactory.GpuAvailable) + BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing); } private string GetNestName(DateTime date, int id)