From dd7383467b311405dd88afec937445677bc20f26 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 18:13:12 -0500 Subject: [PATCH 1/9] feat: add Polygon.ContainsPoint using ray casting Co-Authored-By: Claude Opus 4.6 --- OpenNest.Core/Geometry/Polygon.cs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/OpenNest.Core/Geometry/Polygon.cs b/OpenNest.Core/Geometry/Polygon.cs index c6b8d4b..8aa8ac2 100644 --- a/OpenNest.Core/Geometry/Polygon.cs +++ b/OpenNest.Core/Geometry/Polygon.cs @@ -608,5 +608,29 @@ namespace OpenNest.Geometry var hull = ConvexHull.Compute(Vertices); return RotatingCalipers.MinimumBoundingRectangle(hull, startAngle, endAngle); } + + public bool ContainsPoint(Vector pt) + { + var n = IsClosed() ? Vertices.Count - 1 : Vertices.Count; + + if (n < 3) + return false; + + var inside = false; + + for (int 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; + } } } From b83d09c3a7b9dcb51012fdb0d1f3cec6bb4adc54 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 18:19:05 -0500 Subject: [PATCH 2/9] refactor: extract IPairEvaluator interface from PairEvaluator Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/BestFit/BestFitFinder.cs | 20 +++++++++++++------- OpenNest.Engine/BestFit/IPairEvaluator.cs | 9 +++++++++ OpenNest.Engine/BestFit/PairEvaluator.cs | 16 +++++++++++++++- 3 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 OpenNest.Engine/BestFit/IPairEvaluator.cs diff --git a/OpenNest.Engine/BestFit/BestFitFinder.cs b/OpenNest.Engine/BestFit/BestFitFinder.cs index 9aede62..074c31d 100644 --- a/OpenNest.Engine/BestFit/BestFitFinder.cs +++ b/OpenNest.Engine/BestFit/BestFitFinder.cs @@ -1,5 +1,7 @@ +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using OpenNest.Converters; using OpenNest.Engine.BestFit.Tiling; using OpenNest.Geometry; @@ -9,12 +11,12 @@ namespace OpenNest.Engine.BestFit { public class BestFitFinder { - private readonly PairEvaluator _evaluator; + private readonly IPairEvaluator _evaluator; private readonly BestFitFilter _filter; - public BestFitFinder(double maxPlateWidth, double maxPlateHeight) + public BestFitFinder(double maxPlateWidth, double maxPlateHeight, IPairEvaluator evaluator = null) { - _evaluator = new PairEvaluator(); + _evaluator = evaluator ?? new PairEvaluator(); _filter = new BestFitFilter { MaxPlateWidth = maxPlateWidth, @@ -30,12 +32,16 @@ namespace OpenNest.Engine.BestFit { var strategies = BuildStrategies(drawing); - var allCandidates = new List(); + var candidateBags = new ConcurrentBag>(); - foreach (var strategy in strategies) - allCandidates.AddRange(strategy.GenerateCandidates(drawing, spacing, stepSize)); + Parallel.ForEach(strategies, strategy => + { + candidateBags.Add(strategy.GenerateCandidates(drawing, spacing, stepSize)); + }); - var results = allCandidates.Select(c => _evaluator.Evaluate(c)).ToList(); + var allCandidates = candidateBags.SelectMany(c => c).ToList(); + + var results = _evaluator.EvaluateAll(allCandidates); _filter.Apply(results); diff --git a/OpenNest.Engine/BestFit/IPairEvaluator.cs b/OpenNest.Engine/BestFit/IPairEvaluator.cs new file mode 100644 index 0000000..159d3c2 --- /dev/null +++ b/OpenNest.Engine/BestFit/IPairEvaluator.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace OpenNest.Engine.BestFit +{ + public interface IPairEvaluator + { + List EvaluateAll(List candidates); + } +} diff --git a/OpenNest.Engine/BestFit/PairEvaluator.cs b/OpenNest.Engine/BestFit/PairEvaluator.cs index 814a88d..c82df0c 100644 --- a/OpenNest.Engine/BestFit/PairEvaluator.cs +++ b/OpenNest.Engine/BestFit/PairEvaluator.cs @@ -1,15 +1,29 @@ +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using OpenNest.Converters; using OpenNest.Geometry; using OpenNest.Math; namespace OpenNest.Engine.BestFit { - public class PairEvaluator + public class PairEvaluator : IPairEvaluator { private const double ChordTolerance = 0.01; + public List EvaluateAll(List candidates) + { + var resultBag = new ConcurrentBag(); + + Parallel.ForEach(candidates, c => + { + resultBag.Add(Evaluate(c)); + }); + + return resultBag.ToList(); + } + public BestFitResult Evaluate(PairCandidate candidate) { var drawing = candidate.Drawing; From 8d28167b2f4de5d589043d91c930325456a8fb57 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 18:21:04 -0500 Subject: [PATCH 3/9] feat: add OpenNest.Gpu project with PartBitmap rasterizer Introduces the OpenNest.Gpu class library with ILGPU dependencies and a PartBitmap class that rasterizes Drawing closed shapes into integer grids for GPU-based overlap testing. Supports rotation and spacing dilation. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Gpu/OpenNest.Gpu.csproj | 15 +++ OpenNest.Gpu/PartBitmap.cs | 155 +++++++++++++++++++++++++++++++ OpenNest.sln | 42 +++++++++ 3 files changed, 212 insertions(+) create mode 100644 OpenNest.Gpu/OpenNest.Gpu.csproj create mode 100644 OpenNest.Gpu/PartBitmap.cs diff --git a/OpenNest.Gpu/OpenNest.Gpu.csproj b/OpenNest.Gpu/OpenNest.Gpu.csproj new file mode 100644 index 0000000..ba271a1 --- /dev/null +++ b/OpenNest.Gpu/OpenNest.Gpu.csproj @@ -0,0 +1,15 @@ + + + net8.0-windows + OpenNest.Gpu + OpenNest.Gpu + + + + + + + + + + diff --git a/OpenNest.Gpu/PartBitmap.cs b/OpenNest.Gpu/PartBitmap.cs new file mode 100644 index 0000000..b141280 --- /dev/null +++ b/OpenNest.Gpu/PartBitmap.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.Math; + +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 const double DefaultCellSize = 0.05; + + public static PartBitmap FromDrawing(Drawing drawing, double cellSize = DefaultCellSize, double spacingDilation = 0) + { + var polygons = GetClosedPolygons(drawing); + return Rasterize(polygons, cellSize, spacingDilation); + } + + public static PartBitmap FromDrawingRotated(Drawing drawing, double rotation, double cellSize = DefaultCellSize, double spacingDilation = 0) + { + var polygons = GetClosedPolygons(drawing); + + if (!rotation.IsEqualTo(0)) + { + foreach (var poly in polygons) + poly.Rotate(rotation); + } + + return Rasterize(polygons, cellSize, spacingDilation); + } + + private static PartBitmap Rasterize(List polygons, double cellSize, double spacingDilation) + { + if (polygons.Count == 0) + return new PartBitmap { Cells = Array.Empty(), 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; + } + + 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(), Width = 0, Height = 0, CellSize = cellSize }; + + var cells = new int[width * height]; + + 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; + } + } + } + } + + var dilationCells = (int)System.Math.Ceiling(spacingDilation / cellSize); + + 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 GetClosedPolygons(Drawing drawing) + { + var entities = ConvertProgram.ToGeometry(drawing.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(); + 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; + } + } + } + } + } + } +} diff --git a/OpenNest.sln b/OpenNest.sln index d26a394..251ebd6 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -9,24 +9,66 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Core", "OpenNest.C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine", "OpenNest.Engine\OpenNest.Engine.csproj", "{0083B9CC-54AD-4085-A30D-56BC6834B71A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Gpu", "OpenNest.Gpu\OpenNest.Gpu.csproj", "{1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Debug|x64.ActiveCfg = Debug|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Debug|x64.Build.0 = Debug|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Debug|x86.ActiveCfg = Debug|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Debug|x86.Build.0 = Debug|Any CPU {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Release|Any CPU.ActiveCfg = Release|Any CPU {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Release|Any CPU.Build.0 = Release|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Release|x64.ActiveCfg = Release|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Release|x64.Build.0 = Release|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Release|x86.ActiveCfg = Release|Any CPU + {1F1E40E0-5C53-474F-A258-69C9C3FAC15A}.Release|x86.Build.0 = Release|Any CPU {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Debug|x64.Build.0 = Debug|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Debug|x86.Build.0 = Debug|Any CPU {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Release|Any CPU.ActiveCfg = Release|Any CPU {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Release|Any CPU.Build.0 = Release|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Release|x64.ActiveCfg = Release|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Release|x64.Build.0 = Release|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Release|x86.ActiveCfg = Release|Any CPU + {5A5FDE8D-F8DB-440E-866C-C4807E1686CF}.Release|x86.Build.0 = Release|Any CPU {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Debug|x64.ActiveCfg = Debug|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Debug|x64.Build.0 = Debug|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Debug|x86.ActiveCfg = Debug|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Debug|x86.Build.0 = Debug|Any CPU {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Release|Any CPU.ActiveCfg = Release|Any CPU {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Release|Any CPU.Build.0 = Release|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Release|x64.ActiveCfg = Release|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Release|x64.Build.0 = Release|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Release|x86.ActiveCfg = Release|Any CPU + {0083B9CC-54AD-4085-A30D-56BC6834B71A}.Release|x86.Build.0 = Release|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Debug|x64.ActiveCfg = Debug|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Debug|x64.Build.0 = Debug|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Debug|x86.ActiveCfg = Debug|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Debug|x86.Build.0 = Debug|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Release|Any CPU.Build.0 = Release|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Release|x64.ActiveCfg = Release|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Release|x64.Build.0 = Release|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Release|x86.ActiveCfg = Release|Any CPU + {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 1c1508bc9e19b15eaab94219c04d71058f0d4d14 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 18:24:39 -0500 Subject: [PATCH 4/9] 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(); + } + } +} From 5bebfcb6127f9ccac4cf96b208cf0fcaa07651da Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 18:27:15 -0500 Subject: [PATCH 5/9] feat: wire GpuPairEvaluator into NestEngine with auto-detection NestEngine.CreateEvaluator factory delegate allows injection of GPU evaluator from UI layer. GpuEvaluatorFactory.Create attempts GPU, returns null (CPU fallback) if unavailable. All NestEngine call sites wired up. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/NestEngine.cs | 195 +++++++++++++++++++++++++++-- OpenNest/Actions/ActionClone.cs | 1 + OpenNest/Actions/ActionFillArea.cs | 6 +- OpenNest/Forms/MainForm.cs | 29 +++++ OpenNest/GpuEvaluatorFactory.cs | 22 ++++ OpenNest/OpenNest.csproj | 1 + 6 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 OpenNest/GpuEvaluatorFactory.cs diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 97a9c18..8befcf0 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using OpenNest.Converters; using OpenNest.Engine.BestFit; @@ -21,8 +22,12 @@ namespace OpenNest public NestDirection NestDirection { get; set; } + public Func CreateEvaluator { get; set; } + public bool Fill(NestItem item) { + var sw = Stopwatch.StartNew(); + var workArea = Plate.WorkArea(); var bestRotation = FindBestRotation(item); @@ -37,22 +42,27 @@ namespace OpenNest engine.Fill(item.Drawing, bestRotation + Angle.HalfPI, NestDirection.Vertical) }; - // Pick the linear configuration with the most parts. + // Pick the best valid linear configuration. List linearBest = null; foreach (var config in configs) { - if (linearBest == null || config.Count > linearBest.Count) + if (IsBetterValidFill(config, linearBest)) linearBest = config; } + var linearMs = sw.ElapsedMilliseconds; + // Try pair-based approach. var pairResult = FillWithPairs(item); - // Pick whichever produced more parts. + var pairMs = sw.ElapsedMilliseconds - linearMs; + + // Pick whichever is the better fill. + Debug.WriteLine($"[NestEngine.Fill] Linear: {linearBest?.Count ?? 0} parts ({linearMs}ms) | Pair: {pairResult.Count} parts ({pairMs}ms) | WorkArea: {workArea.Width:F1}x{workArea.Height:F1}"); var best = linearBest; - if (pairResult.Count > (best?.Count ?? 0)) + if (IsBetterFill(pairResult, best)) best = pairResult; if (best == null || best.Count == 0) @@ -76,6 +86,15 @@ namespace OpenNest var angles = FindHullEdgeAngles(groupParts); var best = FillPattern(engine, groupParts, angles); + // For single-part groups, also try pair-based filling. + if (groupParts.Count == 1) + { + var pairResult = FillWithPairs(new NestItem { Drawing = groupParts[0].BaseDrawing }); + + if (IsBetterFill(pairResult, best)) + best = pairResult; + } + if (best == null || best.Count == 0) return false; @@ -101,10 +120,20 @@ namespace OpenNest foreach (var config in configs) { - if (best == null || config.Count > best.Count) + if (IsBetterValidFill(config, best)) best = config; } + Debug.WriteLine($"[Fill(NestItem,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1}"); + + // Try pair-based approach. + var pairResult = FillWithPairs(item, workArea); + + Debug.WriteLine($"[Fill(NestItem,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best) ? "Pair" : "Linear")}"); + + if (IsBetterFill(pairResult, best)) + best = pairResult; + if (best == null || best.Count == 0) return false; @@ -124,6 +153,18 @@ namespace OpenNest var angles = FindHullEdgeAngles(groupParts); var best = FillPattern(engine, groupParts, angles); + Debug.WriteLine($"[Fill(groupParts,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1}"); + + if (groupParts.Count == 1) + { + var pairResult = FillWithPairs(new NestItem { Drawing = groupParts[0].BaseDrawing }, workArea); + + Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best) ? "Pair" : "Linear")}"); + + if (IsBetterFill(pairResult, best)) + best = pairResult; + } + if (best == null || best.Count == 0) return false; @@ -223,14 +264,84 @@ namespace OpenNest private List FillWithPairs(NestItem item) { - var finder = new BestFitFinder(Plate.Size.Width, Plate.Size.Height); - var tileResults = finder.FindAndTile(item.Drawing, Plate, Plate.PartSpacing); + return FillWithPairs(item, Plate.WorkArea()); + } - if (tileResults.Count == 0) - return new List(); + private List FillWithPairs(NestItem item, Box workArea) + { + IPairEvaluator evaluator = null; - var bestTile = tileResults[0]; - return ConvertTileResultToParts(bestTile, item.Drawing); + if (CreateEvaluator != null) + { + try { evaluator = CreateEvaluator(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 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 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(); + } + + public static List BuildPairParts(BestFitResult bestFit, Drawing drawing) + { + var candidate = bestFit.Candidate; + + var part1 = new Part(drawing); + var bbox1 = part1.Program.BoundingBox(); + part1.Offset(-bbox1.Location.X, -bbox1.Location.Y); + part1.UpdateBounds(); + + 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(); + + if (!bestFit.OptimalRotation.IsEqualTo(0)) + { + var pairBounds = ((IEnumerable)new IBoundable[] { part1, part2 }).GetBoundingBox(); + var center = pairBounds.Center; + part1.Rotate(-bestFit.OptimalRotation, center); + part2.Rotate(-bestFit.OptimalRotation, center); + } + + var finalBounds = ((IEnumerable)new IBoundable[] { part1, part2 }).GetBoundingBox(); + var offset = new Vector(-finalBounds.Left, -finalBounds.Bottom); + part1.Offset(offset); + part2.Offset(offset); + + return new List { part1, part2 }; } private List ConvertTileResultToParts(TileResult tileResult, Drawing drawing) @@ -295,6 +406,64 @@ namespace OpenNest return parts; } + private bool HasOverlaps(List parts, double spacing) + { + if (parts == null || parts.Count <= 1) + return false; + + for (var i = 0; i < parts.Count; i++) + { + for (var j = i + 1; j < parts.Count; j++) + { + List pts; + + if (parts[i].Intersects(parts[j], out pts)) + return true; + } + } + + return false; + } + + private bool IsBetterFill(List candidate, List current) + { + if (candidate == null || candidate.Count == 0) + return false; + + if (current == null || current.Count == 0) + return true; + + if (candidate.Count != current.Count) + return candidate.Count > current.Count; + + // Same count: prefer smaller bounding box (more compact). + var candidateBox = ((IEnumerable)candidate).GetBoundingBox(); + var currentBox = ((IEnumerable)current).GetBoundingBox(); + + return candidateBox.Area() < currentBox.Area(); + } + + private bool IsBetterValidFill(List candidate, List current) + { + if (candidate == null || candidate.Count == 0) + return false; + + // Reject candidate if it has overlapping parts. + if (HasOverlaps(candidate, Plate.PartSpacing)) + return false; + + if (current == null || current.Count == 0) + return true; + + if (candidate.Count != current.Count) + return candidate.Count > current.Count; + + var candidateBox = ((IEnumerable)candidate).GetBoundingBox(); + var currentBox = ((IEnumerable)current).GetBoundingBox(); + + return candidateBox.Area() < currentBox.Area(); + } + private List FindHullEdgeAngles(List parts) { var points = new List(); @@ -376,10 +545,10 @@ namespace OpenNest var h = engine.Fill(pattern, NestDirection.Horizontal); var v = engine.Fill(pattern, NestDirection.Vertical); - if (best == null || h.Count > best.Count) + if (IsBetterValidFill(h, best)) best = h; - if (best == null || v.Count > best.Count) + if (IsBetterValidFill(v, best)) best = v; } diff --git a/OpenNest/Actions/ActionClone.cs b/OpenNest/Actions/ActionClone.cs index 5879048..c0755a3 100644 --- a/OpenNest/Actions/ActionClone.cs +++ b/OpenNest/Actions/ActionClone.cs @@ -172,6 +172,7 @@ namespace OpenNest.Actions { var plate = plateView.Plate; var engine = new NestEngine(plate); + engine.CreateEvaluator = GpuEvaluatorFactory.Create; var groupParts = parts.Select(p => p.BasePart).ToList(); var bounds = plate.WorkArea(); diff --git a/OpenNest/Actions/ActionFillArea.cs b/OpenNest/Actions/ActionFillArea.cs index ba64af2..c17e39f 100644 --- a/OpenNest/Actions/ActionFillArea.cs +++ b/OpenNest/Actions/ActionFillArea.cs @@ -25,10 +25,8 @@ namespace OpenNest.Actions private void FillArea() { var engine = new NestEngine(plateView.Plate); - engine.FillArea(SelectedArea, new NestItem - { - Drawing = drawing - }); + engine.CreateEvaluator = GpuEvaluatorFactory.Create; + engine.Fill(new NestItem { Drawing = drawing }, SelectedArea); plateView.Invalidate(); Update(); diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 16adabe..544da59 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -501,6 +501,33 @@ namespace OpenNest.Forms activeForm.PlateView.SetAction(typeof(ActionSelectArea)); } + private void BestFitViewer_Click(object sender, EventArgs e) + { + if (activeForm == null) + return; + + var plate = activeForm.PlateView.Plate; + var drawing = activeForm.Nest.Drawings.Count > 0 + ? activeForm.Nest.Drawings.First() + : null; + + if (drawing == null) + { + MessageBox.Show("No drawings available.", "Best-Fit Viewer", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + using (var form = new BestFitViewerForm(drawing, plate)) + { + if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null) + { + var parts = NestEngine.BuildPairParts(form.SelectedResult, drawing); + activeForm.PlateView.SetAction(typeof(ActionClone), parts); + } + } + } + private void SetOffsetIncrement_Click(object sender, EventArgs e) { if (activeForm == null) return; @@ -645,6 +672,7 @@ namespace OpenNest.Forms : activeForm.PlateView.Plate; var engine = new NestEngine(plate); + engine.CreateEvaluator = GpuEvaluatorFactory.Create; if (!engine.Pack(items)) break; @@ -718,6 +746,7 @@ namespace OpenNest.Forms return; var engine = new NestEngine(activeForm.PlateView.Plate); + engine.CreateEvaluator = GpuEvaluatorFactory.Create; engine.Fill(new NestItem { Drawing = drawing diff --git a/OpenNest/GpuEvaluatorFactory.cs b/OpenNest/GpuEvaluatorFactory.cs new file mode 100644 index 0000000..4993ac3 --- /dev/null +++ b/OpenNest/GpuEvaluatorFactory.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; +using OpenNest.Engine.BestFit; +using OpenNest.Gpu; + +namespace OpenNest +{ + internal static class GpuEvaluatorFactory + { + public static IPairEvaluator Create(Drawing drawing, double spacing) + { + try + { + return new GpuPairEvaluator(drawing, spacing); + } + catch + { + Debug.WriteLine("[GpuEvaluatorFactory] GPU not available, falling back to CPU"); + return null; + } + } + } +} diff --git a/OpenNest/OpenNest.csproj b/OpenNest/OpenNest.csproj index d3a7593..4eafc18 100644 --- a/OpenNest/OpenNest.csproj +++ b/OpenNest/OpenNest.csproj @@ -13,6 +13,7 @@ + From 7ed4572f8a14351df15d9bd1bcbd953758c46b45 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 18:33:10 -0500 Subject: [PATCH 6/9] feat: add GPU status indicator and device probe - GpuEvaluatorFactory probes for CUDA/OpenCL devices at startup - Status bar shows "GPU : " (green) or "GPU : None (CPU)" (gray) - Factory skips GPU evaluator creation entirely when no device found - Logs actual exception message on failure for debugging Co-Authored-By: Claude Opus 4.6 --- OpenNest/Forms/MainForm.Designer.cs | 26 +++++++++++-- OpenNest/Forms/MainForm.cs | 15 ++++++++ OpenNest/GpuEvaluatorFactory.cs | 59 ++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index 87218d5..e32d58d 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -59,6 +59,7 @@ this.mnuViewZoomIn = new System.Windows.Forms.ToolStripMenuItem(); this.mnuViewZoomOut = new System.Windows.Forms.ToolStripMenuItem(); this.mnuTools = new System.Windows.Forms.ToolStripMenuItem(); + this.mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem(); this.mnuToolsMeasureArea = new System.Windows.Forms.ToolStripMenuItem(); this.mnuToolsAlign = new System.Windows.Forms.ToolStripMenuItem(); this.mnuToolsAlignLeft = new System.Windows.Forms.ToolStripMenuItem(); @@ -129,6 +130,7 @@ this.plateIndexStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); this.plateSizeStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); this.plateQtyStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + this.gpuStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); this.toolStrip1 = new System.Windows.Forms.ToolStrip(); this.btnNew = new System.Windows.Forms.ToolStripButton(); this.btnOpen = new System.Windows.Forms.ToolStripButton(); @@ -412,6 +414,7 @@ // this.mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { this.mnuToolsMeasureArea, + this.mnuToolsBestFitViewer, this.mnuToolsAlign, this.toolStripMenuItem14, this.mnuSetOffsetIncrement, @@ -428,7 +431,14 @@ this.mnuToolsMeasureArea.Size = new System.Drawing.Size(214, 22); this.mnuToolsMeasureArea.Text = "Measure Area"; this.mnuToolsMeasureArea.Click += new System.EventHandler(this.MeasureArea_Click); - // + // + // mnuToolsBestFitViewer + // + this.mnuToolsBestFitViewer.Name = "mnuToolsBestFitViewer"; + this.mnuToolsBestFitViewer.Size = new System.Drawing.Size(214, 22); + this.mnuToolsBestFitViewer.Text = "Best-Fit Viewer"; + this.mnuToolsBestFitViewer.Click += new System.EventHandler(this.BestFitViewer_Click); + // // mnuToolsAlign // this.mnuToolsAlign.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { @@ -909,7 +919,8 @@ this.spacerLabel, this.plateIndexStatusLabel, this.plateSizeStatusLabel, - this.plateQtyStatusLabel}); + this.plateQtyStatusLabel, + this.gpuStatusLabel}); this.statusStrip1.Location = new System.Drawing.Point(0, 543); this.statusStrip1.Name = "statusStrip1"; this.statusStrip1.Size = new System.Drawing.Size(1098, 24); @@ -961,7 +972,14 @@ this.plateQtyStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); this.plateQtyStatusLabel.Size = new System.Drawing.Size(55, 19); this.plateQtyStatusLabel.Text = "Qty : 0"; - // + // + // gpuStatusLabel + // + this.gpuStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + this.gpuStatusLabel.Name = "gpuStatusLabel"; + this.gpuStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + this.gpuStatusLabel.Size = new System.Drawing.Size(55, 19); + // // toolStrip1 // this.toolStrip1.AutoSize = false; @@ -1287,7 +1305,9 @@ private System.Windows.Forms.ToolStripMenuItem manualSequenceToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem autoSequenceAllPlatesToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem mnuToolsMeasureArea; + private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer; private System.Windows.Forms.ToolStripButton btnSaveAs; private System.Windows.Forms.ToolStripMenuItem centerPartsToolStripMenuItem; + private System.Windows.Forms.ToolStripStatusLabel gpuStatusLabel; } } \ No newline at end of file diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 544da59..99918fa 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -39,6 +39,7 @@ namespace OpenNest.Forms LoadPosts(); EnableCheck(); UpdateStatus(); + UpdateGpuStatus(); } private string GetNestName(DateTime date, int id) @@ -191,6 +192,20 @@ namespace OpenNest.Forms UpdatePlateStatus(); } + private void UpdateGpuStatus() + { + if (GpuEvaluatorFactory.GpuAvailable) + { + gpuStatusLabel.Text = $"GPU : {GpuEvaluatorFactory.DeviceName}"; + gpuStatusLabel.ForeColor = Color.DarkGreen; + } + else + { + gpuStatusLabel.Text = "GPU : None (CPU)"; + gpuStatusLabel.ForeColor = Color.Gray; + } + } + private void UpdateLocationMode() { if (activeForm == null) diff --git a/OpenNest/GpuEvaluatorFactory.cs b/OpenNest/GpuEvaluatorFactory.cs index 4993ac3..ca94ef8 100644 --- a/OpenNest/GpuEvaluatorFactory.cs +++ b/OpenNest/GpuEvaluatorFactory.cs @@ -1,4 +1,7 @@ +using System; using System.Diagnostics; +using ILGPU; +using ILGPU.Runtime; using OpenNest.Engine.BestFit; using OpenNest.Gpu; @@ -6,17 +9,69 @@ namespace OpenNest { internal static class GpuEvaluatorFactory { + private static bool _probed; + private static bool _gpuAvailable; + private static string _deviceName; + + public static bool GpuAvailable + { + get + { + if (!_probed) Probe(); + return _gpuAvailable; + } + } + + public static string DeviceName + { + get + { + if (!_probed) Probe(); + return _deviceName ?? "None"; + } + } + public static IPairEvaluator Create(Drawing drawing, double spacing) { + if (!GpuAvailable) + return null; + try { return new GpuPairEvaluator(drawing, spacing); } - catch + catch (Exception ex) { - Debug.WriteLine("[GpuEvaluatorFactory] GPU not available, falling back to CPU"); + Debug.WriteLine($"[GpuEvaluatorFactory] GPU evaluator failed: {ex.Message}"); return null; } } + + private static void Probe() + { + _probed = true; + + try + { + using var context = Context.CreateDefault(); + foreach (var device in context.Devices) + { + if (device.AcceleratorType == AcceleratorType.Cuda || + device.AcceleratorType == AcceleratorType.OpenCL) + { + _gpuAvailable = true; + _deviceName = device.Name; + Debug.WriteLine($"[GpuEvaluatorFactory] GPU found: {device.Name} ({device.AcceleratorType})"); + return; + } + } + + Debug.WriteLine("[GpuEvaluatorFactory] No GPU device found"); + } + catch (Exception ex) + { + Debug.WriteLine($"[GpuEvaluatorFactory] GPU probe failed: {ex.Message}"); + } + } } } From 73c20c30ce877ecf85cd6b68ab395ec440642848 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 18:52:35 -0500 Subject: [PATCH 7/9] fix: exclude rapid moves from Part.Intersects to fix FillLinear rejection Part.Intersects included rapid move geometry (G00 traversals) when checking for overlaps, causing false positives. The overlap validation added in 5bebfcb rejected all FillLinear configs, producing 0 parts. Every other GetShapes caller already filters SpecialLayers.Rapid. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Core/Part.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index ac1e523..e88a60c 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using OpenNest.CNC; using OpenNest.Converters; using OpenNest.Geometry; @@ -130,8 +131,10 @@ namespace OpenNest { pts = new List(); - var entities1 = ConvertProgram.ToGeometry(Program); - var entities2 = ConvertProgram.ToGeometry(part.Program); + var entities1 = ConvertProgram.ToGeometry(Program) + .Where(e => e.Layer != SpecialLayers.Rapid); + var entities2 = ConvertProgram.ToGeometry(part.Program) + .Where(e => e.Layer != SpecialLayers.Rapid); var shapes1 = Helper.GetShapes(entities1); var shapes2 = Helper.GetShapes(entities2); From 0c14d7f854047a4568012e3735b8a7b7da8d866a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 19:24:37 -0500 Subject: [PATCH 8/9] fix: align best-fit sweep to include offset=0 for grid arrangements The perpendicular sweep started at perpMin (e.g. -8.75) which with coarse step sizes never landed on offset=0, missing the perfect side-by-side and stacked same-orientation patterns for rectangular parts. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/BestFit/RotationSlideStrategy.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs index 2447213..8cb6162 100644 --- a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs +++ b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs @@ -80,7 +80,12 @@ namespace OpenNest.Engine.BestFit // Pre-compute part1's offset lines (half-spacing outward) var part1Lines = Helper.GetOffsetPartLines(part1, halfSpacing); - for (var offset = perpMin; offset <= perpMax; offset += stepSize) + // 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(); From 90b89a5dfa6bef438ddeb834a4798fbaedd733ea Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Mar 2026 20:00:38 -0500 Subject: [PATCH 9/9] feat: add FillRectangleBestFit strategy and remove false overlap rejection - Remove IsBetterValidFill overlap gate for FillLinear results; the geometry-aware spacing in FillLinear is sufficient and the overlap check produced false positives on parts with arcs/curves, causing valid grid layouts to be rejected in favor of inferior pair fills. - Add FillRectangleBestFit strategy that uses BestCombination to mix normal and rotated orientations, filling remnant strips for higher part counts on rectangular parts. - All Fill overloads now compare linear, rectangle best-fit, and pair-based strategies, picking whichever yields the most parts. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/NestEngine.cs | 68 ++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 8befcf0..db809e7 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -42,26 +42,36 @@ namespace OpenNest engine.Fill(item.Drawing, bestRotation + Angle.HalfPI, NestDirection.Vertical) }; - // Pick the best valid linear configuration. + // Pick the best linear configuration. FillLinear already ensures + // geometry-aware spacing, so skip the redundant overlap check that + // can produce false positives on arcs/curves. List linearBest = null; foreach (var config in configs) { - if (IsBetterValidFill(config, linearBest)) + if (IsBetterFill(config, linearBest)) linearBest = config; } var linearMs = sw.ElapsedMilliseconds; + // Try rectangle best-fit (mixes orientations to fill remnant strips). + var rectResult = FillRectangleBestFit(item, workArea); + + var rectMs = sw.ElapsedMilliseconds - linearMs; + // Try pair-based approach. var pairResult = FillWithPairs(item); - var pairMs = sw.ElapsedMilliseconds - linearMs; + var pairMs = sw.ElapsedMilliseconds - linearMs - rectMs; // Pick whichever is the better fill. - Debug.WriteLine($"[NestEngine.Fill] Linear: {linearBest?.Count ?? 0} parts ({linearMs}ms) | Pair: {pairResult.Count} parts ({pairMs}ms) | WorkArea: {workArea.Width:F1}x{workArea.Height:F1}"); + Debug.WriteLine($"[NestEngine.Fill] Linear: {linearBest?.Count ?? 0} parts ({linearMs}ms) | Rect: {rectResult?.Count ?? 0} parts ({rectMs}ms) | Pair: {pairResult.Count} parts ({pairMs}ms) | WorkArea: {workArea.Width:F1}x{workArea.Height:F1}"); var best = linearBest; + if (IsBetterFill(rectResult, best)) + best = rectResult; + if (IsBetterFill(pairResult, best)) best = pairResult; @@ -86,10 +96,16 @@ namespace OpenNest var angles = FindHullEdgeAngles(groupParts); var best = FillPattern(engine, groupParts, angles); - // For single-part groups, also try pair-based filling. + // For single-part groups, also try rectangle best-fit and pair-based filling. if (groupParts.Count == 1) { - var pairResult = FillWithPairs(new NestItem { Drawing = groupParts[0].BaseDrawing }); + var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing }; + var rectResult = FillRectangleBestFit(nestItem, workArea); + + if (IsBetterFill(rectResult, best)) + best = rectResult; + + var pairResult = FillWithPairs(nestItem); if (IsBetterFill(pairResult, best)) best = pairResult; @@ -120,12 +136,20 @@ namespace OpenNest foreach (var config in configs) { - if (IsBetterValidFill(config, best)) + if (IsBetterFill(config, best)) best = config; } Debug.WriteLine($"[Fill(NestItem,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1}"); + // Try rectangle best-fit (mixes orientations to fill remnant strips). + var rectResult = FillRectangleBestFit(item, workArea); + + Debug.WriteLine($"[Fill(NestItem,Box)] RectBestFit: {rectResult?.Count ?? 0} parts"); + + if (IsBetterFill(rectResult, best)) + best = rectResult; + // Try pair-based approach. var pairResult = FillWithPairs(item, workArea); @@ -157,7 +181,15 @@ namespace OpenNest if (groupParts.Count == 1) { - var pairResult = FillWithPairs(new NestItem { Drawing = groupParts[0].BaseDrawing }, workArea); + var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing }; + var rectResult = FillRectangleBestFit(nestItem, workArea); + + Debug.WriteLine($"[Fill(groupParts,Box)] RectBestFit: {rectResult?.Count ?? 0} parts"); + + if (IsBetterFill(rectResult, best)) + best = rectResult; + + var pairResult = FillWithPairs(nestItem, workArea); Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best) ? "Pair" : "Linear")}"); @@ -262,6 +294,26 @@ namespace OpenNest return parts.Count > 0; } + private List FillRectangleBestFit(NestItem item, Box workArea) + { + var binItem = ConvertToRectangleItem(item); + + var bin = new Bin + { + Location = workArea.Location, + Size = workArea.Size + }; + + bin.Width += Plate.PartSpacing; + bin.Height += Plate.PartSpacing; + + var engine = new FillBestFit(bin); + engine.Fill(binItem); + + var nestItems = new List { item }; + return ConvertToParts(bin, nestItems); + } + private List FillWithPairs(NestItem item) { return FillWithPairs(item, Plate.WorkArea());