From b509a4139dc1c1ab0384e5b2c3d7bcbf5a46bc59 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 20:29:36 -0400 Subject: [PATCH 001/116] refactor: optimize directional distance for GPU-friendly batching Add primitive-parameter RayEdgeDistance overload with AggressiveInlining, merge Left/Right and Up/Down cases. Add DirectionalDistance overload that accepts (dx, dy) translation without creating Line objects, and FlattenLines for packing segments into flat arrays for GPU transfer. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Core/Helper.cs | 142 +++++++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 46 deletions(-) diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index 5f0da8d..c2c23af 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -863,81 +863,55 @@ namespace OpenNest /// private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction) { - var p1x = edge.pt1.X; - var p1y = edge.pt1.Y; - var p2x = edge.pt2.X; - var p2y = edge.pt2.Y; + return RayEdgeDistance( + vertex.X, vertex.Y, + edge.pt1.X, edge.pt1.Y, edge.pt2.X, edge.pt2.Y, + direction); + } + [System.Runtime.CompilerServices.MethodImpl( + System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static double RayEdgeDistance( + double vx, double vy, + double p1x, double p1y, double p2x, double p2y, + PushDirection direction) + { switch (direction) { case PushDirection.Left: - { - // Ray goes in -X direction. Need non-horizontal edge. - var dy = p2y - p1y; - if (dy > -Tolerance.Epsilon && dy < Tolerance.Epsilon) - return double.MaxValue; // horizontal edge, parallel to ray - - var t = (vertex.Y - p1y) / dy; - if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) - return double.MaxValue; - - var ix = p1x + t * (p2x - p1x); - var dist = vertex.X - ix; // positive if edge is to the left - if (dist > Tolerance.Epsilon) return dist; - if (dist >= -Tolerance.Epsilon) return 0; // touching - return double.MaxValue; // edge is behind vertex - } - case PushDirection.Right: { var dy = p2y - p1y; if (dy > -Tolerance.Epsilon && dy < Tolerance.Epsilon) return double.MaxValue; - var t = (vertex.Y - p1y) / dy; + var t = (vy - p1y) / dy; if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) return double.MaxValue; var ix = p1x + t * (p2x - p1x); - var dist = ix - vertex.X; + var dist = direction == PushDirection.Left ? vx - ix : ix - vx; if (dist > Tolerance.Epsilon) return dist; - if (dist >= -Tolerance.Epsilon) return 0; // touching - return double.MaxValue; // edge is behind vertex + if (dist >= -Tolerance.Epsilon) return 0; + return double.MaxValue; } case PushDirection.Down: - { - // Ray goes in -Y direction. Need non-vertical edge. - var dx = p2x - p1x; - if (dx > -Tolerance.Epsilon && dx < Tolerance.Epsilon) - return double.MaxValue; // vertical edge, parallel to ray - - var t = (vertex.X - p1x) / dx; - if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) - return double.MaxValue; - - var iy = p1y + t * (p2y - p1y); - var dist = vertex.Y - iy; - if (dist > Tolerance.Epsilon) return dist; - if (dist >= -Tolerance.Epsilon) return 0; // touching - return double.MaxValue; // edge is behind vertex - } - case PushDirection.Up: { var dx = p2x - p1x; if (dx > -Tolerance.Epsilon && dx < Tolerance.Epsilon) return double.MaxValue; - var t = (vertex.X - p1x) / dx; + var t = (vx - p1x) / dx; if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) return double.MaxValue; var iy = p1y + t * (p2y - p1y); - var dist = iy - vertex.Y; + var dist = direction == PushDirection.Down ? vy - iy : iy - vy; if (dist > Tolerance.Epsilon) return dist; - if (dist >= -Tolerance.Epsilon) return 0; // touching - return double.MaxValue; // edge is behind vertex + if (dist >= -Tolerance.Epsilon) return 0; + return double.MaxValue; } default: @@ -991,6 +965,82 @@ namespace OpenNest return minDist; } + /// + /// Computes the minimum directional distance with the moving lines translated + /// by (movingDx, movingDy) without creating new Line objects. + /// + public static double DirectionalDistance( + List movingLines, double movingDx, double movingDy, + List stationaryLines, PushDirection direction) + { + var minDist = double.MaxValue; + + // Case 1: Each moving vertex → each stationary edge + for (int i = 0; i < movingLines.Count; i++) + { + var ml = movingLines[i]; + var mx1 = ml.pt1.X + movingDx; + var my1 = ml.pt1.Y + movingDy; + var mx2 = ml.pt2.X + movingDx; + var my2 = ml.pt2.Y + movingDy; + + for (int j = 0; j < stationaryLines.Count; j++) + { + var se = stationaryLines[j]; + var d = RayEdgeDistance(mx1, my1, se.pt1.X, se.pt1.Y, se.pt2.X, se.pt2.Y, direction); + if (d < minDist) minDist = d; + + d = RayEdgeDistance(mx2, my2, se.pt1.X, se.pt1.Y, se.pt2.X, se.pt2.Y, direction); + if (d < minDist) minDist = d; + } + } + + // Case 2: Each stationary vertex → each moving edge (opposite direction) + var opposite = OppositeDirection(direction); + + for (int i = 0; i < stationaryLines.Count; i++) + { + var sl = stationaryLines[i]; + + for (int j = 0; j < movingLines.Count; j++) + { + var me = movingLines[j]; + var d = RayEdgeDistance( + sl.pt1.X, sl.pt1.Y, + me.pt1.X + movingDx, me.pt1.Y + movingDy, + me.pt2.X + movingDx, me.pt2.Y + movingDy, + opposite); + if (d < minDist) minDist = d; + + d = RayEdgeDistance( + sl.pt2.X, sl.pt2.Y, + me.pt1.X + movingDx, me.pt1.Y + movingDy, + me.pt2.X + movingDx, me.pt2.Y + movingDy, + opposite); + if (d < minDist) minDist = d; + } + } + + return minDist; + } + + /// + /// Packs line segments into a flat double array [x1,y1,x2,y2, ...] for GPU transfer. + /// + public static double[] FlattenLines(List lines) + { + var result = new double[lines.Count * 4]; + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i]; + result[i * 4] = line.pt1.X; + result[i * 4 + 1] = line.pt1.Y; + result[i * 4 + 2] = line.pt2.X; + result[i * 4 + 3] = line.pt2.Y; + } + return result; + } + public static PushDirection OppositeDirection(PushDirection direction) { switch (direction) From 97dfe27953223f292f07e616e2dc3855044a4036 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 20:29:43 -0400 Subject: [PATCH 002/116] feat: add ISlideComputer interface and GPU implementation ISlideComputer abstracts batched directional-distance computation so GPU implementations can process all slide offsets in a single kernel launch. GpuSlideComputer uses ILGPU with prepared edge data (precomputed inverse deltas and min/max bounds) and caches stationary/moving buffers across calls. GpuEvaluatorFactory exposes a singleton factory method. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/BestFit/ISlideComputer.cs | 38 ++ OpenNest.Gpu/GpuEvaluatorFactory.cs | 25 ++ OpenNest.Gpu/GpuSlideComputer.cs | 460 ++++++++++++++++++++++ 3 files changed, 523 insertions(+) create mode 100644 OpenNest.Engine/BestFit/ISlideComputer.cs create mode 100644 OpenNest.Gpu/GpuSlideComputer.cs diff --git a/OpenNest.Engine/BestFit/ISlideComputer.cs b/OpenNest.Engine/BestFit/ISlideComputer.cs new file mode 100644 index 0000000..9096f9a --- /dev/null +++ b/OpenNest.Engine/BestFit/ISlideComputer.cs @@ -0,0 +1,38 @@ +using System; + +namespace OpenNest.Engine.BestFit +{ + /// + /// Batches directional-distance computations for multiple offset positions. + /// GPU implementations can process all offsets in a single kernel launch. + /// + public interface ISlideComputer : IDisposable + { + /// + /// Computes the minimum directional distance for each offset position. + /// + /// Flat array [x1,y1,x2,y2, ...] for stationary edges. + /// Number of line segments in stationarySegments. + /// Flat array [x1,y1,x2,y2, ...] for moving edges at origin. + /// Number of line segments in movingTemplateSegments. + /// Flat array [dx,dy, dx,dy, ...] of translation offsets. + /// Number of offset positions. + /// Push direction. + /// Array of minimum distances, one per offset position. + double[] ComputeBatch( + double[] stationarySegments, int stationaryCount, + double[] movingTemplateSegments, int movingCount, + double[] offsets, int offsetCount, + PushDirection direction); + + /// + /// Computes minimum directional distance for offsets with per-offset directions. + /// Uploads segment data once for all offsets, reducing GPU round-trips. + /// + double[] ComputeBatchMultiDir( + double[] stationarySegments, int stationaryCount, + double[] movingTemplateSegments, int movingCount, + double[] offsets, int offsetCount, + int[] directions); + } +} diff --git a/OpenNest.Gpu/GpuEvaluatorFactory.cs b/OpenNest.Gpu/GpuEvaluatorFactory.cs index e69edb2..9ee04f4 100644 --- a/OpenNest.Gpu/GpuEvaluatorFactory.cs +++ b/OpenNest.Gpu/GpuEvaluatorFactory.cs @@ -11,6 +11,8 @@ namespace OpenNest.Gpu private static bool _probed; private static bool _gpuAvailable; private static string _deviceName; + private static GpuSlideComputer _slideComputer; + private static readonly object _slideLock = new object(); public static bool GpuAvailable { @@ -46,6 +48,29 @@ namespace OpenNest.Gpu } } + public static ISlideComputer CreateSlideComputer() + { + if (!GpuAvailable) + return null; + + lock (_slideLock) + { + if (_slideComputer != null) + return _slideComputer; + + try + { + _slideComputer = new GpuSlideComputer(); + return _slideComputer; + } + catch (Exception ex) + { + Debug.WriteLine($"[GpuEvaluatorFactory] GPU slide computer failed: {ex.Message}"); + return null; + } + } + } + private static void Probe() { _probed = true; diff --git a/OpenNest.Gpu/GpuSlideComputer.cs b/OpenNest.Gpu/GpuSlideComputer.cs new file mode 100644 index 0000000..b6c2ea0 --- /dev/null +++ b/OpenNest.Gpu/GpuSlideComputer.cs @@ -0,0 +1,460 @@ +using System; +using ILGPU; +using ILGPU.Runtime; +using ILGPU.Algorithms; +using OpenNest.Engine.BestFit; + +namespace OpenNest.Gpu +{ + public class GpuSlideComputer : ISlideComputer + { + private readonly Context _context; + private readonly Accelerator _accelerator; + private readonly object _lock = new object(); + + // ── Kernels ────────────────────────────────────────────────── + + private readonly Action, // stationaryPrep + ArrayView1D, // movingPrep + ArrayView1D, // offsets + ArrayView1D, // results + int, int, int> _kernel; + + private readonly Action, // stationaryPrep + ArrayView1D, // movingPrep + ArrayView1D, // offsets + ArrayView1D, // results + ArrayView1D, // directions + int, int> _kernelMultiDir; + + private readonly Action, // raw + ArrayView1D, // prepared + int> _prepareKernel; + + // ── Buffers ────────────────────────────────────────────────── + + private MemoryBuffer1D? _gpuStationaryRaw; + private MemoryBuffer1D? _gpuStationaryPrep; + private double[]? _lastStationaryData; // Keep CPU copy/ref for content check + + private MemoryBuffer1D? _gpuMovingRaw; + private MemoryBuffer1D? _gpuMovingPrep; + private double[]? _lastMovingData; // Keep CPU copy/ref for content check + + private MemoryBuffer1D? _gpuOffsets; + private MemoryBuffer1D? _gpuResults; + private MemoryBuffer1D? _gpuDirs; + private int _offsetCapacity; + + public GpuSlideComputer() + { + _context = Context.CreateDefault(); + _accelerator = _context.GetPreferredDevice(preferCPU: false) + .CreateAccelerator(_context); + + _kernel = _accelerator.LoadAutoGroupedStreamKernel< + Index1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + int, int, int>(SlideKernel); + + _kernelMultiDir = _accelerator.LoadAutoGroupedStreamKernel< + Index1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + int, int>(SlideKernelMultiDir); + + _prepareKernel = _accelerator.LoadAutoGroupedStreamKernel< + Index1D, + ArrayView1D, + ArrayView1D, + int>(PrepareKernel); + } + + public double[] ComputeBatch( + double[] stationarySegments, int stationaryCount, + double[] movingTemplateSegments, int movingCount, + double[] offsets, int offsetCount, + PushDirection direction) + { + var results = new double[offsetCount]; + if (offsetCount == 0 || stationaryCount == 0 || movingCount == 0) + { + Array.Fill(results, double.MaxValue); + return results; + } + + lock (_lock) + { + EnsureStationary(stationarySegments, stationaryCount); + EnsureMoving(movingTemplateSegments, movingCount); + EnsureOffsetBuffers(offsetCount); + + _gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets); + + _kernel(offsetCount, + _gpuStationaryPrep!.View, _gpuMovingPrep!.View, + _gpuOffsets.View, _gpuResults!.View, + stationaryCount, movingCount, (int)direction); + + _accelerator.Synchronize(); + _gpuResults.View.SubView(0, offsetCount).CopyToCPU(results); + } + + return results; + } + + public double[] ComputeBatchMultiDir( + double[] stationarySegments, int stationaryCount, + double[] movingTemplateSegments, int movingCount, + double[] offsets, int offsetCount, + int[] directions) + { + var results = new double[offsetCount]; + if (offsetCount == 0 || stationaryCount == 0 || movingCount == 0) + { + Array.Fill(results, double.MaxValue); + return results; + } + + lock (_lock) + { + EnsureStationary(stationarySegments, stationaryCount); + EnsureMoving(movingTemplateSegments, movingCount); + EnsureOffsetBuffers(offsetCount); + + _gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets); + _gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(directions); + + _kernelMultiDir(offsetCount, + _gpuStationaryPrep!.View, _gpuMovingPrep!.View, + _gpuOffsets.View, _gpuResults!.View, _gpuDirs.View, + stationaryCount, movingCount); + + _accelerator.Synchronize(); + _gpuResults.View.SubView(0, offsetCount).CopyToCPU(results); + } + + return results; + } + + public void InvalidateStationary() => _lastStationaryData = null; + public void InvalidateMoving() => _lastMovingData = null; + + private void EnsureStationary(double[] data, int count) + { + // Fast check: if same object or content is identical, skip upload + if (_gpuStationaryPrep != null && + _lastStationaryData != null && + _lastStationaryData.Length == data.Length) + { + // Reference equality or content equality + if (_lastStationaryData == data || + new ReadOnlySpan(_lastStationaryData).SequenceEqual(new ReadOnlySpan(data))) + { + return; + } + } + + _gpuStationaryRaw?.Dispose(); + _gpuStationaryPrep?.Dispose(); + + _gpuStationaryRaw = _accelerator.Allocate1D(data); + _gpuStationaryPrep = _accelerator.Allocate1D(count * 10); + + _prepareKernel(count, _gpuStationaryRaw.View, _gpuStationaryPrep.View, count); + _accelerator.Synchronize(); + + _lastStationaryData = data; // store reference for next comparison + } + + private void EnsureMoving(double[] data, int count) + { + if (_gpuMovingPrep != null && + _lastMovingData != null && + _lastMovingData.Length == data.Length) + { + if (_lastMovingData == data || + new ReadOnlySpan(_lastMovingData).SequenceEqual(new ReadOnlySpan(data))) + { + return; + } + } + + _gpuMovingRaw?.Dispose(); + _gpuMovingPrep?.Dispose(); + + _gpuMovingRaw = _accelerator.Allocate1D(data); + _gpuMovingPrep = _accelerator.Allocate1D(count * 10); + + _prepareKernel(count, _gpuMovingRaw.View, _gpuMovingPrep.View, count); + _accelerator.Synchronize(); + + _lastMovingData = data; + } + + private void EnsureOffsetBuffers(int offsetCount) + { + if (_offsetCapacity >= offsetCount) + return; + + var newCapacity = System.Math.Max(offsetCount, _offsetCapacity * 3 / 2); + + _gpuOffsets?.Dispose(); + _gpuResults?.Dispose(); + _gpuDirs?.Dispose(); + + _gpuOffsets = _accelerator.Allocate1D(newCapacity * 2); + _gpuResults = _accelerator.Allocate1D(newCapacity); + _gpuDirs = _accelerator.Allocate1D(newCapacity); + + _offsetCapacity = newCapacity; + } + + // ── Preparation Kernel ─────────────────────────────────────── + + private static void PrepareKernel( + Index1D index, + ArrayView1D raw, + ArrayView1D prepared, + int count) + { + if (index >= count) return; + var x1 = raw[index * 4 + 0]; + var y1 = raw[index * 4 + 1]; + var x2 = raw[index * 4 + 2]; + var y2 = raw[index * 4 + 3]; + + prepared[index * 10 + 0] = x1; + prepared[index * 10 + 1] = y1; + prepared[index * 10 + 2] = x2; + prepared[index * 10 + 3] = y2; + + var dx = x2 - x1; + var dy = y2 - y1; + + // invD is used for parameter 't'. We use a small epsilon for stability. + prepared[index * 10 + 4] = (XMath.Abs(dx) < 1e-9) ? 0 : 1.0 / dx; + prepared[index * 10 + 5] = (XMath.Abs(dy) < 1e-9) ? 0 : 1.0 / dy; + + prepared[index * 10 + 6] = XMath.Min(x1, x2); + prepared[index * 10 + 7] = XMath.Max(x1, x2); + prepared[index * 10 + 8] = XMath.Min(y1, y2); + prepared[index * 10 + 9] = XMath.Max(y1, y2); + } + + // ── Main Slide Kernels ─────────────────────────────────────── + + private static void SlideKernel( + Index1D index, + ArrayView1D stationaryPrep, + ArrayView1D movingPrep, + ArrayView1D offsets, + ArrayView1D results, + int sCount, int mCount, int direction) + { + if (index >= results.Length) return; + + var dx = offsets[index * 2]; + var dy = offsets[index * 2 + 1]; + + results[index] = ComputeSlideLean( + stationaryPrep, movingPrep, dx, dy, sCount, mCount, direction); + } + + private static void SlideKernelMultiDir( + Index1D index, + ArrayView1D stationaryPrep, + ArrayView1D movingPrep, + ArrayView1D offsets, + ArrayView1D results, + ArrayView1D directions, + int sCount, int mCount) + { + if (index >= results.Length) return; + + var dx = offsets[index * 2]; + var dy = offsets[index * 2 + 1]; + var dir = directions[index]; + + results[index] = ComputeSlideLean( + stationaryPrep, movingPrep, dx, dy, sCount, mCount, dir); + } + + private static double ComputeSlideLean( + ArrayView1D sPrep, + ArrayView1D mPrep, + double dx, double dy, int sCount, int mCount, int direction) + { + const double eps = 0.00001; + var minDist = double.MaxValue; + var horizontal = direction >= 2; + var oppDir = direction ^ 1; + + // ── Forward Pass: moving vertices vs stationary edges ───── + for (int i = 0; i < mCount; i++) + { + var m1x = mPrep[i * 10 + 0] + dx; + var m1y = mPrep[i * 10 + 1] + dy; + var m2x = mPrep[i * 10 + 2] + dx; + var m2y = mPrep[i * 10 + 3] + dy; + + for (int j = 0; j < sCount; j++) + { + var sMin = horizontal ? sPrep[j * 10 + 8] : sPrep[j * 10 + 6]; + var sMax = horizontal ? sPrep[j * 10 + 9] : sPrep[j * 10 + 7]; + + // Test moving vertex 1 against stationary edge j + var mv1 = horizontal ? m1y : m1x; + if (mv1 >= sMin - eps && mv1 <= sMax + eps) + { + var d = RayEdgeLean(m1x, m1y, sPrep, j, direction, eps); + if (d < minDist) minDist = d; + } + + // Test moving vertex 2 against stationary edge j + var mv2 = horizontal ? m2y : m2x; + if (mv2 >= sMin - eps && mv2 <= sMax + eps) + { + var d = RayEdgeLean(m2x, m2y, sPrep, j, direction, eps); + if (d < minDist) minDist = d; + } + } + } + + // ── Reverse Pass: stationary vertices vs moving edges ───── + for (int i = 0; i < sCount; i++) + { + var s1x = sPrep[i * 10 + 0]; + var s1y = sPrep[i * 10 + 1]; + var s2x = sPrep[i * 10 + 2]; + var s2y = sPrep[i * 10 + 3]; + + for (int j = 0; j < mCount; j++) + { + var mMin = horizontal ? (mPrep[j * 10 + 8] + dy) : (mPrep[j * 10 + 6] + dx); + var mMax = horizontal ? (mPrep[j * 10 + 9] + dy) : (mPrep[j * 10 + 7] + dx); + + // Test stationary vertex 1 against moving edge j + var sv1 = horizontal ? s1y : s1x; + if (sv1 >= mMin - eps && sv1 <= mMax + eps) + { + var d = RayEdgeLeanMoving(s1x, s1y, mPrep, j, dx, dy, oppDir, eps); + if (d < minDist) minDist = d; + } + + // Test stationary vertex 2 against moving edge j + var sv2 = horizontal ? s2y : s2x; + if (sv2 >= mMin - eps && sv2 <= mMax + eps) + { + var d = RayEdgeLeanMoving(s2x, s2y, mPrep, j, dx, dy, oppDir, eps); + if (d < minDist) minDist = d; + } + } + } + + return minDist; + } + + private static double RayEdgeLean( + double vx, double vy, + ArrayView1D sPrep, int j, + int direction, double eps) + { + var p1x = sPrep[j * 10 + 0]; + var p1y = sPrep[j * 10 + 1]; + var p2x = sPrep[j * 10 + 2]; + var p2y = sPrep[j * 10 + 3]; + + if (direction >= 2) // Horizontal (Left=2, Right=3) + { + var invDy = sPrep[j * 10 + 5]; + if (invDy == 0) return double.MaxValue; + + var t = (vy - p1y) * invDy; + if (t < -eps || t > 1.0 + eps) return double.MaxValue; + + var ix = p1x + t * (p2x - p1x); + var dist = (direction == 2) ? (vx - ix) : (ix - vx); + + if (dist > eps) return dist; + return (dist >= -eps) ? 0.0 : double.MaxValue; + } + else // Vertical (Up=0, Down=1) + { + var invDx = sPrep[j * 10 + 4]; + if (invDx == 0) return double.MaxValue; + + var t = (vx - p1x) * invDx; + if (t < -eps || t > 1.0 + eps) return double.MaxValue; + + var iy = p1y + t * (p2y - p1y); + var dist = (direction == 1) ? (vy - iy) : (iy - vy); + + if (dist > eps) return dist; + return (dist >= -eps) ? 0.0 : double.MaxValue; + } + } + + private static double RayEdgeLeanMoving( + double vx, double vy, + ArrayView1D mPrep, int j, + double dx, double dy, int direction, double eps) + { + var p1x = mPrep[j * 10 + 0] + dx; + var p1y = mPrep[j * 10 + 1] + dy; + var p2x = mPrep[j * 10 + 2] + dx; + var p2y = mPrep[j * 10 + 3] + dy; + + if (direction >= 2) // Horizontal + { + var invDy = mPrep[j * 10 + 5]; + if (invDy == 0) return double.MaxValue; + + var t = (vy - p1y) * invDy; + if (t < -eps || t > 1.0 + eps) return double.MaxValue; + + var ix = p1x + t * (p2x - p1x); + var dist = (direction == 2) ? (vx - ix) : (ix - vx); + + if (dist > eps) return dist; + return (dist >= -eps) ? 0.0 : double.MaxValue; + } + else // Vertical + { + var invDx = mPrep[j * 10 + 4]; + if (invDx == 0) return double.MaxValue; + + var t = (vx - p1x) * invDx; + if (t < -eps || t > 1.0 + eps) return double.MaxValue; + + var iy = p1y + t * (p2y - p1y); + var dist = (direction == 1) ? (vy - iy) : (iy - vy); + + if (dist > eps) return dist; + return (dist >= -eps) ? 0.0 : double.MaxValue; + } + } + + public void Dispose() + { + _gpuStationaryRaw?.Dispose(); + _gpuStationaryPrep?.Dispose(); + _gpuMovingRaw?.Dispose(); + _gpuMovingPrep?.Dispose(); + _gpuOffsets?.Dispose(); + _gpuResults?.Dispose(); + _gpuDirs?.Dispose(); + _accelerator?.Dispose(); + _context?.Dispose(); + } + } +} From 183d169cc11dd4fe62d72f7c60fbf65a6163d0eb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 20:29:51 -0400 Subject: [PATCH 003/116] 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() From 4c1ac418a042f9d39e5274584382bec43f4b09a2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 20:29:56 -0400 Subject: [PATCH 004/116] feat(ui): add pagination to best-fit viewer Replace single scrollable grid with fixed 5x2 pages (10 items per page). Add prev/next buttons and page label. Support Left/Right and PageUp/ PageDown keyboard navigation. Co-Authored-By: Claude Opus 4.6 --- OpenNest/Forms/BestFitViewerForm.Designer.cs | 60 +++++++++- OpenNest/Forms/BestFitViewerForm.cs | 110 +++++++++++++------ 2 files changed, 133 insertions(+), 37 deletions(-) diff --git a/OpenNest/Forms/BestFitViewerForm.Designer.cs b/OpenNest/Forms/BestFitViewerForm.Designer.cs index a53a48a..e977148 100644 --- a/OpenNest/Forms/BestFitViewerForm.Designer.cs +++ b/OpenNest/Forms/BestFitViewerForm.Designer.cs @@ -14,11 +14,15 @@ namespace OpenNest.Forms private void InitializeComponent() { this.gridPanel = new System.Windows.Forms.TableLayoutPanel(); + this.navPanel = new System.Windows.Forms.Panel(); + this.btnPrev = new System.Windows.Forms.Button(); + this.btnNext = new System.Windows.Forms.Button(); + this.lblPage = new System.Windows.Forms.Label(); + this.navPanel.SuspendLayout(); this.SuspendLayout(); // // gridPanel // - this.gridPanel.AutoScroll = true; this.gridPanel.ColumnCount = 5; this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); @@ -28,24 +32,72 @@ namespace OpenNest.Forms this.gridPanel.Dock = System.Windows.Forms.DockStyle.Fill; this.gridPanel.Location = new System.Drawing.Point(0, 0); this.gridPanel.Name = "gridPanel"; - this.gridPanel.RowCount = 1; - this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); - this.gridPanel.Size = new System.Drawing.Size(1200, 800); + this.gridPanel.RowCount = 2; + this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F)); + this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F)); + this.gridPanel.Size = new System.Drawing.Size(1200, 764); this.gridPanel.TabIndex = 0; // + // navPanel + // + this.navPanel.Controls.Add(this.btnPrev); + this.navPanel.Controls.Add(this.lblPage); + this.navPanel.Controls.Add(this.btnNext); + this.navPanel.Dock = System.Windows.Forms.DockStyle.Bottom; + this.navPanel.Location = new System.Drawing.Point(0, 764); + this.navPanel.Name = "navPanel"; + this.navPanel.Size = new System.Drawing.Size(1200, 36); + this.navPanel.TabIndex = 1; + // + // btnPrev + // + this.btnPrev.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnPrev.Location = new System.Drawing.Point(4, 4); + this.btnPrev.Name = "btnPrev"; + this.btnPrev.Size = new System.Drawing.Size(80, 28); + this.btnPrev.TabIndex = 0; + this.btnPrev.Text = "< Prev"; + this.btnPrev.Click += new System.EventHandler(this.btnPrev_Click); + // + // lblPage + // + this.lblPage.Dock = System.Windows.Forms.DockStyle.Fill; + this.lblPage.Name = "lblPage"; + this.lblPage.Size = new System.Drawing.Size(1200, 36); + this.lblPage.TabIndex = 1; + this.lblPage.Text = "Page 1 / 1"; + this.lblPage.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // btnNext + // + this.btnNext.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right; + this.btnNext.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnNext.Location = new System.Drawing.Point(1116, 4); + this.btnNext.Name = "btnNext"; + this.btnNext.Size = new System.Drawing.Size(80, 28); + this.btnNext.TabIndex = 2; + this.btnNext.Text = "Next >"; + this.btnNext.Click += new System.EventHandler(this.btnNext_Click); + // // BestFitViewerForm // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(1200, 800); this.Controls.Add(this.gridPanel); + this.Controls.Add(this.navPanel); this.KeyPreview = true; this.Name = "BestFitViewerForm"; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; this.Text = "Best-Fit Viewer"; + this.navPanel.ResumeLayout(false); this.ResumeLayout(false); } private System.Windows.Forms.TableLayoutPanel gridPanel; + private System.Windows.Forms.Panel navPanel; + private System.Windows.Forms.Button btnPrev; + private System.Windows.Forms.Button btnNext; + private System.Windows.Forms.Label lblPage; } } diff --git a/OpenNest/Forms/BestFitViewerForm.cs b/OpenNest/Forms/BestFitViewerForm.cs index 2b52a4d..5d590a7 100644 --- a/OpenNest/Forms/BestFitViewerForm.cs +++ b/OpenNest/Forms/BestFitViewerForm.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Diagnostics; using System.Drawing; using System.Windows.Forms; @@ -9,7 +10,8 @@ namespace OpenNest.Forms public partial class BestFitViewerForm : Form { private const int Columns = 5; - private const int RowHeight = 300; + private const int Rows = 2; + private const int ItemsPerPage = Columns * Rows; private const int MaxResults = 50; private static readonly Color KeptColor = Color.FromArgb(0, 0, 100); @@ -18,6 +20,14 @@ namespace OpenNest.Forms private readonly Drawing drawing; private readonly Plate plate; + private List results; + private int totalResults; + private int keptCount; + private double computeSeconds; + private double totalSeconds; + private int currentPage; + private int pageCount; + public BestFitResult SelectedResult { get; private set; } public BestFitViewerForm(Drawing drawing, Plate plate) @@ -33,7 +43,8 @@ namespace OpenNest.Forms Cursor = Cursors.WaitCursor; try { - PopulateGrid(drawing, plate); + ComputeResults(); + ShowPage(0); } finally { @@ -48,51 +59,84 @@ namespace OpenNest.Forms Close(); return true; } + if (keyData == Keys.Left || keyData == Keys.PageUp) + { + NavigatePage(-1); + return true; + } + if (keyData == Keys.Right || keyData == Keys.PageDown) + { + NavigatePage(1); + return true; + } return base.ProcessCmdKey(ref msg, keyData); } - private void PopulateGrid(Drawing drawing, Plate plate) + private void ComputeResults() { var sw = Stopwatch.StartNew(); - var results = BestFitCache.GetOrCompute( + var all = BestFitCache.GetOrCompute( drawing, plate.Size.Width, plate.Size.Length, plate.PartSpacing); - var findMs = sw.ElapsedMilliseconds; - var total = results.Count; - var kept = 0; + computeSeconds = sw.ElapsedMilliseconds / 1000.0; + totalResults = all.Count; + keptCount = 0; - foreach (var r in results) + foreach (var r in all) { - if (r.Keep) kept++; + if (r.Keep) keptCount++; } - var count = System.Math.Min(total, MaxResults); - var rows = (int)System.Math.Ceiling(count / (double)Columns); - gridPanel.RowCount = rows; - gridPanel.RowStyles.Clear(); - - for (var i = 0; i < rows; i++) - gridPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, RowHeight)); - - gridPanel.SuspendLayout(); - try - { - for (var i = 0; i < count; i++) - { - var result = results[i]; - var cell = CreateCell(result, drawing, i + 1); - gridPanel.Controls.Add(cell, i % Columns, i / Columns); - } - } - finally - { - gridPanel.ResumeLayout(true); - } + var count = System.Math.Min(totalResults, MaxResults); + results = all.GetRange(0, count); + pageCount = System.Math.Max(1, (int)System.Math.Ceiling(count / (double)ItemsPerPage)); sw.Stop(); - Text = string.Format("Best-Fit Viewer — {0} candidates ({1} kept) | Compute: {2:F1}s | Total: {3:F1}s | Showing {4}", - total, kept, findMs / 1000.0, sw.Elapsed.TotalSeconds, count); + totalSeconds = sw.Elapsed.TotalSeconds; + } + + private void ShowPage(int page) + { + currentPage = page; + var start = page * ItemsPerPage; + var count = System.Math.Min(ItemsPerPage, results.Count - start); + + gridPanel.SuspendLayout(); + gridPanel.Controls.Clear(); + + gridPanel.RowCount = Rows; + gridPanel.RowStyles.Clear(); + for (var i = 0; i < Rows; i++) + gridPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100f / Rows)); + + for (var i = 0; i < count; i++) + { + var result = results[start + i]; + var cell = CreateCell(result, drawing, start + i + 1); + gridPanel.Controls.Add(cell, i % Columns, i / Columns); + } + + gridPanel.ResumeLayout(true); + + btnPrev.Enabled = currentPage > 0; + btnNext.Enabled = currentPage < pageCount - 1; + lblPage.Text = string.Format("Page {0} / {1}", currentPage + 1, pageCount); + + Text = string.Format("Best-Fit Viewer — {0} candidates ({1} kept) | Compute: {2:F1}s | Total: {3:F1}s | Showing {4}-{5} of {6}", + totalResults, keptCount, computeSeconds, totalSeconds, + start + 1, start + count, results.Count); + } + + private void btnPrev_Click(object sender, System.EventArgs e) => NavigatePage(-1); + + private void btnNext_Click(object sender, System.EventArgs e) => NavigatePage(1); + + private void NavigatePage(int delta) + { + var newPage = currentPage + delta; + if (newPage >= 0 && newPage < pageCount) + ShowPage(newPage); } private BestFitCell CreateCell(BestFitResult result, Drawing drawing, int rank) From 891bb49548e6b36ac69fa5e6b12f9fc64045ff90 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 20:30:00 -0400 Subject: [PATCH 005/116] docs: add GPU debug, test harness, and contour reindexing plans Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-10-gpu-overlap-debug.md | 110 ++++++ .../plans/2026-03-11-test-harness.md | 367 ++++++++++++++++++ .../plans/2026-03-12-contour-reindexing.md | 281 ++++++++++++++ 3 files changed, 758 insertions(+) create mode 100644 docs/plans/2026-03-10-gpu-overlap-debug.md create mode 100644 docs/superpowers/plans/2026-03-11-test-harness.md create mode 100644 docs/superpowers/plans/2026-03-12-contour-reindexing.md diff --git a/docs/plans/2026-03-10-gpu-overlap-debug.md b/docs/plans/2026-03-10-gpu-overlap-debug.md new file mode 100644 index 0000000..268bffd --- /dev/null +++ b/docs/plans/2026-03-10-gpu-overlap-debug.md @@ -0,0 +1,110 @@ +# GPU Pair Evaluator — Overlap Detection Bug + +**Date**: 2026-03-10 +**Status**: RESOLVED — commit b55aa7a + +## Problem + +The `GpuPairEvaluator` reports "Overlap detected" for ALL best-fit candidates, even though the parts are clearly not overlapping. The CPU `PairEvaluator` works correctly (screenshot comparison: GPU = all red/overlap, CPU = blue with valid results like 93.9% utilization). + +## Root Cause (identified but not yet fully fixed) + +The bitmap coordinate system doesn't match the `Part2Offset` coordinate system. + +### How Part2Offset is computed +`RotationSlideStrategy` creates parts using `Part.CreateAtOrigin(drawing, rotation)` which: +1. Clones the drawing's program +2. Rotates it +3. Calls `Program.BoundingBox()` to get the bbox +4. Offsets by `-bbox.Location` to normalize to origin + +`Part2Offset` is the final position of Part2 in this **normalized** coordinate space. + +### How bitmaps are rasterized +`PartBitmap.FromDrawing` / `FromDrawingRotated`: +1. Extracts closed polygons from the drawing (filters out rapids, open shapes) +2. Rotates them (for B) +3. Rasterizes with `OriginX/Y = polygon min` + +### The mismatch +`Program.BoundingBox()` initializes `minX=0, minY=0, maxX=0, maxY=0` (line 289-292 in Program.cs), so (0,0) is **always** included in the bbox. This means: +- For geometry at (5,3)-(10,8): bbox.Location = (0,0), CreateAtOrigin shifts by (0,0) = no change +- But polygon min = (5,3), so bitmap OriginX=5, OriginY=3 +- Part2Offset is in the (0,0)-based normalized space, bitmap is in the (5,3)-based polygon space + +For rotated geometry, the discrepancy is even worse because rotation changes the polygon min dramatically while the bbox may or may not include (0,0). + +## What we tried + +### Attempt 1: BlitPair approach (correct but too slow) +- Added `PartBitmap.BlitPair()` that places both bitmaps into a shared world-space grid +- Eliminated all offset math from the kernel (trivial element-wise AND) +- **Problem**: Per-candidate grid allocation. 21K candidates × large grids = massive memory + GPU transfer. Took minutes instead of seconds. + +### Attempt 2: Integer offsets with gap correction +- Kept shared-bitmap approach (one A + one B per rotation group) +- Changed offsets from `float` to `int` with `Math.Round()` on CPU +- Added gap correction: `offset = (Part2Offset - gapA + gapB) / cellSize` where `gapA = bitmapOriginA - bboxA.Location`, `gapB = bitmapOriginB - bboxB.Location` +- **Problem**: Still false positives. The formula is mathematically correct in derivation but something is wrong in practice. + +### Attempt 3: Normalize bitmaps to match CreateAtOrigin (current state) +- Added `PartBitmap.FromDrawingAtOrigin()` and `FromDrawingAtOriginRotated()` +- These shift polygons by `-bbox.Location` before rasterizing, exactly like `CreateAtOrigin` +- Offset formula: `(Part2Offset.X - bitmapA.OriginX + bitmapB.OriginX) / cellSize` +- **Problem**: STILL showing false overlaps for all candidates (see gpu.png). 33.8s compute, 3942 kept but all marked overlap. + +## Current state of code + +### Files modified + +**`OpenNest.Gpu/PartBitmap.cs`**: +- Added `BlitPair()` static method (from attempt 1, still present but unused) +- Added `FromDrawingAtOrigin()` — normalizes polygons by `-bbox.Location` before rasterize +- Added `FromDrawingAtOriginRotated()` — rotates polygons, clones+rotates program for bbox, normalizes, rasterizes + +**`OpenNest.Gpu/GpuPairEvaluator.cs`**: +- Uses `FromDrawingAtOrigin` / `FromDrawingAtOriginRotated` instead of raw `FromDrawing` / `FromDrawingRotated` +- Offsets are `int[]` (not `float[]`) computed with `Math.Round()` on CPU +- Kernel is `OverlapKernel` — uses integer offsets, early-exit on `cellA != 1` +- `PadBitmap` helper restored +- Removed the old `NestingKernel` with float offsets + +**`OpenNest/Forms/MainForm.cs`**: +- Added `using OpenNest.Engine.BestFit;` +- Wired up GPU evaluator: `BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);` + +## Next steps to debug + +1. **Add diagnostic logging** to compare GPU vs CPU for a single candidate: + - Print bitmapA: OriginX, OriginY, Width, Height + - Print bitmapB: OriginX, OriginY, Width, Height + - Print the computed integer offset + - Print the overlap count from the kernel + - Compare with CPU `PairEvaluator.CheckOverlap()` result for the same candidate + +2. **Verify Program.Clone() + Rotate() produces same geometry as Polygon.Rotate()**: + - `FromDrawingAtOriginRotated` rotates polygons with `poly.Rotate(rotation)` then normalizes using `prog.Clone().Rotate(rotation).BoundingBox()` + - If `Program.Rotate` and `Polygon.Rotate` use different rotation centers or conventions, the normalization would be wrong + - Check: does `Program.Rotate` rotate around (0,0)? Does `Polygon.Rotate` rotate around (0,0)? + +3. **Try rasterizing from the Part directly**: Instead of extracting polygons from the raw drawing and manually rotating/normalizing, create `Part.CreateAtOrigin(drawing, rotation)` and extract polygons from the Part's already-normalized program. This guarantees exact coordinate system match. + +4. **Consider that the kernel grid might be too small**: `gridWidth = max(A.Width, B.Width)` only works if offset is small. If Part2Offset places B far from A, the B cells at `bx = x - offset` could all be out of bounds (negative), leading the kernel to find zero overlaps (false negative). But we're seeing false POSITIVES, so this isn't the issue unless the offset sign is wrong. + +5. **Check offset sign**: Verify that when offset is positive, `bx = x - offset` correctly maps A cells to B cells. A positive offset should mean B is shifted right relative to A. + +## Performance notes +- CPU evaluator: 25.0s compute, 5954 kept, correct results +- GPU evaluator (current): 33.8s compute, 3942 kept, all false overlaps +- GPU is actually SLOWER because `FromDrawingAtOriginRotated` clones+rotates the full program per rotation group +- Once overlap detection is fixed, performance optimization should focus on avoiding the Program.Clone().Rotate() per rotation group + +## Key files to reference +- `OpenNest.Gpu/GpuPairEvaluator.cs` — the GPU evaluator +- `OpenNest.Gpu/PartBitmap.cs` — bitmap rasterization +- `OpenNest.Engine/BestFit/PairEvaluator.cs` — CPU evaluator (working reference) +- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — generates Part2Offset values +- `OpenNest.Core/Part.cs:109` — `Part.CreateAtOrigin()` +- `OpenNest.Core/CNC/Program.cs:281-342` — `Program.BoundingBox()` (note min init at 0,0) +- `OpenNest.Engine/BestFit/BestFitCache.cs` — where evaluator is plugged in +- `OpenNest/Forms/MainForm.cs` — where GPU evaluator is wired up diff --git a/docs/superpowers/plans/2026-03-11-test-harness.md b/docs/superpowers/plans/2026-03-11-test-harness.md new file mode 100644 index 0000000..6830462 --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-test-harness.md @@ -0,0 +1,367 @@ +# OpenNest Test Harness Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a console app + MCP tool that builds and runs OpenNest.Engine against a nest file, writing debug output to a file for grepping and saving the resulting nest. + +**Architecture:** A new `OpenNest.TestHarness` console app references Core, Engine, and IO. It loads a nest file, clears a plate, runs `NestEngine.Fill()`, writes `Debug.WriteLine` output to a timestamped log file via `TextWriterTraceListener`, prints a summary to stdout, and saves the nest. An MCP tool `test_engine` in OpenNest.Mcp shells out to `dotnet run --project OpenNest.TestHarness` and returns the summary + log file path. + +**Tech Stack:** .NET 8, System.Diagnostics tracing, OpenNest.Core/Engine/IO + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `OpenNest.TestHarness/OpenNest.TestHarness.csproj` | Console app project, references Core + Engine + IO. Forces `DEBUG` constant. | +| Create | `OpenNest.TestHarness/Program.cs` | Entry point: parse args, load nest, run fill, write debug to file, save nest | +| Modify | `OpenNest.sln` | Add new project to solution | +| Create | `OpenNest.Mcp/Tools/TestTools.cs` | MCP `test_engine` tool that shells out to the harness | + +--- + +## Chunk 1: Console App + MCP Tool + +### Task 1: Create the OpenNest.TestHarness project + +**Files:** +- Create: `OpenNest.TestHarness/OpenNest.TestHarness.csproj` + +- [ ] **Step 1: Create the project file** + +Note: `DEBUG` is defined for all configurations so `Debug.WriteLine` output is always captured — that's the whole point of this tool. + +```xml + + + Exe + net8.0-windows + OpenNest.TestHarness + OpenNest.TestHarness + $(DefineConstants);DEBUG;TRACE + + + + + + + +``` + +- [ ] **Step 2: Add project to solution** + +```bash +dotnet sln OpenNest.sln add OpenNest.TestHarness/OpenNest.TestHarness.csproj +``` + +- [ ] **Step 3: Verify it builds** + +```bash +dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj +``` + +Expected: Build succeeded (with warning about empty Program.cs — that's fine, we create it next). + +--- + +### Task 2: Write the TestHarness Program.cs + +**Files:** +- Create: `OpenNest.TestHarness/Program.cs` + +The console app does: +1. Parse command-line args for nest file path, optional drawing name, plate index, output path +2. Create a timestamped log file and attach a `TextWriterTraceListener` so `Debug.WriteLine` goes to the file +3. Load the nest file via `NestReader` +4. Find the drawing and plate +5. Clear existing parts from the plate +6. Run `NestEngine.Fill()` +7. Print summary (part count, utilization, log file path) to stdout +8. Save the nest via `NestWriter` + +- [ ] **Step 1: Write Program.cs** + +```csharp +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using OpenNest; +using OpenNest.IO; + +// Parse arguments. +var nestFile = args.Length > 0 ? args[0] : null; +var drawingName = (string)null; +var plateIndex = 0; +var outputFile = (string)null; + +for (var i = 1; i < args.Length; i++) +{ + switch (args[i]) + { + case "--drawing" when i + 1 < args.Length: + drawingName = args[++i]; + break; + case "--plate" when i + 1 < args.Length: + plateIndex = int.Parse(args[++i]); + break; + case "--output" when i + 1 < args.Length: + outputFile = args[++i]; + break; + } +} + +if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile)) +{ + Console.Error.WriteLine("Usage: OpenNest.TestHarness [--drawing ] [--plate ] [--output ]"); + Console.Error.WriteLine(" nest-file Path to a .zip nest file"); + Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); + Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); + Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); + return 1; +} + +// Set up debug log file. +var logDir = Path.Combine(Path.GetDirectoryName(nestFile), "test-harness-logs"); +Directory.CreateDirectory(logDir); +var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log"); +var logWriter = new StreamWriter(logFile) { AutoFlush = true }; +Trace.Listeners.Add(new TextWriterTraceListener(logWriter)); + +// Load nest. +var reader = new NestReader(nestFile); +var nest = reader.Read(); + +if (nest.Plates.Count == 0) +{ + Console.Error.WriteLine("Error: nest file contains no plates"); + return 1; +} + +if (plateIndex >= nest.Plates.Count) +{ + Console.Error.WriteLine($"Error: plate index {plateIndex} out of range (0-{nest.Plates.Count - 1})"); + return 1; +} + +var plate = nest.Plates[plateIndex]; + +// Find drawing. +var drawing = drawingName != null + ? nest.Drawings.FirstOrDefault(d => d.Name == drawingName) + : nest.Drawings.FirstOrDefault(); + +if (drawing == null) +{ + Console.Error.WriteLine(drawingName != null + ? $"Error: drawing '{drawingName}' not found" + : "Error: nest file contains no drawings"); + return 1; +} + +// Clear existing parts. +var existingCount = plate.Parts.Count; +plate.Parts.Clear(); + +Console.WriteLine($"Nest: {nest.Name}"); +Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Height:F1}), spacing={plate.PartSpacing:F2}"); +Console.WriteLine($"Drawing: {drawing.Name}"); +Console.WriteLine($"Cleared {existingCount} existing parts"); +Console.WriteLine("---"); + +// Run fill. +var sw = Stopwatch.StartNew(); +var engine = new NestEngine(plate); +var item = new NestItem { Drawing = drawing, Quantity = 0 }; +var success = engine.Fill(item); +sw.Stop(); + +// Flush and close the log. +Trace.Flush(); +logWriter.Dispose(); + +// Print results. +Console.WriteLine($"Result: {(success ? "success" : "failed")}"); +Console.WriteLine($"Parts placed: {plate.Parts.Count}"); +Console.WriteLine($"Utilization: {plate.Utilization():P1}"); +Console.WriteLine($"Time: {sw.ElapsedMilliseconds}ms"); +Console.WriteLine($"Debug log: {logFile}"); + +// Save output. +if (outputFile == null) +{ + var dir = Path.GetDirectoryName(nestFile); + var name = Path.GetFileNameWithoutExtension(nestFile); + outputFile = Path.Combine(dir, $"{name}-result.zip"); +} + +var writer = new NestWriter(nest); +writer.Write(outputFile); +Console.WriteLine($"Saved: {outputFile}"); + +return 0; +``` + +- [ ] **Step 2: Build the project** + +```bash +dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj +``` + +Expected: Build succeeded with 0 errors. + +- [ ] **Step 3: Run a smoke test with the real nest file** + +```bash +dotnet run --project OpenNest.TestHarness -- "C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip" +``` + +Expected: Prints nest info and results to stdout, writes debug log file, saves a `-result.zip` file. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.TestHarness/ OpenNest.sln +git commit -m "feat: add OpenNest.TestHarness console app for engine testing" +``` + +--- + +### Task 3: Add the MCP test_engine tool + +**Files:** +- Create: `OpenNest.Mcp/Tools/TestTools.cs` + +The MCP tool: +1. Accepts optional `nestFile`, `drawingName`, `plateIndex` parameters +2. Runs `dotnet run --project -- ` capturing stdout (results) and stderr (errors only) +3. Returns the summary + debug log file path (Claude can then Grep the log file) + +Note: The solution root is hard-coded because the MCP server is published to `~/.claude/mcp/OpenNest.Mcp/`, far from the source tree. + +- [ ] **Step 1: Create TestTools.cs** + +```csharp +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Text; +using ModelContextProtocol.Server; + +namespace OpenNest.Mcp.Tools +{ + [McpServerToolType] + public class TestTools + { + private const string SolutionRoot = @"C:\Users\AJ\Desktop\Projects\OpenNest"; + + private static readonly string HarnessProject = Path.Combine( + SolutionRoot, "OpenNest.TestHarness", "OpenNest.TestHarness.csproj"); + + [McpServerTool(Name = "test_engine")] + [Description("Build and run the nesting engine against a nest file. Returns fill results and a debug log file path for grepping. Use this to test engine changes without restarting the MCP server.")] + public string TestEngine( + [Description("Path to the nest .zip file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip", + [Description("Drawing name to fill with (default: first drawing)")] string drawingName = null, + [Description("Plate index to fill (default: 0)")] int plateIndex = 0, + [Description("Output nest file path (default: -result.zip)")] string outputFile = null) + { + if (!File.Exists(nestFile)) + return $"Error: nest file not found: {nestFile}"; + + var processArgs = new StringBuilder(); + processArgs.Append($"\"{nestFile}\""); + + if (!string.IsNullOrEmpty(drawingName)) + processArgs.Append($" --drawing \"{drawingName}\""); + + processArgs.Append($" --plate {plateIndex}"); + + if (!string.IsNullOrEmpty(outputFile)) + processArgs.Append($" --output \"{outputFile}\""); + + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project \"{HarnessProject}\" -- {processArgs}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = SolutionRoot + }; + + var sb = new StringBuilder(); + + try + { + using var process = Process.Start(psi); + var stderrTask = process.StandardError.ReadToEndAsync(); + var stdout = process.StandardOutput.ReadToEnd(); + process.WaitForExit(120_000); + var stderr = stderrTask.Result; + + if (!string.IsNullOrWhiteSpace(stdout)) + sb.Append(stdout.TrimEnd()); + + if (!string.IsNullOrWhiteSpace(stderr)) + { + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("=== Errors ==="); + sb.Append(stderr.TrimEnd()); + } + + if (process.ExitCode != 0) + { + sb.AppendLine(); + sb.AppendLine($"Process exited with code {process.ExitCode}"); + } + } + catch (System.Exception ex) + { + sb.AppendLine($"Error running test harness: {ex.Message}"); + } + + return sb.ToString(); + } + } +} +``` + +- [ ] **Step 2: Build the MCP project** + +```bash +dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj +``` + +Expected: Build succeeded. + +- [ ] **Step 3: Republish the MCP server** + +```bash +dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp" +``` + +Expected: Publish succeeded. The MCP server now has the `test_engine` tool. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Mcp/Tools/TestTools.cs +git commit -m "feat: add test_engine MCP tool for iterative engine testing" +``` + +--- + +## Usage + +After implementation, the workflow for iterating on FillLinear becomes: + +1. **Other session** makes changes to `FillLinear.cs` or `NestEngine.cs` +2. **This session** calls `test_engine` (no args needed — defaults to the test nest file) +3. The tool builds the latest code and runs it in a fresh process +4. Returns: part count, utilization, timing, and **debug log file path** +5. Grep the log file for specific patterns (e.g., `[FillLinear]`, `[FindBestFill]`) +6. Repeat diff --git a/docs/superpowers/plans/2026-03-12-contour-reindexing.md b/docs/superpowers/plans/2026-03-12-contour-reindexing.md new file mode 100644 index 0000000..660d707 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-contour-reindexing.md @@ -0,0 +1,281 @@ +# Contour Re-Indexing Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add entity-splitting primitives (`Line.SplitAt`, `Arc.SplitAt`), a `Shape.ReindexAt` method, and wire them into `ContourCuttingStrategy.Apply()` to replace the `NotImplementedException` stubs. + +**Architecture:** Bottom-up — build splitting primitives first, then the reindexing algorithm on top, then wire into the strategy. Each layer depends only on the one below it. + +**Tech Stack:** C# / .NET 8, OpenNest.Core (Geometry + CNC namespaces) + +**Spec:** `docs/superpowers/specs/2026-03-12-contour-reindexing-design.md` + +--- + +## File Structure + +| File | Change | Responsibility | +|------|--------|----------------| +| `OpenNest.Core/Geometry/Line.cs` | Add method | `SplitAt(Vector)` — split a line at a point into two halves | +| `OpenNest.Core/Geometry/Arc.cs` | Add method | `SplitAt(Vector)` — split an arc at a point into two halves | +| `OpenNest.Core/Geometry/Shape.cs` | Add method | `ReindexAt(Vector, Entity)` — reorder a closed contour to start at a given point | +| `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Add method + modify | `ConvertShapeToMoves` + replace two `NotImplementedException` blocks | + +--- + +## Chunk 1: Splitting Primitives + +### Task 1: Add `Line.SplitAt(Vector)` + +**Files:** +- Modify: `OpenNest.Core/Geometry/Line.cs` + +- [ ] **Step 1: Add `SplitAt` method to `Line`** + +Add the following method to the `Line` class (after the existing `ClosestPointTo` method): + +```csharp +public (Line first, Line second) SplitAt(Vector point) +{ + var first = point.DistanceTo(StartPoint) < Tolerance.Epsilon + ? null + : new Line(StartPoint, point); + + var second = point.DistanceTo(EndPoint) < Tolerance.Epsilon + ? null + : new Line(point, EndPoint); + + return (first, second); +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded, 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/Geometry/Line.cs +git commit -m "feat: add Line.SplitAt(Vector) splitting primitive" +``` + +### Task 2: Add `Arc.SplitAt(Vector)` + +**Files:** +- Modify: `OpenNest.Core/Geometry/Arc.cs` + +- [ ] **Step 1: Add `SplitAt` method to `Arc`** + +Add the following method to the `Arc` class (after the existing `EndPoint` method): + +```csharp +public (Arc first, Arc second) SplitAt(Vector point) +{ + if (point.DistanceTo(StartPoint()) < Tolerance.Epsilon) + return (null, new Arc(Center, Radius, StartAngle, EndAngle, IsReversed)); + + if (point.DistanceTo(EndPoint()) < Tolerance.Epsilon) + return (new Arc(Center, Radius, StartAngle, EndAngle, IsReversed), null); + + var splitAngle = Angle.NormalizeRad(Center.AngleTo(point)); + + var firstArc = new Arc(Center, Radius, StartAngle, splitAngle, IsReversed); + var secondArc = new Arc(Center, Radius, splitAngle, EndAngle, IsReversed); + + return (firstArc, secondArc); +} +``` + +Key details from spec: +- Compare distances to `StartPoint()`/`EndPoint()` rather than comparing angles (avoids 0/2π wrap-around issues). +- `splitAngle` is computed from `Center.AngleTo(point)`, normalized. +- Both halves preserve center, radius, and `IsReversed` direction. + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded, 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/Geometry/Arc.cs +git commit -m "feat: add Arc.SplitAt(Vector) splitting primitive" +``` + +--- + +## Chunk 2: Shape.ReindexAt + +### Task 3: Add `Shape.ReindexAt(Vector, Entity)` + +**Files:** +- Modify: `OpenNest.Core/Geometry/Shape.cs` + +- [ ] **Step 1: Add `ReindexAt` method to `Shape`** + +Add the following method to the `Shape` class (after the existing `ClosestPointTo(Vector, out Entity)` method around line 201): + +```csharp +public Shape ReindexAt(Vector point, Entity entity) +{ + // Circle case: return a new shape with just the circle + if (entity is Circle) + { + var result = new Shape(); + result.Entities.Add(entity); + return result; + } + + var i = Entities.IndexOf(entity); + if (i < 0) + throw new ArgumentException("Entity not found in shape", nameof(entity)); + + // Split the entity at the point + Entity firstHalf = null; + Entity secondHalf = null; + + if (entity is Line line) + { + var (f, s) = line.SplitAt(point); + firstHalf = f; + secondHalf = s; + } + else if (entity is Arc arc) + { + var (f, s) = arc.SplitAt(point); + firstHalf = f; + secondHalf = s; + } + + // Build reindexed entity list + var entities = new List(); + + // secondHalf of split entity (if not null) + if (secondHalf != null) + entities.Add(secondHalf); + + // Entities after the split index (wrapping) + for (var j = i + 1; j < Entities.Count; j++) + entities.Add(Entities[j]); + + // Entities before the split index (wrapping) + for (var j = 0; j < i; j++) + entities.Add(Entities[j]); + + // firstHalf of split entity (if not null) + if (firstHalf != null) + entities.Add(firstHalf); + + var reindexed = new Shape(); + reindexed.Entities.AddRange(entities); + return reindexed; +} +``` + +The `Shape` class already imports `System` and `System.Collections.Generic`, so no new usings needed. + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded, 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/Geometry/Shape.cs +git commit -m "feat: add Shape.ReindexAt(Vector, Entity) for contour reordering" +``` + +--- + +## Chunk 3: Wire into ContourCuttingStrategy + +### Task 4: Add `ConvertShapeToMoves` and replace stubs + +**Files:** +- Modify: `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` + +- [ ] **Step 1: Add `ConvertShapeToMoves` private method** + +Add the following private method to `ContourCuttingStrategy` (after the existing `SelectLeadOut` method, before the closing brace of the class): + +```csharp +private List ConvertShapeToMoves(Shape shape, Vector startPoint) +{ + var moves = new List(); + + foreach (var entity in shape.Entities) + { + if (entity is Line line) + { + moves.Add(new LinearMove(line.EndPoint)); + } + else if (entity is Arc arc) + { + moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW)); + } + else if (entity is Circle circle) + { + moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation)); + } + else + { + throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}"); + } + } + + return moves; +} +``` + +This matches the `ConvertGeometry.AddArc`/`AddCircle`/`AddLine` patterns but without `RapidMove` between entities (they are contiguous in a reindexed shape). + +- [ ] **Step 2: Replace cutout `NotImplementedException` (line 41)** + +In the `Apply` method, replace: +```csharp + // Contour re-indexing: split shape entities at closestPt so cutting + // starts there, convert to ICode, and add to result.Codes + throw new System.NotImplementedException("Contour re-indexing not yet implemented"); +``` + +With: +```csharp + var reindexed = cutout.ReindexAt(closestPt, entity); + result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt)); + // TODO: MicrotabLeadOut — trim last cutting move by GapSize +``` + +- [ ] **Step 3: Replace perimeter `NotImplementedException` (line 57)** + +In the `Apply` method, replace: +```csharp + throw new System.NotImplementedException("Contour re-indexing not yet implemented"); +``` + +With: +```csharp + var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity); + result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt)); + // TODO: MicrotabLeadOut — trim last cutting move by GapSize +``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded, 0 errors + +- [ ] **Step 5: Build full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded, 0 errors + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +git commit -m "feat: wire contour re-indexing into ContourCuttingStrategy.Apply()" +``` From 1a9bd795a88ffe27c88ea8219dff9f2f03565065 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 21:14:21 -0400 Subject: [PATCH 006/116] perf(core): share Program instance in CloneAtOffset Offset-only copies don't modify program codes, so sharing the instance avoids expensive cloning during large pattern tiling. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Core/Part.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index f190ce0..e5e4adc 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -216,8 +216,9 @@ namespace OpenNest /// public Part CloneAtOffset(Vector offset) { - var clonedProgram = Program.Clone() as Program; - var part = new Part(BaseDrawing, clonedProgram, + // Share the Program instance — offset-only copies don't modify the program codes. + // This is a major performance win for tiling large patterns. + var part = new Part(BaseDrawing, Program, location + offset, new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y, BoundingBox.Width, BoundingBox.Length)); From de70999975ed485bdad9171d8ce43e1938387ffb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 21:14:25 -0400 Subject: [PATCH 007/116] refactor(engine): precompute hull angles during pair evaluation Store hull edge angles in BestFitResult at evaluation time so they don't need to be recomputed during the fill phase. Extract GetHullEdgeAngles(Polygon) overload from FindHullEdgeAngles. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/BestFit/BestFitResult.cs | 1 + OpenNest.Engine/BestFit/PairEvaluator.cs | 4 ++++ OpenNest.Engine/RotationAnalysis.cs | 5 +++++ 3 files changed, 10 insertions(+) diff --git a/OpenNest.Engine/BestFit/BestFitResult.cs b/OpenNest.Engine/BestFit/BestFitResult.cs index 169f478..97ce07c 100644 --- a/OpenNest.Engine/BestFit/BestFitResult.cs +++ b/OpenNest.Engine/BestFit/BestFitResult.cs @@ -14,6 +14,7 @@ namespace OpenNest.Engine.BestFit public bool Keep { get; set; } public string Reason { get; set; } public double TrueArea { get; set; } + public List HullAngles { get; set; } public double Utilization { diff --git a/OpenNest.Engine/BestFit/PairEvaluator.cs b/OpenNest.Engine/BestFit/PairEvaluator.cs index 5224820..bffc7c6 100644 --- a/OpenNest.Engine/BestFit/PairEvaluator.cs +++ b/OpenNest.Engine/BestFit/PairEvaluator.cs @@ -42,6 +42,7 @@ namespace OpenNest.Engine.BestFit // Find optimal bounding rectangle via rotating calipers double bestArea, bestWidth, bestHeight, bestRotation; + List hullAngles = null; if (allPoints.Count >= 3) { @@ -51,6 +52,7 @@ namespace OpenNest.Engine.BestFit bestWidth = result.Width; bestHeight = result.Height; bestRotation = result.Angle; + hullAngles = RotationAnalysis.GetHullEdgeAngles(hull); } else { @@ -59,6 +61,7 @@ namespace OpenNest.Engine.BestFit bestWidth = combinedBox.Width; bestHeight = combinedBox.Length; bestRotation = 0; + hullAngles = new List { 0 }; } var trueArea = drawing.Area * 2; @@ -71,6 +74,7 @@ namespace OpenNest.Engine.BestFit BoundingHeight = bestHeight, OptimalRotation = bestRotation, TrueArea = trueArea, + HullAngles = hullAngles, Keep = !overlaps, Reason = overlaps ? "Overlap detected" : "Valid" }; diff --git a/OpenNest.Engine/RotationAnalysis.cs b/OpenNest.Engine/RotationAnalysis.cs index 65dac9d..8aa7aa5 100644 --- a/OpenNest.Engine/RotationAnalysis.cs +++ b/OpenNest.Engine/RotationAnalysis.cs @@ -80,6 +80,11 @@ namespace OpenNest return new List { 0 }; var hull = ConvexHull.Compute(points); + return GetHullEdgeAngles(hull); + } + + public static List GetHullEdgeAngles(Polygon hull) + { var vertices = hull.Vertices; var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count; From 152e057a46a293687b8be373c0a80fc665c7e77e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 21:14:28 -0400 Subject: [PATCH 008/116] perf(engine): inline bounding box computation in FillScore Compute min/max bounds in a single pass alongside part area accumulation, avoiding the separate GetBoundingBox() call and redundant iteration. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/FillScore.cs | 37 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/OpenNest.Engine/FillScore.cs b/OpenNest.Engine/FillScore.cs index 8c1dd2b..74f3954 100644 --- a/OpenNest.Engine/FillScore.cs +++ b/OpenNest.Engine/FillScore.cs @@ -41,39 +41,32 @@ namespace OpenNest return default; var totalPartArea = 0.0; + var minX = double.MaxValue; + var minY = double.MaxValue; + var maxX = double.MinValue; + var maxY = double.MinValue; foreach (var part in parts) + { totalPartArea += part.BaseDrawing.Area; + var bb = part.BoundingBox; - var bbox = ((IEnumerable)parts).GetBoundingBox(); - var bboxArea = bbox.Area(); + 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; + } + + var bboxArea = (maxX - minX) * (maxY - minY); var density = bboxArea > 0 ? totalPartArea / bboxArea : 0; - var usableRemnantArea = ComputeUsableRemnantArea(parts, workArea); + var usableRemnantArea = ComputeUsableRemnantArea(maxX, maxY, workArea); return new FillScore(parts.Count, usableRemnantArea, density); } - /// - /// Finds the largest usable remnant (short side >= MinRemnantDimension) - /// by checking right and top edge strips between placed parts and the work area boundary. - /// - private static double ComputeUsableRemnantArea(List parts, Box workArea) + private static double ComputeUsableRemnantArea(double maxRight, double maxTop, Box workArea) { - var maxRight = double.MinValue; - var maxTop = double.MinValue; - - foreach (var part in parts) - { - var bb = part.BoundingBox; - - if (bb.Right > maxRight) - maxRight = bb.Right; - - if (bb.Top > maxTop) - maxTop = bb.Top; - } - var largest = 0.0; // Right strip From 1440d2a16acdf9e173ab35824c0a58b2ac64aace Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 21:14:32 -0400 Subject: [PATCH 009/116] refactor(engine): reorder phases and simplify fill pipeline Run pairs phase first to establish a baseline before linear and rect-best-fit phases. Replace IsBetterFill with direct FillScore comparison. Simplify FillPattern to sequential iteration, reusing a single FillLinear engine instance. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/NestEngine.cs | 105 +++++++++++++++------------------- 1 file changed, 47 insertions(+), 58 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index ea5d92a..a74e8ca 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -105,6 +105,14 @@ namespace OpenNest } } + // Try pair-based approach first. + var pairResult = FillWithPairs(item, workArea); + var best = pairResult; + var bestScore = FillScore.Compute(best, workArea); + + Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts"); + + // Try linear phase. var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); System.Threading.Tasks.Parallel.ForEach(angles, angle => @@ -120,37 +128,26 @@ namespace OpenNest linearBag.Add((FillScore.Compute(v, workArea), v)); }); - List best = null; - var bestScore = default(FillScore); - foreach (var (score, parts) in linearBag) { - if (best == null || score > bestScore) + if (score > bestScore) { best = parts; bestScore = score; } } - var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default; - Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); + Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); // Try rectangle best-fit (mixes orientations to fill remnant strips). var rectResult = FillRectangleBestFit(item, workArea); + var rectScore = rectResult != null ? FillScore.Compute(rectResult, workArea) : default; - Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts"); + Debug.WriteLine($"[FindBestFill] RectBestFit: {rectScore.Count} parts"); - if (IsBetterFill(rectResult, best, workArea)) + if (rectScore > bestScore) best = rectResult; - // Try pair-based approach. - var pairResult = FillWithPairs(item, workArea); - - Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}"); - - if (IsBetterFill(pairResult, best, workArea)) - best = pairResult; - return best; } @@ -183,6 +180,15 @@ namespace OpenNest } } + // Pairs phase first + var pairResult = FillWithPairs(item, workArea, token); + best = pairResult; + var bestScore = FillScore.Compute(best, workArea); + + Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts"); + ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea); + token.ThrowIfCancellationRequested(); + // Linear phase var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); @@ -200,44 +206,30 @@ namespace OpenNest linearBag.Add((FillScore.Compute(v, workArea), v)); }); - var bestScore = default(FillScore); - foreach (var (score, parts) in linearBag) { - if (best == null || score > bestScore) + if (score > bestScore) { best = parts; bestScore = score; } } - var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default; - Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); + Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea); token.ThrowIfCancellationRequested(); // RectBestFit phase var rectResult = FillRectangleBestFit(item, workArea); - Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts"); + var rectScore = rectResult != null ? FillScore.Compute(rectResult, workArea) : default; + Debug.WriteLine($"[FindBestFill] RectBestFit: {rectScore.Count} parts"); - if (IsBetterFill(rectResult, best, workArea)) + if (rectScore > bestScore) { best = rectResult; ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); } - - token.ThrowIfCancellationRequested(); - - // Pairs phase - var pairResult = FillWithPairs(item, workArea, token); - Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}"); - - if (IsBetterFill(pairResult, best, workArea)) - { - best = pairResult; - ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea); - } } catch (OperationCanceledException) { @@ -366,7 +358,7 @@ namespace OpenNest { var result = candidates[i]; var pairParts = result.BuildParts(item.Drawing); - var angles = RotationAnalysis.FindHullEdgeAngles(pairParts); + var angles = result.HullAngles; var engine = new FillLinear(workArea, Plate.PartSpacing); var filled = FillPattern(engine, pairParts, angles, workArea); @@ -409,7 +401,7 @@ namespace OpenNest { var result = candidates[i]; var pairParts = result.BuildParts(item.Drawing); - var angles = RotationAnalysis.FindHullEdgeAngles(pairParts); + var angles = result.HullAngles; var engine = new FillLinear(workArea, Plate.PartSpacing); var filled = FillPattern(engine, pairParts, angles, workArea); @@ -687,35 +679,32 @@ namespace OpenNest private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) { - var bag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); + List best = null; + var bestScore = default(FillScore); - System.Threading.Tasks.Parallel.ForEach(angles, angle => + foreach (var angle in angles) { var pattern = BuildRotatedPattern(groupParts, angle); if (pattern.Parts.Count == 0) - return; + continue; - var localEngine = new FillLinear(workArea, engine.PartSpacing); - var h = localEngine.Fill(pattern, NestDirection.Horizontal); - var v = localEngine.Fill(pattern, NestDirection.Vertical); + var h = engine.Fill(pattern, NestDirection.Horizontal); + var scoreH = h != null && h.Count > 0 ? FillScore.Compute(h, workArea) : default; - if (h != null && h.Count > 0 && !HasOverlaps(h, engine.PartSpacing)) - bag.Add((FillScore.Compute(h, workArea), h)); - - if (v != null && v.Count > 0 && !HasOverlaps(v, engine.PartSpacing)) - bag.Add((FillScore.Compute(v, workArea), v)); - }); - - List best = null; - var bestScore = default(FillScore); - - foreach (var (score, parts) in bag) - { - if (best == null || score > bestScore) + if (scoreH.Count > 0 && (best == null || scoreH > bestScore)) { - best = parts; - bestScore = score; + best = h; + bestScore = scoreH; + } + + var v = engine.Fill(pattern, NestDirection.Vertical); + var scoreV = v != null && v.Count > 0 ? FillScore.Compute(v, workArea) : default; + + if (scoreV.Count > 0 && (best == null || scoreV > bestScore)) + { + best = v; + bestScore = scoreV; } } From 3133228fc973086730fad8d32cc066a9097637b7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 23:40:14 -0400 Subject: [PATCH 010/116] feat(console): use GPU for best-fit when available Wire up GpuEvaluatorFactory in the Console app the same way the GUI app does, so BestFitCache uses GPU-accelerated slide computation when a CUDA/OpenCL device is detected. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Console/OpenNest.Console.csproj | 4 + OpenNest.Console/Program.cs | 212 +++++++++++++++++++++-- 2 files changed, 205 insertions(+), 11 deletions(-) diff --git a/OpenNest.Console/OpenNest.Console.csproj b/OpenNest.Console/OpenNest.Console.csproj index dfb174b..f1a0806 100644 --- a/OpenNest.Console/OpenNest.Console.csproj +++ b/OpenNest.Console/OpenNest.Console.csproj @@ -10,5 +10,9 @@ + + + + diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index f96e900..58bc9b1 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -6,6 +6,11 @@ using System.Linq; using OpenNest; using OpenNest.Geometry; using OpenNest.IO; +using Color = System.Drawing.Color; +using OpenNest.Console; +using OpenNest.Engine.BestFit; +using OpenNest.Engine.ML; +using OpenNest.Gpu; // Parse arguments. var nestFile = (string)null; @@ -21,11 +26,27 @@ var noSave = false; var noLog = false; var keepParts = false; var autoNest = false; +var collectDataDir = (string)null; +var dbPath = "nesting_training.db"; +var saveNestsDir = (string)null; +var templateFile = (string)null; for (var i = 0; i < args.Length; i++) { switch (args[i]) { + case "--db" when i + 1 < args.Length: + dbPath = args[++i]; + break; + case "--save-nests" when i + 1 < args.Length: + saveNestsDir = args[++i]; + break; + case "--template" when i + 1 < args.Length: + templateFile = args[++i]; + break; + case "--collect" when i + 1 < args.Length: + collectDataDir = args[++i]; + break; case "--drawing" when i + 1 < args.Length: drawingName = args[++i]; break; @@ -75,6 +96,22 @@ for (var i = 0; i < args.Length; i++) } } +// Initialize GPU if available. +if (GpuEvaluatorFactory.GpuAvailable) +{ + BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer(); + Console.WriteLine($"GPU: {GpuEvaluatorFactory.DeviceName}"); +} +else +{ + Console.WriteLine("GPU: not available (using CPU)"); +} + +if (collectDataDir != null) +{ + return RunDataCollection(collectDataDir, dbPath, saveNestsDir, spacing ?? 0.5, templateFile); +} + if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile)) { PrintUsage(); @@ -225,24 +262,177 @@ if (!noSave) return checkOverlaps && overlapCount > 0 ? 1 : 0; +int RunDataCollection(string dir, string dbPath, string saveDir, double s, string template) +{ + if (!Directory.Exists(dir)) + { + Console.Error.WriteLine($"Error: Directory not found: {dir}"); + return 1; + } + + // Load template nest for plate defaults if provided. + Nest templateNest = null; + if (template != null) + { + if (!File.Exists(template)) + { + Console.Error.WriteLine($"Error: Template not found: {template}"); + return 1; + } + templateNest = new NestReader(template).Read(); + Console.WriteLine($"Using template: {template}"); + } + + var PartColors = new[] + { + Color.FromArgb(205, 92, 92), + Color.FromArgb(148, 103, 189), + Color.FromArgb(75, 180, 175), + Color.FromArgb(210, 190, 75), + Color.FromArgb(190, 85, 175), + Color.FromArgb(185, 115, 85), + Color.FromArgb(120, 100, 190), + Color.FromArgb(200, 100, 140), + Color.FromArgb(80, 175, 155), + Color.FromArgb(195, 160, 85), + Color.FromArgb(175, 95, 160), + Color.FromArgb(215, 130, 130), + }; + + var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories); + Console.WriteLine($"Found {dxfFiles.Length} DXF files. Initializing SQLite database at: {dbPath}"); + + using var db = new TrainingDatabase(dbPath); + + var sheetSuite = new[] + { + new Size(96, 48), new Size(120, 48), new Size(144, 48), + new Size(96, 60), new Size(120, 60), new Size(144, 60), + new Size(96, 72), new Size(120, 72), new Size(144, 72), + new Size(48, 24), new Size(120, 10) + }; + + var importer = new DxfImporter(); + var colorIndex = 0; + var processed = 0; + + foreach (var file in dxfFiles) + { + try + { + if (!importer.GetGeometry(file, out var entities)) continue; + + var partNo = Path.GetFileNameWithoutExtension(file); + var drawing = new Drawing(Path.GetFileName(file)); + drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(entities); + drawing.UpdateArea(); + drawing.Color = PartColors[colorIndex % PartColors.Length]; + colorIndex++; + + var features = FeatureExtractor.Extract(drawing); + if (features == null) continue; + + using var txn = db.BeginTransaction(); + + var partId = db.GetOrAddPart(Path.GetFileName(file), features, drawing.Program.ToString()); + + foreach (var size in sheetSuite) + { + Plate runPlate; + if (templateNest != null) + { + runPlate = templateNest.PlateDefaults.CreateNew(); + runPlate.Size = size; + runPlate.PartSpacing = s; + } + else + { + runPlate = new Plate { Size = size, PartSpacing = s }; + } + + var result = BruteForceRunner.Run(drawing, runPlate); + if (result == null) continue; + + string savedFilePath = null; + if (saveDir != null) + { + // Deterministic bucket (00-FF) based on filename hash + uint hash = 0; + foreach (char c in partNo) hash = (hash * 31) + c; + var bucket = (hash % 256).ToString("X2"); + + var partDir = Path.Combine(saveDir, bucket, partNo); + Directory.CreateDirectory(partDir); + + var fileName = $"{partNo}-{size.Length}x{size.Width}-{result.PartCount}pcs.zip"; + savedFilePath = Path.Combine(partDir, fileName); + + // Create nest from template or from scratch + Nest nestObj; + if (templateNest != null) + { + nestObj = new Nest(partNo) + { + Units = templateNest.Units, + DateCreated = DateTime.Now + }; + nestObj.PlateDefaults.SetFromExisting(templateNest.PlateDefaults.CreateNew()); + } + else + { + nestObj = new Nest(partNo) { Units = Units.Inches, DateCreated = DateTime.Now }; + } + + nestObj.Drawings.Add(drawing); + var plateObj = nestObj.CreatePlate(); + plateObj.Size = size; + plateObj.PartSpacing = s; + plateObj.Parts.AddRange(result.PlacedParts); + + var writer = new NestWriter(nestObj); + writer.Write(savedFilePath); + } + + db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath); + } + + txn.Commit(); + processed++; + if (processed % 10 == 0) Console.WriteLine($"Processed {processed}/{dxfFiles.Length} parts across all sheet sizes..."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error processing {file}: {ex.Message}"); + } + } + + Console.WriteLine($"Done! Brute-force data for {processed} parts saved to {dbPath}"); + return 0; +} + void PrintUsage() { Console.Error.WriteLine("Usage: OpenNest.Console [options]"); + Console.Error.WriteLine(" OpenNest.Console --collect [options]"); Console.Error.WriteLine(); Console.Error.WriteLine("Arguments:"); Console.Error.WriteLine(" nest-file Path to a .zip nest file"); Console.Error.WriteLine(); Console.Error.WriteLine("Options:"); Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); - Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); - Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); - Console.Error.WriteLine(" --spacing Override part spacing"); - Console.Error.WriteLine(" --size Override plate size (e.g. 120x60)"); - Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); - Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); - Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling"); - Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); - Console.Error.WriteLine(" --no-save Skip saving output file"); - Console.Error.WriteLine(" --no-log Skip writing debug log file"); - Console.Error.WriteLine(" -h, --help Show this help"); + Console.WriteLine(" --plate Plate index to fill (default: 0)"); + Console.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); + Console.WriteLine(" --spacing Override part spacing"); + Console.WriteLine(" --size Override plate size (e.g. 120x60)"); + Console.WriteLine(" --output Output nest file path (default: -result.zip)"); + Console.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); + Console.WriteLine(" --keep-parts Don't clear existing parts before filling"); + Console.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); + Console.WriteLine(" --no-save Skip saving output file"); + Console.WriteLine(" --no-log Skip writing debug log file"); + Console.WriteLine(" --collect Brute-force process all DXFs in directory to SQLite"); + Console.WriteLine(" --db Path to the SQLite training database (default: nesting_training.db)"); + Console.WriteLine(" --save-nests Directory to save individual .zip nests for each winner"); + Console.WriteLine(" --template Nest template (.nstdot) for plate defaults"); + Console.WriteLine(" -h, --help Show this help"); } From d6ffa77f35d9f7914921c574da9c5693fe7741a7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 12:39:24 -0400 Subject: [PATCH 011/116] feat(console): improve training data collection and best-fit persistence - Add verbose per-file and per-sheet-size console output during collection - Skip already-processed parts at the sheet-size level instead of all-or-nothing - Precompute best-fits once per part and reuse across all sheet sizes - Clear best-fit cache after each part to prevent memory growth - Save best-fits in separate bestfits/ zip entries instead of embedding in nest.json - Filter to Keep=true results only and scope to plate sizes in the nest - Set nest name to match filename (includes sheet size and part count) - Add TrainingDatabase with per-run skip logic and SQLite schema Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/Program.cs | 106 ++++++++++++++--- OpenNest.Console/TrainingDatabase.cs | 145 ++++++++++++++++++++++++ OpenNest.Engine/BestFit/BestFitCache.cs | 106 +++++++++++++++++ OpenNest.IO/NestFormat.cs | 27 +++++ OpenNest.IO/NestReader.cs | 50 ++++++++ OpenNest.IO/NestWriter.cs | 67 +++++++++++ OpenNest.IO/OpenNest.IO.csproj | 1 + collect-training-data.ps1 | 10 ++ 8 files changed, 497 insertions(+), 15 deletions(-) create mode 100644 OpenNest.Console/TrainingDatabase.cs create mode 100644 collect-training-data.ps1 diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 58bc9b1..8ec74e6 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -299,11 +299,6 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin Color.FromArgb(215, 130, 130), }; - var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories); - Console.WriteLine($"Found {dxfFiles.Length} DXF files. Initializing SQLite database at: {dbPath}"); - - using var db = new TrainingDatabase(dbPath); - var sheetSuite = new[] { new Size(96, 48), new Size(120, 48), new Size(144, 48), @@ -312,17 +307,48 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin new Size(48, 24), new Size(120, 10) }; + var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories); + Console.WriteLine($"Found {dxfFiles.Length} DXF files"); + Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}"); + Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations"); + Console.WriteLine($"Spacing: {s:F2}"); + if (saveDir != null) Console.WriteLine($"Saving nests to: {saveDir}"); + Console.WriteLine("---"); + + using var db = new TrainingDatabase(dbPath); + var importer = new DxfImporter(); var colorIndex = 0; var processed = 0; + var skippedGeometry = 0; + var skippedFeatures = 0; + var skippedExisting = 0; + var totalRuns = 0; + var totalSw = Stopwatch.StartNew(); foreach (var file in dxfFiles) { + var fileNum = processed + skippedGeometry + skippedFeatures + skippedExisting + 1; + var partNo = Path.GetFileNameWithoutExtension(file); + Console.Write($"[{fileNum}/{dxfFiles.Length}] {partNo}"); + try { - if (!importer.GetGeometry(file, out var entities)) continue; + var existingRuns = db.RunCount(Path.GetFileName(file)); + if (existingRuns >= sheetSuite.Length) + { + Console.WriteLine(" - SKIP (all sizes done)"); + skippedExisting++; + continue; + } + + if (!importer.GetGeometry(file, out var entities)) + { + Console.WriteLine(" - SKIP (no geometry)"); + skippedGeometry++; + continue; + } - var partNo = Path.GetFileNameWithoutExtension(file); var drawing = new Drawing(Path.GetFileName(file)); drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(entities); drawing.UpdateArea(); @@ -330,14 +356,38 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin colorIndex++; var features = FeatureExtractor.Extract(drawing); - if (features == null) continue; + if (features == null) + { + Console.WriteLine(" - SKIP (feature extraction failed)"); + skippedFeatures++; + continue; + } + + Console.WriteLine($" (area={features.Area:F1}, verts={features.VertexCount})"); + + // Precompute best-fits once for all sheet sizes. + var sizes = sheetSuite.Select(sz => (sz.Width, sz.Length)).ToList(); + var bfSw = Stopwatch.StartNew(); + BestFitCache.ComputeForSizes(drawing, s, sizes); + bfSw.Stop(); + Console.WriteLine($" Best-fits computed in {bfSw.ElapsedMilliseconds}ms"); using var txn = db.BeginTransaction(); var partId = db.GetOrAddPart(Path.GetFileName(file), features, drawing.Program.ToString()); + var partSw = Stopwatch.StartNew(); + var runsThisPart = 0; + var bestUtil = 0.0; + var bestCount = 0; foreach (var size in sheetSuite) { + if (db.HasRun(Path.GetFileName(file), size.Width, size.Length, s)) + { + Console.WriteLine($" {size.Length}x{size.Width} - skip (exists)"); + continue; + } + Plate runPlate; if (templateNest != null) { @@ -350,8 +400,23 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin runPlate = new Plate { Size = size, PartSpacing = s }; } + var sizeSw = Stopwatch.StartNew(); var result = BruteForceRunner.Run(drawing, runPlate); - if (result == null) continue; + sizeSw.Stop(); + + if (result == null) + { + Console.WriteLine($" {size.Length}x{size.Width} - no fit"); + continue; + } + + if (result.Utilization > bestUtil) + { + bestUtil = result.Utilization; + bestCount = result.PartCount; + } + + Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms"); string savedFilePath = null; if (saveDir != null) @@ -364,14 +429,15 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin var partDir = Path.Combine(saveDir, bucket, partNo); Directory.CreateDirectory(partDir); - var fileName = $"{partNo}-{size.Length}x{size.Width}-{result.PartCount}pcs.zip"; + var nestName = $"{partNo}-{size.Length}x{size.Width}-{result.PartCount}pcs"; + var fileName = nestName + ".zip"; savedFilePath = Path.Combine(partDir, fileName); // Create nest from template or from scratch Nest nestObj; if (templateNest != null) { - nestObj = new Nest(partNo) + nestObj = new Nest(nestName) { Units = templateNest.Units, DateCreated = DateTime.Now @@ -380,7 +446,7 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin } else { - nestObj = new Nest(partNo) { Units = Units.Inches, DateCreated = DateTime.Now }; + nestObj = new Nest(nestName) { Units = Units.Inches, DateCreated = DateTime.Now }; } nestObj.Drawings.Add(drawing); @@ -394,19 +460,29 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin } db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath); + runsThisPart++; + totalRuns++; } txn.Commit(); + BestFitCache.Invalidate(drawing); + partSw.Stop(); processed++; - if (processed % 10 == 0) Console.WriteLine($"Processed {processed}/{dxfFiles.Length} parts across all sheet sizes..."); + Console.WriteLine($" Total: {runsThisPart} runs, best={bestCount}pcs @ {bestUtil:P1}, {partSw.ElapsedMilliseconds}ms"); } catch (Exception ex) { - Console.Error.WriteLine($"Error processing {file}: {ex.Message}"); + Console.WriteLine(); + Console.Error.WriteLine($" ERROR: {ex.Message}"); } } - Console.WriteLine($"Done! Brute-force data for {processed} parts saved to {dbPath}"); + totalSw.Stop(); + Console.WriteLine("---"); + Console.WriteLine($"Processed: {processed} parts, {totalRuns} total runs"); + Console.WriteLine($"Skipped: {skippedExisting} (existing) + {skippedGeometry} (no geometry) + {skippedFeatures} (no features)"); + Console.WriteLine($"Time: {totalSw.Elapsed:h\\:mm\\:ss}"); + Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}"); return 0; } diff --git a/OpenNest.Console/TrainingDatabase.cs b/OpenNest.Console/TrainingDatabase.cs new file mode 100644 index 0000000..2a40f97 --- /dev/null +++ b/OpenNest.Console/TrainingDatabase.cs @@ -0,0 +1,145 @@ +using System; +using Microsoft.Data.Sqlite; +using OpenNest.Engine.ML; + +namespace OpenNest.Console +{ + public class TrainingDatabase : IDisposable + { + private readonly SqliteConnection _connection; + + public TrainingDatabase(string dbPath) + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWriteCreate + }.ToString(); + + _connection = new SqliteConnection(connectionString); + _connection.Open(); + + InitializeSchema(); + } + + private void InitializeSchema() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = @" + CREATE TABLE IF NOT EXISTS Parts ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + FileName TEXT, + Area REAL, + Convexity REAL, + AspectRatio REAL, + BBFill REAL, + Circularity REAL, + VertexCount INTEGER, + Bitmask BLOB, + GeometryData TEXT + ); + + CREATE TABLE IF NOT EXISTS Runs ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + PartId INTEGER, + SheetWidth REAL, + SheetHeight REAL, + Spacing REAL, + PartCount INTEGER, + Utilization REAL, + TimeMs INTEGER, + LayoutData TEXT, + FilePath TEXT, + FOREIGN KEY(PartId) REFERENCES Parts(Id) + ); + + CREATE INDEX IF NOT EXISTS idx_parts_filename ON Parts(FileName); + CREATE INDEX IF NOT EXISTS idx_runs_partid ON Runs(PartId); + "; + cmd.ExecuteNonQuery(); + } + + public long GetOrAddPart(string fileName, PartFeatures features, string geometryData) + { + // Check if part already exists + using (var checkCmd = _connection.CreateCommand()) + { + checkCmd.CommandText = "SELECT Id FROM Parts WHERE FileName = @name"; + checkCmd.Parameters.AddWithValue("@name", fileName); + var result = checkCmd.ExecuteScalar(); + if (result != null) return (long)result; + } + + // Add new part + using (var insertCmd = _connection.CreateCommand()) + { + insertCmd.CommandText = @" + INSERT INTO Parts (FileName, Area, Convexity, AspectRatio, BBFill, Circularity, VertexCount, Bitmask, GeometryData) + VALUES (@name, @area, @conv, @asp, @fill, @circ, @vert, @mask, @geo); + SELECT last_insert_rowid();"; + + insertCmd.Parameters.AddWithValue("@name", fileName); + insertCmd.Parameters.AddWithValue("@area", features.Area); + insertCmd.Parameters.AddWithValue("@conv", features.Convexity); + insertCmd.Parameters.AddWithValue("@asp", features.AspectRatio); + insertCmd.Parameters.AddWithValue("@fill", features.BoundingBoxFill); + insertCmd.Parameters.AddWithValue("@circ", features.Circularity); + insertCmd.Parameters.AddWithValue("@vert", features.VertexCount); + insertCmd.Parameters.AddWithValue("@mask", features.Bitmask); + insertCmd.Parameters.AddWithValue("@geo", geometryData); + + return (long)insertCmd.ExecuteScalar(); + } + } + + public bool HasRun(string fileName, double sheetWidth, double sheetHeight, double spacing) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = @"SELECT COUNT(*) FROM Runs r JOIN Parts p ON r.PartId = p.Id + WHERE p.FileName = @name AND r.SheetWidth = @w AND r.SheetHeight = @h AND r.Spacing = @s"; + cmd.Parameters.AddWithValue("@name", fileName); + cmd.Parameters.AddWithValue("@w", sheetWidth); + cmd.Parameters.AddWithValue("@h", sheetHeight); + cmd.Parameters.AddWithValue("@s", spacing); + return (long)cmd.ExecuteScalar() > 0; + } + + public int RunCount(string fileName) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM Runs r JOIN Parts p ON r.PartId = p.Id WHERE p.FileName = @name"; + cmd.Parameters.AddWithValue("@name", fileName); + return (int)(long)cmd.ExecuteScalar(); + } + + public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = @" + INSERT INTO Runs (PartId, SheetWidth, SheetHeight, Spacing, PartCount, Utilization, TimeMs, LayoutData, FilePath) + VALUES (@pid, @w, @h, @s, @cnt, @util, @time, @layout, @path)"; + + cmd.Parameters.AddWithValue("@pid", partId); + cmd.Parameters.AddWithValue("@w", w); + cmd.Parameters.AddWithValue("@h", h); + cmd.Parameters.AddWithValue("@s", s); + cmd.Parameters.AddWithValue("@cnt", result.PartCount); + cmd.Parameters.AddWithValue("@util", result.Utilization); + cmd.Parameters.AddWithValue("@time", result.TimeMs); + cmd.Parameters.AddWithValue("@layout", result.LayoutData ?? ""); + cmd.Parameters.AddWithValue("@path", filePath ?? ""); + + cmd.ExecuteNonQuery(); + } + + public SqliteTransaction BeginTransaction() + { + return _connection.BeginTransaction(); + } + + public void Dispose() + { + _connection?.Dispose(); + } + } +} diff --git a/OpenNest.Engine/BestFit/BestFitCache.cs b/OpenNest.Engine/BestFit/BestFitCache.cs index b4318a2..d7b1fc9 100644 --- a/OpenNest.Engine/BestFit/BestFitCache.cs +++ b/OpenNest.Engine/BestFit/BestFitCache.cs @@ -54,6 +54,93 @@ namespace OpenNest.Engine.BestFit } } + public static void ComputeForSizes( + Drawing drawing, double spacing, + IEnumerable<(double Width, double Height)> plateSizes) + { + // Skip sizes that are already cached. + var needed = new List<(double Width, double Height)>(); + foreach (var size in plateSizes) + { + var key = new CacheKey(drawing, size.Width, size.Height, spacing); + if (!_cache.ContainsKey(key)) + needed.Add(size); + } + + if (needed.Count == 0) + return; + + // Find the largest plate to use for the initial computation — this + // keeps the filter maximally permissive so we don't discard results + // that a smaller plate might still use after re-filtering. + var maxWidth = 0.0; + var maxHeight = 0.0; + foreach (var size in needed) + { + if (size.Width > maxWidth) maxWidth = size.Width; + if (size.Height > maxHeight) maxHeight = size.Height; + } + + IPairEvaluator evaluator = null; + ISlideComputer slideComputer = null; + + try + { + if (CreateEvaluator != null) + { + try { evaluator = CreateEvaluator(drawing, spacing); } + catch { /* fall back to default evaluator */ } + } + + if (CreateSlideComputer != null) + { + try { slideComputer = CreateSlideComputer(); } + catch { /* fall back to CPU slide computation */ } + } + + // Compute candidates and evaluate once with the largest plate. + var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer); + var baseResults = finder.FindBestFits(drawing, spacing, StepSize); + + // Cache a filtered copy for each plate size. + foreach (var size in needed) + { + var filter = new BestFitFilter + { + MaxPlateWidth = size.Width, + MaxPlateHeight = size.Height + }; + + var copy = new List(baseResults.Count); + for (var i = 0; i < baseResults.Count; i++) + { + var r = baseResults[i]; + copy.Add(new BestFitResult + { + Candidate = r.Candidate, + RotatedArea = r.RotatedArea, + BoundingWidth = r.BoundingWidth, + BoundingHeight = r.BoundingHeight, + OptimalRotation = r.OptimalRotation, + TrueArea = r.TrueArea, + HullAngles = r.HullAngles, + Keep = r.Keep, + Reason = r.Reason + }); + } + + filter.Apply(copy); + + var key = new CacheKey(drawing, size.Width, size.Height, spacing); + _cache.TryAdd(key, copy); + } + } + finally + { + (evaluator as IDisposable)?.Dispose(); + } + } + public static void Invalidate(Drawing drawing) { foreach (var key in _cache.Keys) @@ -63,6 +150,25 @@ namespace OpenNest.Engine.BestFit } } + public static void Populate(Drawing drawing, double plateWidth, double plateHeight, + double spacing, List results) + { + var key = new CacheKey(drawing, plateWidth, plateHeight, spacing); + _cache.TryAdd(key, results); + } + + public static Dictionary<(double PlateWidth, double PlateHeight, double Spacing), List> + GetAllForDrawing(Drawing drawing) + { + var result = new Dictionary<(double, double, double), List>(); + foreach (var kvp in _cache) + { + if (ReferenceEquals(kvp.Key.Drawing, drawing)) + result[(kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing)] = kvp.Value; + } + return result; + } + public static void Clear() { _cache.Clear(); diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index 6801a31..60a0097 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -122,5 +122,32 @@ namespace OpenNest.IO public double X { get; init; } public double Y { get; init; } } + + public record BestFitSetDto + { + public double PlateWidth { get; init; } + public double PlateHeight { get; init; } + public double Spacing { get; init; } + public List Results { get; init; } = new(); + } + + public record BestFitResultDto + { + public double Part1Rotation { get; init; } + public double Part2Rotation { get; init; } + public double Part2OffsetX { get; init; } + public double Part2OffsetY { get; init; } + public int StrategyType { get; init; } + public int TestNumber { get; init; } + public double CandidateSpacing { get; init; } + public double RotatedArea { get; init; } + public double BoundingWidth { get; init; } + public double BoundingHeight { get; init; } + public double OptimalRotation { get; init; } + public bool Keep { get; init; } + public string Reason { get; init; } = ""; + public double TrueArea { get; init; } + public List HullAngles { get; init; } = new(); + } } } diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 5ce5715..e88889e 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -6,6 +6,7 @@ using System.IO.Compression; using System.Linq; using System.Text.Json; using OpenNest.CNC; +using OpenNest.Engine.BestFit; using OpenNest.Geometry; using static OpenNest.IO.NestFormat; @@ -35,6 +36,7 @@ namespace OpenNest.IO var programs = ReadPrograms(dto.Drawings.Count); var drawingMap = BuildDrawings(dto, programs); + ReadBestFits(drawingMap); var nest = BuildNest(dto, drawingMap); zipArchive.Dispose(); @@ -97,6 +99,54 @@ namespace OpenNest.IO return map; } + private void ReadBestFits(Dictionary drawingMap) + { + foreach (var kvp in drawingMap) + { + var entry = zipArchive.GetEntry($"bestfits/bestfit-{kvp.Key}"); + if (entry == null) continue; + + using var entryStream = entry.Open(); + using var reader = new StreamReader(entryStream); + var json = reader.ReadToEnd(); + + var sets = JsonSerializer.Deserialize>(json, JsonOptions); + if (sets == null) continue; + + PopulateBestFitSets(kvp.Value, sets); + } + } + + private void PopulateBestFitSets(Drawing drawing, List sets) + { + foreach (var set in sets) + { + var results = set.Results.Select(r => new BestFitResult + { + Candidate = new PairCandidate + { + Drawing = drawing, + Part1Rotation = r.Part1Rotation, + Part2Rotation = r.Part2Rotation, + Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY), + StrategyType = r.StrategyType, + TestNumber = r.TestNumber, + Spacing = r.CandidateSpacing + }, + RotatedArea = r.RotatedArea, + BoundingWidth = r.BoundingWidth, + BoundingHeight = r.BoundingHeight, + OptimalRotation = r.OptimalRotation, + Keep = r.Keep, + Reason = r.Reason, + TrueArea = r.TrueArea, + HullAngles = r.HullAngles + }).ToList(); + + BestFitCache.Populate(drawing, set.PlateWidth, set.PlateHeight, set.Spacing, results); + } + } + private Nest BuildNest(NestDto dto, Dictionary drawingMap) { var nest = new Nest(); diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index 4202a3d..d9583c5 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -6,6 +6,8 @@ using System.Linq; using System.Text; using System.Text.Json; using OpenNest.CNC; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; using OpenNest.Math; using static OpenNest.IO.NestFormat; @@ -35,6 +37,7 @@ namespace OpenNest.IO WriteNestJson(zipArchive); WritePrograms(zipArchive); + WriteBestFits(zipArchive); return true; } @@ -185,6 +188,70 @@ namespace OpenNest.IO return list; } + private List BuildBestFitDtos(Drawing drawing) + { + var allBestFits = BestFitCache.GetAllForDrawing(drawing); + var sets = new List(); + + // Only save best-fit sets for plate sizes actually used in this nest. + var plateSizes = new HashSet<(double, double, double)>(); + foreach (var plate in nest.Plates) + plateSizes.Add((plate.Size.Width, plate.Size.Length, plate.PartSpacing)); + + foreach (var kvp in allBestFits) + { + if (!plateSizes.Contains((kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing))) + continue; + + var results = kvp.Value + .Where(r => r.Keep) + .Select(r => new BestFitResultDto + { + Part1Rotation = r.Candidate.Part1Rotation, + Part2Rotation = r.Candidate.Part2Rotation, + Part2OffsetX = r.Candidate.Part2Offset.X, + Part2OffsetY = r.Candidate.Part2Offset.Y, + StrategyType = r.Candidate.StrategyType, + TestNumber = r.Candidate.TestNumber, + CandidateSpacing = r.Candidate.Spacing, + RotatedArea = r.RotatedArea, + BoundingWidth = r.BoundingWidth, + BoundingHeight = r.BoundingHeight, + OptimalRotation = r.OptimalRotation, + Keep = r.Keep, + Reason = r.Reason ?? "", + TrueArea = r.TrueArea, + HullAngles = r.HullAngles ?? new List() + }).ToList(); + + sets.Add(new BestFitSetDto + { + PlateWidth = kvp.Key.PlateWidth, + PlateHeight = kvp.Key.PlateHeight, + Spacing = kvp.Key.Spacing, + Results = results + }); + } + + return sets; + } + + private void WriteBestFits(ZipArchive zipArchive) + { + foreach (var kvp in drawingDict.OrderBy(k => k.Key)) + { + var sets = BuildBestFitDtos(kvp.Value); + if (sets.Count == 0) + continue; + + var json = JsonSerializer.Serialize(sets, JsonOptions); + var entry = zipArchive.CreateEntry($"bestfits/bestfit-{kvp.Key}"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write(json); + } + } + private void WritePrograms(ZipArchive zipArchive) { foreach (var kvp in drawingDict.OrderBy(k => k.Key)) diff --git a/OpenNest.IO/OpenNest.IO.csproj b/OpenNest.IO/OpenNest.IO.csproj index cf96aaf..3314127 100644 --- a/OpenNest.IO/OpenNest.IO.csproj +++ b/OpenNest.IO/OpenNest.IO.csproj @@ -6,6 +6,7 @@ + diff --git a/collect-training-data.ps1 b/collect-training-data.ps1 new file mode 100644 index 0000000..ef4b9a4 --- /dev/null +++ b/collect-training-data.ps1 @@ -0,0 +1,10 @@ +param( + [Parameter(Mandatory, Position = 0)] + [string]$DxfDir +) + +$DbPath = Join-Path $PSScriptRoot 'test-training.db' +$SaveDir = 'X:\' +$Template = 'X:\Template.nstdot' + +dotnet run --project (Join-Path $PSScriptRoot 'OpenNest.Console') -- --collect $DxfDir --db $DbPath --save-nests $SaveDir --template $Template From 65ded42120886eecee9820236f1060872ab0d428 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 12:40:57 -0400 Subject: [PATCH 012/116] feat(core): add Program.ToString() and fix sub-program rotation origin Add G-code serialization via ToString() for debugging/ML workflows. Fix Rotate(angle, origin) to propagate origin to sub-programs instead of calling the single-argument overload. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/CNC/Program.cs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/OpenNest.Core/CNC/Program.cs b/OpenNest.Core/CNC/Program.cs index 0f31650..6f61c1b 100644 --- a/OpenNest.Core/CNC/Program.cs +++ b/OpenNest.Core/CNC/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using OpenNest.Converters; using OpenNest.Geometry; @@ -84,6 +84,23 @@ namespace OpenNest.CNC Rotation = Angle.NormalizeRad(Rotation + angle); } + public override string ToString() + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine(mode == Mode.Absolute ? "G90" : "G91"); + foreach (var code in Codes) + { + if (code is Motion m) + { + var cmd = m is RapidMove ? "G00" : (m is ArcMove am ? (am.Rotation == RotationType.CW ? "G02" : "G03") : "G01"); + sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}"); + if (m is ArcMove arc) sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}"); + sb.AppendLine(); + } + } + return sb.ToString(); + } + public virtual void Rotate(double angle, Vector origin) { var mode = Mode; @@ -99,7 +116,7 @@ namespace OpenNest.CNC var subpgm = (SubProgramCall)code; if (subpgm.Program != null) - subpgm.Program.Rotate(angle); + subpgm.Program.Rotate(angle, origin); } if (code is Motion == false) From a9aaab8337dfcab08f377ba549be26f647d5e5c0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 12:41:03 -0400 Subject: [PATCH 013/116] refactor: use ShapeProfile perimeter for boundary and intersection Replace shape-list iteration with ShapeProfile.Perimeter in both Part.Intersects and PartBoundary, simplifying the logic and ensuring only the outermost contour is used for collision detection. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Part.cs | 32 +++++++++++++------------------- OpenNest.Engine/PartBoundary.cs | 28 ++++++++++++++++------------ 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index e5e4adc..56598f4 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -149,31 +149,25 @@ namespace OpenNest pts = new List(); var entities1 = ConvertProgram.ToGeometry(Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); var entities2 = ConvertProgram.ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); - var shapes1 = Helper.GetShapes(entities1); - var shapes2 = Helper.GetShapes(entities2); + if (entities1.Count == 0 || entities2.Count == 0) + return false; - shapes1.ForEach(shape => shape.Offset(Location)); - shapes2.ForEach(shape => shape.Offset(part.Location)); + var perimeter1 = new ShapeProfile(entities1).Perimeter; + var perimeter2 = new ShapeProfile(entities2).Perimeter; - for (int i = 0; i < shapes1.Count; i++) - { - var shape1 = shapes1[i]; + if (perimeter1 == null || perimeter2 == null) + return false; - for (int j = 0; j < shapes2.Count; j++) - { - var shape2 = shapes2[j]; - List pts2; + perimeter1.Offset(Location); + perimeter2.Offset(part.Location); - if (shape1.Intersects(shape2, out pts2)) - pts.AddRange(pts2); - } - } - - return pts.Count > 0; + return perimeter1.Intersects(perimeter2, out pts); } public double Left diff --git a/OpenNest.Engine/PartBoundary.cs b/OpenNest.Engine/PartBoundary.cs index 4ab96fc..2545b93 100644 --- a/OpenNest.Engine/PartBoundary.cs +++ b/OpenNest.Engine/PartBoundary.cs @@ -23,22 +23,26 @@ namespace OpenNest public PartBoundary(Part part, double spacing) { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = Helper.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var entities = ConvertProgram.ToGeometry(part.Program) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + + var definedShape = new ShapeProfile(entities); + var perimeter = definedShape.Perimeter; _polygons = new List(); - foreach (var shape in shapes) + if (perimeter != null) { - var offsetEntity = shape.OffsetEntity(spacing, OffsetSide.Left) as Shape; + var offsetEntity = perimeter.OffsetEntity(spacing, OffsetSide.Left) as Shape; - if (offsetEntity == null) - continue; - - // Circumscribe arcs so polygon vertices are always outside - // the true arc — guarantees the boundary never under-estimates. - var polygon = offsetEntity.ToPolygonWithTolerance(PolygonTolerance, circumscribe: true); - polygon.RemoveSelfIntersections(); - _polygons.Add(polygon); + if (offsetEntity != null) + { + // Circumscribe arcs so polygon vertices are always outside + // the true arc — guarantees the boundary never under-estimates. + var polygon = offsetEntity.ToPolygonWithTolerance(PolygonTolerance, circumscribe: true); + polygon.RemoveSelfIntersections(); + _polygons.Add(polygon); + } } PrecomputeDirectionalEdges( From 10f9b5357cac875a4053e9b5168bf59b53633782 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 12:41:08 -0400 Subject: [PATCH 014/116] fix(engine): compute unused area from actual part areas Calculate unused plate area by subtracting total part area from the work area instead of relying on FillScore.UsableRemnantArea, which could over-report available space. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index a74e8ca..ca39d10 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -723,9 +723,13 @@ namespace OpenNest var score = FillScore.Compute(best, workArea); var clonedParts = new List(best.Count); + var totalPartArea = 0.0; foreach (var part in best) + { clonedParts.Add((Part)part.Clone()); + totalPartArea += part.BaseDrawing.Area; + } progress.Report(new NestProgress { @@ -733,7 +737,7 @@ namespace OpenNest PlateNumber = plateNumber, BestPartCount = score.Count, BestDensity = score.Density, - UsableRemnantArea = score.UsableRemnantArea, + UsableRemnantArea = workArea.Area() - totalPartArea, BestParts = clonedParts }); } From 1db51b1cce4610fa572c1d63b45b68f2cb315d82 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 12:41:14 -0400 Subject: [PATCH 015/116] feat(ui): add elapsed timer to nest progress form Show a live elapsed-time counter that updates every second during nesting. Rename "Remnant" label to "Unused" for clarity. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Forms/NestProgressForm.Designer.cs | 30 ++++++++++++++++++--- OpenNest/Forms/NestProgressForm.cs | 26 ++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/OpenNest/Forms/NestProgressForm.Designer.cs b/OpenNest/Forms/NestProgressForm.Designer.cs index 573e08f..6670048 100644 --- a/OpenNest/Forms/NestProgressForm.Designer.cs +++ b/OpenNest/Forms/NestProgressForm.Designer.cs @@ -39,6 +39,8 @@ namespace OpenNest.Forms this.densityValue = new System.Windows.Forms.Label(); this.remnantLabel = new System.Windows.Forms.Label(); this.remnantValue = new System.Windows.Forms.Label(); + this.elapsedLabel = new System.Windows.Forms.Label(); + this.elapsedValue = new System.Windows.Forms.Label(); this.stopButton = new System.Windows.Forms.Button(); this.buttonPanel = new System.Windows.Forms.FlowLayoutPanel(); this.table.SuspendLayout(); @@ -60,18 +62,21 @@ namespace OpenNest.Forms this.table.Controls.Add(this.densityValue, 1, 3); this.table.Controls.Add(this.remnantLabel, 0, 4); this.table.Controls.Add(this.remnantValue, 1, 4); + this.table.Controls.Add(this.elapsedLabel, 0, 5); + this.table.Controls.Add(this.elapsedValue, 1, 5); this.table.Dock = System.Windows.Forms.DockStyle.Top; this.table.Location = new System.Drawing.Point(0, 0); this.table.Name = "table"; this.table.Padding = new System.Windows.Forms.Padding(8); - this.table.RowCount = 5; + this.table.RowCount = 6; + this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); this.table.AutoSize = true; - this.table.Size = new System.Drawing.Size(264, 130); + this.table.Size = new System.Drawing.Size(264, 156); this.table.TabIndex = 0; // // phaseLabel @@ -140,7 +145,7 @@ namespace OpenNest.Forms this.remnantLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); this.remnantLabel.Margin = new System.Windows.Forms.Padding(4); this.remnantLabel.Name = "remnantLabel"; - this.remnantLabel.Text = "Remnant:"; + this.remnantLabel.Text = "Unused:"; // // remnantValue // @@ -149,6 +154,21 @@ namespace OpenNest.Forms this.remnantValue.Name = "remnantValue"; this.remnantValue.Text = "\u2014"; // + // elapsedLabel + // + this.elapsedLabel.AutoSize = true; + this.elapsedLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); + this.elapsedLabel.Margin = new System.Windows.Forms.Padding(4); + this.elapsedLabel.Name = "elapsedLabel"; + this.elapsedLabel.Text = "Elapsed:"; + // + // elapsedValue + // + this.elapsedValue.AutoSize = true; + this.elapsedValue.Margin = new System.Windows.Forms.Padding(4); + this.elapsedValue.Name = "elapsedValue"; + this.elapsedValue.Text = "0:00"; + // // stopButton // this.stopButton.Anchor = System.Windows.Forms.AnchorStyles.None; @@ -174,7 +194,7 @@ namespace OpenNest.Forms // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(264, 181); + this.ClientSize = new System.Drawing.Size(264, 207); this.Controls.Add(this.buttonPanel); this.Controls.Add(this.table); this.Controls.SetChildIndex(this.table, 0); @@ -206,6 +226,8 @@ namespace OpenNest.Forms private System.Windows.Forms.Label densityValue; private System.Windows.Forms.Label remnantLabel; private System.Windows.Forms.Label remnantValue; + private System.Windows.Forms.Label elapsedLabel; + private System.Windows.Forms.Label elapsedValue; private System.Windows.Forms.Button stopButton; private System.Windows.Forms.FlowLayoutPanel buttonPanel; } diff --git a/OpenNest/Forms/NestProgressForm.cs b/OpenNest/Forms/NestProgressForm.cs index 6e51a24..79813c8 100644 --- a/OpenNest/Forms/NestProgressForm.cs +++ b/OpenNest/Forms/NestProgressForm.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Threading; using System.Windows.Forms; @@ -7,6 +8,8 @@ namespace OpenNest.Forms public partial class NestProgressForm : Form { private readonly CancellationTokenSource cts; + private readonly Stopwatch stopwatch = Stopwatch.StartNew(); + private readonly System.Windows.Forms.Timer elapsedTimer; public NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true) { @@ -18,6 +21,10 @@ namespace OpenNest.Forms plateLabel.Visible = false; plateValue.Visible = false; } + + elapsedTimer = new System.Windows.Forms.Timer { Interval = 1000 }; + elapsedTimer.Tick += (s, e) => UpdateElapsed(); + elapsedTimer.Start(); } public void UpdateProgress(NestProgress progress) @@ -37,6 +44,10 @@ namespace OpenNest.Forms if (IsDisposed || !IsHandleCreated) return; + stopwatch.Stop(); + elapsedTimer.Stop(); + UpdateElapsed(); + phaseValue.Text = "Done"; stopButton.Text = "Close"; stopButton.Enabled = true; @@ -44,6 +55,17 @@ namespace OpenNest.Forms stopButton.Click += (s, e) => Close(); } + private void UpdateElapsed() + { + if (IsDisposed || !IsHandleCreated) + return; + + var elapsed = stopwatch.Elapsed; + elapsedValue.Text = elapsed.TotalHours >= 1 + ? elapsed.ToString(@"h\:mm\:ss") + : elapsed.ToString(@"m\:ss"); + } + private void StopButton_Click(object sender, EventArgs e) { cts.Cancel(); @@ -53,6 +75,10 @@ namespace OpenNest.Forms protected override void OnFormClosing(FormClosingEventArgs e) { + elapsedTimer.Stop(); + elapsedTimer.Dispose(); + stopwatch.Stop(); + if (!cts.IsCancellationRequested) cts.Cancel(); From 321c476b8beca373eeee2d8f1dde779abf64dab2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 12:41:20 -0400 Subject: [PATCH 016/116] feat(engine): add ML feature extraction and brute-force runner Add FeatureExtractor for computing geometric part features (convexity, aspect ratio, circularity, bitmask) and BruteForceRunner for generating training data by running the fill engine and recording results. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/ML/BruteForceRunner.cs | 53 ++++++++++++++++ OpenNest.Engine/ML/FeatureExtractor.cs | 88 ++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 OpenNest.Engine/ML/BruteForceRunner.cs create mode 100644 OpenNest.Engine/ML/FeatureExtractor.cs diff --git a/OpenNest.Engine/ML/BruteForceRunner.cs b/OpenNest.Engine/ML/BruteForceRunner.cs new file mode 100644 index 0000000..c3d8c59 --- /dev/null +++ b/OpenNest.Engine/ML/BruteForceRunner.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Engine.ML +{ + public class BruteForceResult + { + public int PartCount { get; set; } + public double Utilization { get; set; } + public long TimeMs { get; set; } + public string LayoutData { get; set; } + public List PlacedParts { get; set; } + } + + public static class BruteForceRunner + { + public static BruteForceResult Run(Drawing drawing, Plate plate) + { + var engine = new NestEngine(plate); + var item = new NestItem { Drawing = drawing }; + + var sw = Stopwatch.StartNew(); + var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None); + sw.Stop(); + + if (parts == null || parts.Count == 0) + return null; + + return new BruteForceResult + { + PartCount = parts.Count, + Utilization = CalculateUtilization(parts, plate.Area()), + TimeMs = sw.ElapsedMilliseconds, + LayoutData = SerializeLayout(parts), + PlacedParts = parts + }; + } + + private static string SerializeLayout(List parts) + { + var data = parts.Select(p => new { X = p.Location.X, Y = p.Location.Y, R = p.Rotation }).ToList(); + return System.Text.Json.JsonSerializer.Serialize(data); + } + + private static double CalculateUtilization(List parts, double plateArea) + { + if (plateArea <= 0) return 0; + return parts.Sum(p => p.BaseDrawing.Area) / plateArea; + } + } +} diff --git a/OpenNest.Engine/ML/FeatureExtractor.cs b/OpenNest.Engine/ML/FeatureExtractor.cs new file mode 100644 index 0000000..640c723 --- /dev/null +++ b/OpenNest.Engine/ML/FeatureExtractor.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Engine.ML +{ + public class PartFeatures + { + // --- Geometric Features --- + public double Area { get; set; } + public double Convexity { get; set; } // Area / Convex Hull Area + public double AspectRatio { get; set; } // Width / Length + public double BoundingBoxFill { get; set; } // Area / (Width * Length) + public double Circularity { get; set; } // 4 * PI * Area / Perimeter^2 + public int VertexCount { get; set; } + + // --- Normalized Bitmask (32x32 = 1024 features) --- + public byte[] Bitmask { get; set; } + + public override string ToString() + { + return $"{Area:F2},{Convexity:F4},{AspectRatio:F4},{BoundingBoxFill:F4},{Circularity:F4},{VertexCount}"; + } + } + + public static class FeatureExtractor + { + public static PartFeatures Extract(Drawing drawing) + { + var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + + var profile = new ShapeProfile(entities); + var perimeter = profile.Perimeter; + + if (perimeter == null) return null; + + var polygon = perimeter.ToPolygonWithTolerance(0.01); + polygon.UpdateBounds(); + var bb = polygon.BoundingBox; + + var hull = ConvexHull.Compute(polygon.Vertices); + var hullArea = hull.Area(); + + var features = new PartFeatures + { + Area = drawing.Area, + Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0), + AspectRatio = bb.Width / (bb.Length > 0 ? bb.Length : 1.0), + BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0), + VertexCount = polygon.Vertices.Count, + Bitmask = GenerateBitmask(polygon, 32) + }; + + // Circularity = 4 * PI * Area / Perimeter^2 + var perimeterLen = polygon.Perimeter(); + features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen); + + return features; + } + + private static byte[] GenerateBitmask(Polygon polygon, int size) + { + var mask = new byte[size * size]; + polygon.UpdateBounds(); + var bb = polygon.BoundingBox; + + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + // Map grid coordinate (0..size) to bounding box coordinate + var px = bb.Left + (x + 0.5) * (bb.Width / size); + var py = bb.Bottom + (y + 0.5) * (bb.Length / size); + + if (polygon.ContainsPoint(new Vector(px, py))) + { + mask[y * size + x] = 1; + } + } + } + + return mask; + } + } +} From bf104309b445bbabef76f3abe3c9ca73dfc2d57b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 13:15:00 -0400 Subject: [PATCH 017/116] chore: ignore SQLite database files Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 6a339e1..e2d4b45 100644 --- a/.gitignore +++ b/.gitignore @@ -202,5 +202,9 @@ FakesAssemblies/ # Git worktrees .worktrees/ +# SQLite databases +*.db +*.db-journal + # Claude Code .claude/ From 74272bea8073e2f3f0c148ac6f60f207e8566a25 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 14:41:30 -0400 Subject: [PATCH 018/116] feat(engine): add perimeter-to-area ratio to part features Adds PerimeterToAreaRatio (perimeter / area) as a spacing sensitivity indicator for ML feature extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/ML/FeatureExtractor.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/OpenNest.Engine/ML/FeatureExtractor.cs b/OpenNest.Engine/ML/FeatureExtractor.cs index 640c723..c940105 100644 --- a/OpenNest.Engine/ML/FeatureExtractor.cs +++ b/OpenNest.Engine/ML/FeatureExtractor.cs @@ -13,6 +13,7 @@ namespace OpenNest.Engine.ML public double AspectRatio { get; set; } // Width / Length public double BoundingBoxFill { get; set; } // Area / (Width * Length) public double Circularity { get; set; } // 4 * PI * Area / Perimeter^2 + public double PerimeterToAreaRatio { get; set; } // Perimeter / Area — spacing sensitivity public int VertexCount { get; set; } // --- Normalized Bitmask (32x32 = 1024 features) --- @@ -20,7 +21,7 @@ namespace OpenNest.Engine.ML public override string ToString() { - return $"{Area:F2},{Convexity:F4},{AspectRatio:F4},{BoundingBoxFill:F4},{Circularity:F4},{VertexCount}"; + return $"{Area:F2},{Convexity:F4},{AspectRatio:F4},{BoundingBoxFill:F4},{Circularity:F4},{PerimeterToAreaRatio:F4},{VertexCount}"; } } @@ -57,6 +58,7 @@ namespace OpenNest.Engine.ML // Circularity = 4 * PI * Area / Perimeter^2 var perimeterLen = polygon.Perimeter(); features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen); + features.PerimeterToAreaRatio = drawing.Area > 0 ? perimeterLen / drawing.Area : 0; return features; } From acc75868c04e5f43bc11f0fe4900423fb2f2d1c2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 14:41:38 -0400 Subject: [PATCH 019/116] refactor: extract training data collection into OpenNest.Training Move brute-force data collection, TrainingDatabase, and GPU init from OpenNest.Console into a dedicated OpenNest.Training project. Replaces raw Microsoft.Data.Sqlite with EF Core. Console is now a pure nesting CLI with template support and cleaned-up usage output. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/OpenNest.Console.csproj | 4 - OpenNest.Console/Program.cs | 315 +++----------------- OpenNest.Console/TrainingDatabase.cs | 145 --------- OpenNest.Training/Data/TrainingDbContext.cs | 38 +++ OpenNest.Training/Data/TrainingPart.cs | 28 ++ OpenNest.Training/Data/TrainingRun.cs | 25 ++ OpenNest.Training/OpenNest.Training.csproj | 19 ++ OpenNest.Training/Program.cs | 300 +++++++++++++++++++ OpenNest.Training/TrainingDatabase.cs | 131 ++++++++ OpenNest.sln | 14 + 10 files changed, 591 insertions(+), 428 deletions(-) delete mode 100644 OpenNest.Console/TrainingDatabase.cs create mode 100644 OpenNest.Training/Data/TrainingDbContext.cs create mode 100644 OpenNest.Training/Data/TrainingPart.cs create mode 100644 OpenNest.Training/Data/TrainingRun.cs create mode 100644 OpenNest.Training/OpenNest.Training.csproj create mode 100644 OpenNest.Training/Program.cs create mode 100644 OpenNest.Training/TrainingDatabase.cs diff --git a/OpenNest.Console/OpenNest.Console.csproj b/OpenNest.Console/OpenNest.Console.csproj index f1a0806..dfb174b 100644 --- a/OpenNest.Console/OpenNest.Console.csproj +++ b/OpenNest.Console/OpenNest.Console.csproj @@ -10,9 +10,5 @@ - - - - diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 8ec74e6..24451ee 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -6,11 +6,6 @@ using System.Linq; using OpenNest; using OpenNest.Geometry; using OpenNest.IO; -using Color = System.Drawing.Color; -using OpenNest.Console; -using OpenNest.Engine.BestFit; -using OpenNest.Engine.ML; -using OpenNest.Gpu; // Parse arguments. var nestFile = (string)null; @@ -26,27 +21,12 @@ var noSave = false; var noLog = false; var keepParts = false; var autoNest = false; -var collectDataDir = (string)null; -var dbPath = "nesting_training.db"; -var saveNestsDir = (string)null; var templateFile = (string)null; for (var i = 0; i < args.Length; i++) { switch (args[i]) { - case "--db" when i + 1 < args.Length: - dbPath = args[++i]; - break; - case "--save-nests" when i + 1 < args.Length: - saveNestsDir = args[++i]; - break; - case "--template" when i + 1 < args.Length: - templateFile = args[++i]; - break; - case "--collect" when i + 1 < args.Length: - collectDataDir = args[++i]; - break; case "--drawing" when i + 1 < args.Length: drawingName = args[++i]; break; @@ -82,6 +62,9 @@ for (var i = 0; i < args.Length; i++) case "--keep-parts": keepParts = true; break; + case "--template" when i + 1 < args.Length: + templateFile = args[++i]; + break; case "--autonest": autoNest = true; break; @@ -96,22 +79,6 @@ for (var i = 0; i < args.Length; i++) } } -// Initialize GPU if available. -if (GpuEvaluatorFactory.GpuAvailable) -{ - BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer(); - Console.WriteLine($"GPU: {GpuEvaluatorFactory.DeviceName}"); -} -else -{ - Console.WriteLine("GPU: not available (using CPU)"); -} - -if (collectDataDir != null) -{ - return RunDataCollection(collectDataDir, dbPath, saveNestsDir, spacing ?? 0.5, templateFile); -} - if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile)) { PrintUsage(); @@ -149,6 +116,24 @@ if (plateIndex >= nest.Plates.Count) var plate = nest.Plates[plateIndex]; +// Apply template defaults. +if (templateFile != null) +{ + if (!File.Exists(templateFile)) + { + Console.Error.WriteLine($"Error: Template not found: {templateFile}"); + return 1; + } + var templateNest = new NestReader(templateFile).Read(); + var templatePlate = templateNest.PlateDefaults.CreateNew(); + plate.Thickness = templatePlate.Thickness; + plate.Quadrant = templatePlate.Quadrant; + plate.Material = templatePlate.Material; + plate.EdgeSpacing = templatePlate.EdgeSpacing; + plate.PartSpacing = templatePlate.PartSpacing; + Console.WriteLine($"Template: {templateFile}"); +} + // Apply overrides. if (spacing.HasValue) plate.PartSpacing = spacing.Value; @@ -207,9 +192,9 @@ if (autoNest) Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); - var parts = NestEngine.AutoNest(nestItems, plate); - plate.Parts.AddRange(parts); - success = parts.Count > 0; + var nestParts = NestEngine.AutoNest(nestItems, plate); + plate.Parts.AddRange(nestParts); + success = nestParts.Count > 0; } else { @@ -262,253 +247,25 @@ if (!noSave) return checkOverlaps && overlapCount > 0 ? 1 : 0; -int RunDataCollection(string dir, string dbPath, string saveDir, double s, string template) -{ - if (!Directory.Exists(dir)) - { - Console.Error.WriteLine($"Error: Directory not found: {dir}"); - return 1; - } - - // Load template nest for plate defaults if provided. - Nest templateNest = null; - if (template != null) - { - if (!File.Exists(template)) - { - Console.Error.WriteLine($"Error: Template not found: {template}"); - return 1; - } - templateNest = new NestReader(template).Read(); - Console.WriteLine($"Using template: {template}"); - } - - var PartColors = new[] - { - Color.FromArgb(205, 92, 92), - Color.FromArgb(148, 103, 189), - Color.FromArgb(75, 180, 175), - Color.FromArgb(210, 190, 75), - Color.FromArgb(190, 85, 175), - Color.FromArgb(185, 115, 85), - Color.FromArgb(120, 100, 190), - Color.FromArgb(200, 100, 140), - Color.FromArgb(80, 175, 155), - Color.FromArgb(195, 160, 85), - Color.FromArgb(175, 95, 160), - Color.FromArgb(215, 130, 130), - }; - - var sheetSuite = new[] - { - new Size(96, 48), new Size(120, 48), new Size(144, 48), - new Size(96, 60), new Size(120, 60), new Size(144, 60), - new Size(96, 72), new Size(120, 72), new Size(144, 72), - new Size(48, 24), new Size(120, 10) - }; - - var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories); - Console.WriteLine($"Found {dxfFiles.Length} DXF files"); - Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}"); - Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations"); - Console.WriteLine($"Spacing: {s:F2}"); - if (saveDir != null) Console.WriteLine($"Saving nests to: {saveDir}"); - Console.WriteLine("---"); - - using var db = new TrainingDatabase(dbPath); - - var importer = new DxfImporter(); - var colorIndex = 0; - var processed = 0; - var skippedGeometry = 0; - var skippedFeatures = 0; - var skippedExisting = 0; - var totalRuns = 0; - var totalSw = Stopwatch.StartNew(); - - foreach (var file in dxfFiles) - { - var fileNum = processed + skippedGeometry + skippedFeatures + skippedExisting + 1; - var partNo = Path.GetFileNameWithoutExtension(file); - Console.Write($"[{fileNum}/{dxfFiles.Length}] {partNo}"); - - try - { - var existingRuns = db.RunCount(Path.GetFileName(file)); - if (existingRuns >= sheetSuite.Length) - { - Console.WriteLine(" - SKIP (all sizes done)"); - skippedExisting++; - continue; - } - - if (!importer.GetGeometry(file, out var entities)) - { - Console.WriteLine(" - SKIP (no geometry)"); - skippedGeometry++; - continue; - } - - var drawing = new Drawing(Path.GetFileName(file)); - drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(entities); - drawing.UpdateArea(); - drawing.Color = PartColors[colorIndex % PartColors.Length]; - colorIndex++; - - var features = FeatureExtractor.Extract(drawing); - if (features == null) - { - Console.WriteLine(" - SKIP (feature extraction failed)"); - skippedFeatures++; - continue; - } - - Console.WriteLine($" (area={features.Area:F1}, verts={features.VertexCount})"); - - // Precompute best-fits once for all sheet sizes. - var sizes = sheetSuite.Select(sz => (sz.Width, sz.Length)).ToList(); - var bfSw = Stopwatch.StartNew(); - BestFitCache.ComputeForSizes(drawing, s, sizes); - bfSw.Stop(); - Console.WriteLine($" Best-fits computed in {bfSw.ElapsedMilliseconds}ms"); - - using var txn = db.BeginTransaction(); - - var partId = db.GetOrAddPart(Path.GetFileName(file), features, drawing.Program.ToString()); - var partSw = Stopwatch.StartNew(); - var runsThisPart = 0; - var bestUtil = 0.0; - var bestCount = 0; - - foreach (var size in sheetSuite) - { - if (db.HasRun(Path.GetFileName(file), size.Width, size.Length, s)) - { - Console.WriteLine($" {size.Length}x{size.Width} - skip (exists)"); - continue; - } - - Plate runPlate; - if (templateNest != null) - { - runPlate = templateNest.PlateDefaults.CreateNew(); - runPlate.Size = size; - runPlate.PartSpacing = s; - } - else - { - runPlate = new Plate { Size = size, PartSpacing = s }; - } - - var sizeSw = Stopwatch.StartNew(); - var result = BruteForceRunner.Run(drawing, runPlate); - sizeSw.Stop(); - - if (result == null) - { - Console.WriteLine($" {size.Length}x{size.Width} - no fit"); - continue; - } - - if (result.Utilization > bestUtil) - { - bestUtil = result.Utilization; - bestCount = result.PartCount; - } - - Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms"); - - string savedFilePath = null; - if (saveDir != null) - { - // Deterministic bucket (00-FF) based on filename hash - uint hash = 0; - foreach (char c in partNo) hash = (hash * 31) + c; - var bucket = (hash % 256).ToString("X2"); - - var partDir = Path.Combine(saveDir, bucket, partNo); - Directory.CreateDirectory(partDir); - - var nestName = $"{partNo}-{size.Length}x{size.Width}-{result.PartCount}pcs"; - var fileName = nestName + ".zip"; - savedFilePath = Path.Combine(partDir, fileName); - - // Create nest from template or from scratch - Nest nestObj; - if (templateNest != null) - { - nestObj = new Nest(nestName) - { - Units = templateNest.Units, - DateCreated = DateTime.Now - }; - nestObj.PlateDefaults.SetFromExisting(templateNest.PlateDefaults.CreateNew()); - } - else - { - nestObj = new Nest(nestName) { Units = Units.Inches, DateCreated = DateTime.Now }; - } - - nestObj.Drawings.Add(drawing); - var plateObj = nestObj.CreatePlate(); - plateObj.Size = size; - plateObj.PartSpacing = s; - plateObj.Parts.AddRange(result.PlacedParts); - - var writer = new NestWriter(nestObj); - writer.Write(savedFilePath); - } - - db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath); - runsThisPart++; - totalRuns++; - } - - txn.Commit(); - BestFitCache.Invalidate(drawing); - partSw.Stop(); - processed++; - Console.WriteLine($" Total: {runsThisPart} runs, best={bestCount}pcs @ {bestUtil:P1}, {partSw.ElapsedMilliseconds}ms"); - } - catch (Exception ex) - { - Console.WriteLine(); - Console.Error.WriteLine($" ERROR: {ex.Message}"); - } - } - - totalSw.Stop(); - Console.WriteLine("---"); - Console.WriteLine($"Processed: {processed} parts, {totalRuns} total runs"); - Console.WriteLine($"Skipped: {skippedExisting} (existing) + {skippedGeometry} (no geometry) + {skippedFeatures} (no features)"); - Console.WriteLine($"Time: {totalSw.Elapsed:h\\:mm\\:ss}"); - Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}"); - return 0; -} - void PrintUsage() { Console.Error.WriteLine("Usage: OpenNest.Console [options]"); - Console.Error.WriteLine(" OpenNest.Console --collect [options]"); Console.Error.WriteLine(); Console.Error.WriteLine("Arguments:"); Console.Error.WriteLine(" nest-file Path to a .zip nest file"); Console.Error.WriteLine(); Console.Error.WriteLine("Options:"); Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); - Console.WriteLine(" --plate Plate index to fill (default: 0)"); - Console.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); - Console.WriteLine(" --spacing Override part spacing"); - Console.WriteLine(" --size Override plate size (e.g. 120x60)"); - Console.WriteLine(" --output Output nest file path (default: -result.zip)"); - Console.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); - Console.WriteLine(" --keep-parts Don't clear existing parts before filling"); - Console.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); - Console.WriteLine(" --no-save Skip saving output file"); - Console.WriteLine(" --no-log Skip writing debug log file"); - Console.WriteLine(" --collect Brute-force process all DXFs in directory to SQLite"); - Console.WriteLine(" --db Path to the SQLite training database (default: nesting_training.db)"); - Console.WriteLine(" --save-nests Directory to save individual .zip nests for each winner"); - Console.WriteLine(" --template Nest template (.nstdot) for plate defaults"); - Console.WriteLine(" -h, --help Show this help"); + Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); + Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); + Console.Error.WriteLine(" --spacing Override part spacing"); + Console.Error.WriteLine(" --size Override plate size (e.g. 120x60)"); + Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); + Console.Error.WriteLine(" --template Nest template for plate defaults (thickness, quadrant, material, spacing)"); + Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); + Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling"); + Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); + Console.Error.WriteLine(" --no-save Skip saving output file"); + Console.Error.WriteLine(" --no-log Skip writing debug log file"); + Console.Error.WriteLine(" -h, --help Show this help"); } diff --git a/OpenNest.Console/TrainingDatabase.cs b/OpenNest.Console/TrainingDatabase.cs deleted file mode 100644 index 2a40f97..0000000 --- a/OpenNest.Console/TrainingDatabase.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System; -using Microsoft.Data.Sqlite; -using OpenNest.Engine.ML; - -namespace OpenNest.Console -{ - public class TrainingDatabase : IDisposable - { - private readonly SqliteConnection _connection; - - public TrainingDatabase(string dbPath) - { - var connectionString = new SqliteConnectionStringBuilder - { - DataSource = dbPath, - Mode = SqliteOpenMode.ReadWriteCreate - }.ToString(); - - _connection = new SqliteConnection(connectionString); - _connection.Open(); - - InitializeSchema(); - } - - private void InitializeSchema() - { - using var cmd = _connection.CreateCommand(); - cmd.CommandText = @" - CREATE TABLE IF NOT EXISTS Parts ( - Id INTEGER PRIMARY KEY AUTOINCREMENT, - FileName TEXT, - Area REAL, - Convexity REAL, - AspectRatio REAL, - BBFill REAL, - Circularity REAL, - VertexCount INTEGER, - Bitmask BLOB, - GeometryData TEXT - ); - - CREATE TABLE IF NOT EXISTS Runs ( - Id INTEGER PRIMARY KEY AUTOINCREMENT, - PartId INTEGER, - SheetWidth REAL, - SheetHeight REAL, - Spacing REAL, - PartCount INTEGER, - Utilization REAL, - TimeMs INTEGER, - LayoutData TEXT, - FilePath TEXT, - FOREIGN KEY(PartId) REFERENCES Parts(Id) - ); - - CREATE INDEX IF NOT EXISTS idx_parts_filename ON Parts(FileName); - CREATE INDEX IF NOT EXISTS idx_runs_partid ON Runs(PartId); - "; - cmd.ExecuteNonQuery(); - } - - public long GetOrAddPart(string fileName, PartFeatures features, string geometryData) - { - // Check if part already exists - using (var checkCmd = _connection.CreateCommand()) - { - checkCmd.CommandText = "SELECT Id FROM Parts WHERE FileName = @name"; - checkCmd.Parameters.AddWithValue("@name", fileName); - var result = checkCmd.ExecuteScalar(); - if (result != null) return (long)result; - } - - // Add new part - using (var insertCmd = _connection.CreateCommand()) - { - insertCmd.CommandText = @" - INSERT INTO Parts (FileName, Area, Convexity, AspectRatio, BBFill, Circularity, VertexCount, Bitmask, GeometryData) - VALUES (@name, @area, @conv, @asp, @fill, @circ, @vert, @mask, @geo); - SELECT last_insert_rowid();"; - - insertCmd.Parameters.AddWithValue("@name", fileName); - insertCmd.Parameters.AddWithValue("@area", features.Area); - insertCmd.Parameters.AddWithValue("@conv", features.Convexity); - insertCmd.Parameters.AddWithValue("@asp", features.AspectRatio); - insertCmd.Parameters.AddWithValue("@fill", features.BoundingBoxFill); - insertCmd.Parameters.AddWithValue("@circ", features.Circularity); - insertCmd.Parameters.AddWithValue("@vert", features.VertexCount); - insertCmd.Parameters.AddWithValue("@mask", features.Bitmask); - insertCmd.Parameters.AddWithValue("@geo", geometryData); - - return (long)insertCmd.ExecuteScalar(); - } - } - - public bool HasRun(string fileName, double sheetWidth, double sheetHeight, double spacing) - { - using var cmd = _connection.CreateCommand(); - cmd.CommandText = @"SELECT COUNT(*) FROM Runs r JOIN Parts p ON r.PartId = p.Id - WHERE p.FileName = @name AND r.SheetWidth = @w AND r.SheetHeight = @h AND r.Spacing = @s"; - cmd.Parameters.AddWithValue("@name", fileName); - cmd.Parameters.AddWithValue("@w", sheetWidth); - cmd.Parameters.AddWithValue("@h", sheetHeight); - cmd.Parameters.AddWithValue("@s", spacing); - return (long)cmd.ExecuteScalar() > 0; - } - - public int RunCount(string fileName) - { - using var cmd = _connection.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM Runs r JOIN Parts p ON r.PartId = p.Id WHERE p.FileName = @name"; - cmd.Parameters.AddWithValue("@name", fileName); - return (int)(long)cmd.ExecuteScalar(); - } - - public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath) - { - using var cmd = _connection.CreateCommand(); - cmd.CommandText = @" - INSERT INTO Runs (PartId, SheetWidth, SheetHeight, Spacing, PartCount, Utilization, TimeMs, LayoutData, FilePath) - VALUES (@pid, @w, @h, @s, @cnt, @util, @time, @layout, @path)"; - - cmd.Parameters.AddWithValue("@pid", partId); - cmd.Parameters.AddWithValue("@w", w); - cmd.Parameters.AddWithValue("@h", h); - cmd.Parameters.AddWithValue("@s", s); - cmd.Parameters.AddWithValue("@cnt", result.PartCount); - cmd.Parameters.AddWithValue("@util", result.Utilization); - cmd.Parameters.AddWithValue("@time", result.TimeMs); - cmd.Parameters.AddWithValue("@layout", result.LayoutData ?? ""); - cmd.Parameters.AddWithValue("@path", filePath ?? ""); - - cmd.ExecuteNonQuery(); - } - - public SqliteTransaction BeginTransaction() - { - return _connection.BeginTransaction(); - } - - public void Dispose() - { - _connection?.Dispose(); - } - } -} diff --git a/OpenNest.Training/Data/TrainingDbContext.cs b/OpenNest.Training/Data/TrainingDbContext.cs new file mode 100644 index 0000000..12bc817 --- /dev/null +++ b/OpenNest.Training/Data/TrainingDbContext.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; + +namespace OpenNest.Training.Data +{ + public class TrainingDbContext : DbContext + { + public DbSet Parts { get; set; } + public DbSet Runs { get; set; } + + private readonly string _dbPath; + + public TrainingDbContext(string dbPath) + { + _dbPath = dbPath; + } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlite($"Data Source={_dbPath}"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasIndex(p => p.FileName).HasDatabaseName("idx_parts_filename"); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(r => r.PartId).HasDatabaseName("idx_runs_partid"); + e.HasOne(r => r.Part) + .WithMany(p => p.Runs) + .HasForeignKey(r => r.PartId); + }); + } + } +} diff --git a/OpenNest.Training/Data/TrainingPart.cs b/OpenNest.Training/Data/TrainingPart.cs new file mode 100644 index 0000000..178180f --- /dev/null +++ b/OpenNest.Training/Data/TrainingPart.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenNest.Training.Data +{ + [Table("Parts")] + public class TrainingPart + { + [Key] + public long Id { get; set; } + + [MaxLength(260)] + public string FileName { get; set; } + + public double Area { get; set; } + public double Convexity { get; set; } + public double AspectRatio { get; set; } + public double BBFill { get; set; } + public double Circularity { get; set; } + public double PerimeterToAreaRatio { get; set; } + public int VertexCount { get; set; } + public byte[] Bitmask { get; set; } + public string GeometryData { get; set; } + + public List Runs { get; set; } = new(); + } +} diff --git a/OpenNest.Training/Data/TrainingRun.cs b/OpenNest.Training/Data/TrainingRun.cs new file mode 100644 index 0000000..a8ae46a --- /dev/null +++ b/OpenNest.Training/Data/TrainingRun.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenNest.Training.Data +{ + [Table("Runs")] + public class TrainingRun + { + [Key] + public long Id { get; set; } + + public long PartId { get; set; } + public double SheetWidth { get; set; } + public double SheetHeight { get; set; } + public double Spacing { get; set; } + public int PartCount { get; set; } + public double Utilization { get; set; } + public long TimeMs { get; set; } + public string LayoutData { get; set; } + public string FilePath { get; set; } + + [ForeignKey(nameof(PartId))] + public TrainingPart Part { get; set; } + } +} diff --git a/OpenNest.Training/OpenNest.Training.csproj b/OpenNest.Training/OpenNest.Training.csproj new file mode 100644 index 0000000..0a7387d --- /dev/null +++ b/OpenNest.Training/OpenNest.Training.csproj @@ -0,0 +1,19 @@ + + + Exe + net8.0-windows + OpenNest.Training + OpenNest.Training + $(DefineConstants);DEBUG;TRACE + + + + + + + + + + + + diff --git a/OpenNest.Training/Program.cs b/OpenNest.Training/Program.cs new file mode 100644 index 0000000..6859c80 --- /dev/null +++ b/OpenNest.Training/Program.cs @@ -0,0 +1,300 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using OpenNest; +using OpenNest.Geometry; +using OpenNest.IO; +using Color = System.Drawing.Color; +using OpenNest.Engine.BestFit; +using OpenNest.Engine.ML; +using OpenNest.Gpu; +using OpenNest.Training; + +// Parse arguments. +var dbPath = "OpenNestTraining"; +var saveNestsDir = (string)null; +var templateFile = (string)null; +var spacing = 0.5; +var collectDir = (string)null; + +for (var i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--db" when i + 1 < args.Length: + dbPath = args[++i]; + break; + case "--save-nests" when i + 1 < args.Length: + saveNestsDir = args[++i]; + break; + case "--template" when i + 1 < args.Length: + templateFile = args[++i]; + break; + case "--spacing" when i + 1 < args.Length: + spacing = double.Parse(args[++i]); + break; + case "--help": + case "-h": + PrintUsage(); + return 0; + default: + if (!args[i].StartsWith("--") && collectDir == null) + collectDir = args[i]; + break; + } +} + +if (string.IsNullOrEmpty(collectDir) || !Directory.Exists(collectDir)) +{ + PrintUsage(); + return 1; +} + +// Initialize GPU if available. +if (GpuEvaluatorFactory.GpuAvailable) +{ + BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer(); + Console.WriteLine($"GPU: {GpuEvaluatorFactory.DeviceName}"); +} +else +{ + Console.WriteLine("GPU: not available (using CPU)"); +} + +return RunDataCollection(collectDir, dbPath, saveNestsDir, spacing, templateFile); + +int RunDataCollection(string dir, string dbPath, string saveDir, double s, string template) +{ + // Load template nest for plate defaults if provided. + Nest templateNest = null; + if (template != null) + { + if (!File.Exists(template)) + { + Console.Error.WriteLine($"Error: Template not found: {template}"); + return 1; + } + templateNest = new NestReader(template).Read(); + Console.WriteLine($"Using template: {template}"); + } + + var PartColors = new[] + { + Color.FromArgb(205, 92, 92), + Color.FromArgb(148, 103, 189), + Color.FromArgb(75, 180, 175), + Color.FromArgb(210, 190, 75), + Color.FromArgb(190, 85, 175), + Color.FromArgb(185, 115, 85), + Color.FromArgb(120, 100, 190), + Color.FromArgb(200, 100, 140), + Color.FromArgb(80, 175, 155), + Color.FromArgb(195, 160, 85), + Color.FromArgb(175, 95, 160), + Color.FromArgb(215, 130, 130), + }; + + var sheetSuite = new[] + { + new Size(96, 48), new Size(120, 48), new Size(144, 48), + new Size(96, 60), new Size(120, 60), new Size(144, 60), + new Size(96, 72), new Size(120, 72), new Size(144, 72), + new Size(48, 24), new Size(120, 10) + }; + + var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories); + Console.WriteLine($"Found {dxfFiles.Length} DXF files"); + var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db"; + Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}"); + Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations"); + Console.WriteLine($"Spacing: {s:F2}"); + if (saveDir != null) Console.WriteLine($"Saving nests to: {saveDir}"); + Console.WriteLine("---"); + + using var db = new TrainingDatabase(dbPath); + + var backfilled = db.BackfillPerimeterToAreaRatio(); + if (backfilled > 0) + Console.WriteLine($"Backfilled PerimeterToAreaRatio for {backfilled} existing parts"); + + var importer = new DxfImporter(); + var colorIndex = 0; + var processed = 0; + var skippedGeometry = 0; + var skippedFeatures = 0; + var skippedExisting = 0; + var totalRuns = 0; + var totalSw = Stopwatch.StartNew(); + + foreach (var file in dxfFiles) + { + var fileNum = processed + skippedGeometry + skippedFeatures + skippedExisting + 1; + var partNo = Path.GetFileNameWithoutExtension(file); + Console.Write($"[{fileNum}/{dxfFiles.Length}] {partNo}"); + + try + { + var existingRuns = db.RunCount(Path.GetFileName(file)); + if (existingRuns >= sheetSuite.Length) + { + Console.WriteLine(" - SKIP (all sizes done)"); + skippedExisting++; + continue; + } + + if (!importer.GetGeometry(file, out var entities)) + { + Console.WriteLine(" - SKIP (no geometry)"); + skippedGeometry++; + continue; + } + + var drawing = new Drawing(Path.GetFileName(file)); + drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(entities); + drawing.UpdateArea(); + drawing.Color = PartColors[colorIndex % PartColors.Length]; + colorIndex++; + + var features = FeatureExtractor.Extract(drawing); + if (features == null) + { + Console.WriteLine(" - SKIP (feature extraction failed)"); + skippedFeatures++; + continue; + } + + Console.WriteLine($" (area={features.Area:F1}, verts={features.VertexCount})"); + + // Precompute best-fits once for all sheet sizes. + var sizes = sheetSuite.Select(sz => (sz.Width, sz.Length)).ToList(); + var bfSw = Stopwatch.StartNew(); + BestFitCache.ComputeForSizes(drawing, s, sizes); + bfSw.Stop(); + Console.WriteLine($" Best-fits computed in {bfSw.ElapsedMilliseconds}ms"); + + var partId = db.GetOrAddPart(Path.GetFileName(file), features, drawing.Program.ToString()); + var partSw = Stopwatch.StartNew(); + var runsThisPart = 0; + var bestUtil = 0.0; + var bestCount = 0; + + foreach (var size in sheetSuite) + { + if (db.HasRun(Path.GetFileName(file), size.Width, size.Length, s)) + { + Console.WriteLine($" {size.Length}x{size.Width} - skip (exists)"); + continue; + } + + Plate runPlate; + if (templateNest != null) + { + runPlate = templateNest.PlateDefaults.CreateNew(); + runPlate.Size = size; + runPlate.PartSpacing = s; + } + else + { + runPlate = new Plate { Size = size, PartSpacing = s }; + } + + var sizeSw = Stopwatch.StartNew(); + var result = BruteForceRunner.Run(drawing, runPlate); + sizeSw.Stop(); + + if (result == null) + { + Console.WriteLine($" {size.Length}x{size.Width} - no fit"); + continue; + } + + if (result.Utilization > bestUtil) + { + bestUtil = result.Utilization; + bestCount = result.PartCount; + } + + Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms"); + + string savedFilePath = null; + if (saveDir != null) + { + // Deterministic bucket (00-FF) based on filename hash + uint hash = 0; + foreach (char c in partNo) hash = (hash * 31) + c; + var bucket = (hash % 256).ToString("X2"); + + var partDir = Path.Combine(saveDir, bucket, partNo); + Directory.CreateDirectory(partDir); + + var nestName = $"{partNo}-{size.Length}x{size.Width}-{result.PartCount}pcs"; + var fileName = nestName + ".zip"; + savedFilePath = Path.Combine(partDir, fileName); + + // Create nest from template or from scratch + Nest nestObj; + if (templateNest != null) + { + nestObj = new Nest(nestName) + { + Units = templateNest.Units, + DateCreated = DateTime.Now + }; + nestObj.PlateDefaults.SetFromExisting(templateNest.PlateDefaults.CreateNew()); + } + else + { + nestObj = new Nest(nestName) { Units = Units.Inches, DateCreated = DateTime.Now }; + } + + nestObj.Drawings.Add(drawing); + var plateObj = nestObj.CreatePlate(); + plateObj.Size = size; + plateObj.PartSpacing = s; + plateObj.Parts.AddRange(result.PlacedParts); + + var writer = new NestWriter(nestObj); + writer.Write(savedFilePath); + } + + db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath); + runsThisPart++; + totalRuns++; + } + + BestFitCache.Invalidate(drawing); + partSw.Stop(); + processed++; + Console.WriteLine($" Total: {runsThisPart} runs, best={bestCount}pcs @ {bestUtil:P1}, {partSw.ElapsedMilliseconds}ms"); + } + catch (Exception ex) + { + Console.WriteLine(); + Console.Error.WriteLine($" ERROR: {ex.Message}"); + } + } + + totalSw.Stop(); + Console.WriteLine("---"); + Console.WriteLine($"Processed: {processed} parts, {totalRuns} total runs"); + Console.WriteLine($"Skipped: {skippedExisting} (existing) + {skippedGeometry} (no geometry) + {skippedFeatures} (no features)"); + Console.WriteLine($"Time: {totalSw.Elapsed:h\\:mm\\:ss}"); + Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}"); + return 0; +} + +void PrintUsage() +{ + Console.Error.WriteLine("Usage: OpenNest.Training [options]"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Arguments:"); + Console.Error.WriteLine(" dxf-dir Directory containing DXF files to process"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Options:"); + Console.Error.WriteLine(" --spacing Part spacing (default: 0.5)"); + Console.Error.WriteLine(" --db SQLite database path (default: OpenNestTraining.db)"); + Console.Error.WriteLine(" --save-nests Directory to save individual .zip nests for each winner"); + Console.Error.WriteLine(" --template Nest template (.nstdot) for plate defaults"); + Console.Error.WriteLine(" -h, --help Show this help"); +} diff --git a/OpenNest.Training/TrainingDatabase.cs b/OpenNest.Training/TrainingDatabase.cs new file mode 100644 index 0000000..2579299 --- /dev/null +++ b/OpenNest.Training/TrainingDatabase.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.EntityFrameworkCore; +using OpenNest.Engine.ML; +using OpenNest.IO; +using OpenNest.Training.Data; + +namespace OpenNest.Training +{ + public class TrainingDatabase : IDisposable + { + private readonly TrainingDbContext _db; + + public TrainingDatabase(string dbPath) + { + if (!dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase)) + dbPath += ".db"; + + _db = new TrainingDbContext(dbPath); + _db.Database.EnsureCreated(); + } + + public long GetOrAddPart(string fileName, PartFeatures features, string geometryData) + { + var existing = _db.Parts.FirstOrDefault(p => p.FileName == fileName); + if (existing != null) return existing.Id; + + var part = new TrainingPart + { + FileName = fileName, + Area = features.Area, + Convexity = features.Convexity, + AspectRatio = features.AspectRatio, + BBFill = features.BoundingBoxFill, + Circularity = features.Circularity, + PerimeterToAreaRatio = features.PerimeterToAreaRatio, + VertexCount = features.VertexCount, + Bitmask = features.Bitmask, + GeometryData = geometryData + }; + + _db.Parts.Add(part); + _db.SaveChanges(); + return part.Id; + } + + public bool HasRun(string fileName, double sheetWidth, double sheetHeight, double spacing) + { + return _db.Runs.Any(r => + r.Part.FileName == fileName && + r.SheetWidth == sheetWidth && + r.SheetHeight == sheetHeight && + r.Spacing == spacing); + } + + public int RunCount(string fileName) + { + return _db.Runs.Count(r => r.Part.FileName == fileName); + } + + public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath) + { + var run = new TrainingRun + { + PartId = partId, + SheetWidth = w, + SheetHeight = h, + Spacing = s, + PartCount = result.PartCount, + Utilization = result.Utilization, + TimeMs = result.TimeMs, + LayoutData = result.LayoutData ?? "", + FilePath = filePath ?? "" + }; + + _db.Runs.Add(run); + _db.SaveChanges(); + } + + public int BackfillPerimeterToAreaRatio() + { + var partsToFix = _db.Parts + .Where(p => p.PerimeterToAreaRatio == 0) + .Select(p => new { p.Id, p.GeometryData }) + .ToList(); + + if (partsToFix.Count == 0) return 0; + + var updated = 0; + foreach (var item in partsToFix) + { + try + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(item.GeometryData)); + var programReader = new ProgramReader(stream); + var program = programReader.Read(); + + var drawing = new Drawing("backfill") { Program = program }; + drawing.UpdateArea(); + + var features = FeatureExtractor.Extract(drawing); + if (features == null) continue; + + var part = _db.Parts.Find(item.Id); + part.PerimeterToAreaRatio = features.PerimeterToAreaRatio; + _db.SaveChanges(); + updated++; + } + catch + { + // Skip parts that fail to reconstruct. + } + } + + return updated; + } + + public void SaveChanges() + { + _db.SaveChanges(); + } + + public void Dispose() + { + _db?.Dispose(); + } + } +} diff --git a/OpenNest.sln b/OpenNest.sln index 8e76f0c..69cf786 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Mcp", "OpenNest.Mc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Console", "OpenNest.Console\OpenNest.Console.csproj", "{58E00A25-86B5-42C7-87B5-DE4AD22381EA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Training", "OpenNest.Training\OpenNest.Training.csproj", "{249BF728-25DD-4863-8266-207ACD26E964}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,6 +113,18 @@ Global {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x64.Build.0 = Release|Any CPU {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x86.ActiveCfg = Release|Any CPU {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x86.Build.0 = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|Any CPU.Build.0 = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|x64.ActiveCfg = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|x64.Build.0 = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|x86.ActiveCfg = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|x86.Build.0 = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|Any CPU.ActiveCfg = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|Any CPU.Build.0 = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|x64.ActiveCfg = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|x64.Build.0 = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.ActiveCfg = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 954831664ab4344ea49539a38e0a41db080702fd Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:05:03 -0400 Subject: [PATCH 020/116] docs: add ML angle pruning design spec Design for training an XGBoost model to predict which rotation angles are worth trying during FillLinear, reducing the 36-angle sweep to 4-8 predicted angles in narrow-work-area cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-14-ml-angle-pruning-design.md | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md diff --git a/docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md b/docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md new file mode 100644 index 0000000..dd16376 --- /dev/null +++ b/docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md @@ -0,0 +1,218 @@ +# ML Angle Pruning Design + +**Date:** 2026-03-14 +**Status:** Draft + +## Problem + +The nesting engine's biggest performance bottleneck is `FillLinear.FillRecursive`, which consumes ~66% of total CPU time. The linear phase builds a list of rotation angles to try — normally just 2 (`bestRotation` and `bestRotation + 90`), but expanding to a full 36-angle sweep (0-175 in 5-degree increments) when the work area's short side is smaller than the part's longest side. This narrow-work-area condition triggers frequently during remainder-strip fills and for large/elongated parts. Each angle x 2 directions requires expensive ray/edge distance calculations for every tile placement. + +## Goal + +Train an ML model that predicts which rotation angles are competitive for a given part geometry and sheet size. At runtime, replace the full angle sweep with only the predicted angles, reducing linear phase compute time in the narrow-work-area case. The model only applies when the engine would otherwise sweep all 36 angles — for the normal 2-angle case, no change is needed. + +## Design + +### Training Data Collection + +#### Forced Full Sweep for Training + +In production, `FindBestFill` only sweeps all 36 angles when `workAreaShortSide < partLongestSide`. For training, the sweep must be forced for every part x sheet combination regardless of this condition — otherwise the model has no data to learn from for the majority of runs that only evaluate 2 angles. + +`NestEngine` gains a `ForceFullAngleSweep` property (default `false`). When `true`, `FindBestFill` always builds the full 0-175 angle list. The training runner sets this to `true`; production code leaves it `false`. + +#### Per-Angle Results from NestEngine + +Instrument `NestEngine.FindBestFill` to collect per-angle results from the linear phase. Each call to `FillLinear.Fill(drawing, angle, direction)` produces a result that is currently only compared against the running best. With this change, each result is also accumulated into a collection on the engine instance. + +New types in `NestProgress.cs`: + +```csharp +public class AngleResult +{ + public double AngleDeg { get; set; } + public NestDirection Direction { get; set; } + public int PartCount { get; set; } +} +``` + +New properties on `NestEngine`: + +```csharp +public bool ForceFullAngleSweep { get; set; } +public List AngleResults { get; } = new(); +``` + +`AngleResults` is cleared at the start of `Fill` (alongside `PhaseResults.Clear()`). Populated inside the `Parallel.ForEach` over angles in `FindBestFill` — uses a `ConcurrentBag` during the parallel loop, then transferred to `AngleResults` via `AddRange` after the loop completes (same pattern as the existing `linearBag`). + +#### Progress Window Enhancement + +`NestProgress` gains a `Description` field — a freeform status string that the progress window displays directly: + +```csharp +public class NestProgress +{ + // ... existing fields ... + public string Description { get; set; } +} +``` + +Progress is reported per-angle during the linear phase (e.g. `"Linear: 35 V - 48 parts"`) and per-candidate during the pairs phase (e.g. `"Pairs: candidate 12/50"`). This gives real-time visibility into what the engine is doing, beyond the current phase-level updates. + +#### BruteForceRunner Changes + +`BruteForceRunner.Run` reads `engine.AngleResults` after `Fill` completes and passes them through `BruteForceResult`: + +```csharp +public class BruteForceResult +{ + // ... existing fields ... + public List AngleResults { get; set; } +} +``` + +The training runner sets `engine.ForceFullAngleSweep = true` before calling `Fill`. + +#### Database Schema + +New `AngleResults` table: + +| Column | Type | Description | +|-----------|---------|--------------------------------------| +| Id | long | PK, auto-increment | +| RunId | long | FK to Runs table | +| AngleDeg | double | Rotation angle in degrees (0-175) | +| Direction | string | "Horizontal" or "Vertical" | +| PartCount | int | Parts placed at this angle/direction | + +Each run produces up to ~72 rows (36 angles x 2 directions, minus angles where zero parts fit). With forced full sweep during training: 41k parts x 11 sheet sizes x ~72 angle results = ~32 million rows. SQLite handles this for batch writes; SQL Express on barge.lan is available as a fallback if needed. + +New EF Core entity `TrainingAngleResult` in `OpenNest.Training/Data/`. `TrainingDatabase.AddRun` is extended to accept and batch-insert angle results alongside the run. + +Migration: `MigrateSchema` creates the `AngleResults` table if it doesn't exist. Existing databases without the table continue to work — the table is created on first use. + +### Model Architecture + +**Type:** XGBoost multi-label classifier exported to ONNX. + +**Input features (11 scalars):** +- Part geometry (7): Area, Convexity, AspectRatio, BBFill, Circularity, PerimeterToAreaRatio, VertexCount +- Sheet dimensions (2): Width, Height +- Derived (2): SheetAspectRatio (Width/Height), PartToSheetAreaRatio (PartArea / SheetArea) + +The 32x32 bitmask is excluded from the initial model. The 7 scalar geometry features capture sufficient shape information for angle prediction. Bitmask can be added later if accuracy needs improvement. + +**Output:** 36 probabilities, one per 5-degree angle bin (0, 5, 10, ..., 175). Each probability represents "this angle is competitive for this part/sheet combination." + +**Label generation:** For each part x sheet run, an angle is labeled positive (1) if its best PartCount (max of H and V directions) is >= 95% of the overall best angle's PartCount for that run. This creates a multi-label target where typically 2-8 angles are labeled positive. + +**Direction handling:** The model predicts angles only. Both H and V directions are always tried for each selected angle — direction computation is cheap relative to the angle setup. + +### Training Pipeline + +Python notebook at `OpenNest.Training/notebooks/train_angle_model.ipynb`: + +1. **Extract** — Read SQLite database, join Parts + Runs + AngleResults into a flat dataframe. +2. **Filter** — Remove title block outliers using feature thresholds (e.g. BBFill < 0.01, abnormally large bounding boxes relative to actual geometry area). Log filtered parts for manual review. +3. **Label** — For each run, compute the best angle's PartCount. Mark angles within 95% as positive. Build a 36-column binary label matrix. +4. **Feature engineering** — Compute derived features (SheetAspectRatio, PartToSheetAreaRatio). Normalize if needed. +5. **Train** — XGBoost multi-label classifier. Use `sklearn.multioutput.MultiOutputClassifier` wrapping `xgboost.XGBClassifier`. Train/test split stratified by part (all sheet sizes for a part stay in the same split). +6. **Evaluate** — Primary metric: per-angle recall > 95% (must almost never skip the winning angle). Secondary: precision > 60% (acceptable to try a few extra angles). Report average angles predicted per part. +7. **Export** — Convert to ONNX via `skl2onnx` or `onnxmltools`. Save to `OpenNest.Engine/Models/angle_predictor.onnx`. + +Python dependencies: `pandas`, `scikit-learn`, `xgboost`, `onnxmltools` (or `skl2onnx`), `matplotlib` (for evaluation plots). + +### C# Inference Integration + +New file `OpenNest.Engine/ML/AnglePredictor.cs`: + +```csharp +public static class AnglePredictor +{ + public static List PredictAngles( + PartFeatures features, double sheetWidth, double sheetHeight); +} +``` + +- Loads `angle_predictor.onnx` from the `Models/` directory adjacent to the Engine DLL on first call. Caches the ONNX session for reuse. +- Runs inference with the 11 input features. +- Applies threshold (default 0.3) to the 36 output probabilities. +- Returns angles above threshold, converted to radians. +- Always includes 0 and 90 degrees as safety fallback. +- Minimum 3 angles returned (if fewer pass threshold, take top 3 by probability). +- If the model file is missing or inference fails, returns `null` — caller falls back to trying all angles (current behavior unchanged). + +**NuGet dependency:** `Microsoft.ML.OnnxRuntime` added to `OpenNest.Engine.csproj`. + +### NestEngine Integration + +In `FindBestFill` (the progress/token overload), the angle list construction changes: + +``` +Current: + angles = [bestRotation, bestRotation + 90] + + sweep 0-175 if narrow work area + +With model (only when narrow work area condition is met): + predicted = AnglePredictor.PredictAngles(features, sheetW, sheetH) + if predicted != null: + angles = predicted + + bestRotation and bestRotation + 90 (if not already included) + else: + angles = current behavior (full sweep) + +ForceFullAngleSweep = true (training only): + angles = full 0-175 sweep regardless of work area condition +``` + +`FeatureExtractor.Extract(drawing)` is called once per drawing before the fill loop. This is cheap (~0ms) and already exists. + +**Note:** The Pairs phase (`FillWithPairs`) uses hull-edge angles from each pair candidate's geometry, not the linear angle list. The ML model does not affect the Pairs phase angle selection. Pairs phase optimization (e.g. pruning pair candidates) is a separate future concern. + +### Fallback and Safety + +- **No model file:** Full angle sweep (current behavior). Zero regression risk. +- **Model loads but prediction fails:** Full angle sweep. Logged to Debug output. +- **Model predicts too few angles:** Minimum 3 angles enforced. 0, 90, bestRotation, and bestRotation + 90 always included. +- **Normal 2-angle case (no narrow work area):** Model is not consulted — the engine only tries bestRotation and bestRotation + 90 as it does today. +- **Model misses the optimal angle:** Recall target of 95% means ~5% of runs may not find the absolute best. The result will still be good (within 95% of optimal by definition of the training labels). Users can disable the model via a setting if needed. + +## Files Changed + +### OpenNest.Engine +- `NestProgress.cs` — Add `AngleResult` class, add `Description` to `NestProgress` +- `NestEngine.cs` — Add `ForceFullAngleSweep` and `AngleResults` properties, clear `AngleResults` alongside `PhaseResults`, populate per-angle results in `FindBestFill` via `ConcurrentBag` + `AddRange`, report per-angle progress with descriptions, use `AnglePredictor` for angle selection when narrow work area +- `ML/BruteForceRunner.cs` — Pass through `AngleResults` from engine +- `ML/AnglePredictor.cs` — New: ONNX model loading and inference +- `ML/FeatureExtractor.cs` — No changes (already exists) +- `Models/angle_predictor.onnx` — New: trained model file (added after training) +- `OpenNest.Engine.csproj` — Add `Microsoft.ML.OnnxRuntime` NuGet package + +### OpenNest.Training +- `Data/TrainingAngleResult.cs` — New: EF Core entity for AngleResults table +- `Data/TrainingDbContext.cs` — Add `DbSet` +- `Data/TrainingRun.cs` — No changes +- `TrainingDatabase.cs` — Add angle result storage, extend `MigrateSchema` +- `Program.cs` — Set `ForceFullAngleSweep = true` on engine, collect and store per-angle results from `BruteForceRunner` + +### OpenNest.Training/notebooks (new directory) +- `train_angle_model.ipynb` — Training notebook +- `requirements.txt` — Python dependencies + +### OpenNest (WinForms) +- Progress window UI — Display `NestProgress.Description` string (minimal change) + +## Data Volume Estimates + +- 41k parts x 11 sheet sizes = ~450k runs +- With forced full sweep: ~72 angle results per run = ~32 million angle result rows +- SQLite can handle this for batch writes. SQL Express on barge.lan available as fallback. +- Trained model file: ~1-5 MB ONNX + +## Success Criteria + +- Per-angle recall > 95% (almost never skips the winning angle) +- Average angles predicted: 4-8 per part (down from 36) +- Linear phase speedup in narrow-work-area case: 70-80% reduction +- Zero regression when model is absent — current behavior preserved exactly +- Progress window shows live angle/candidate details during nesting From eb21f76ef41ad29cc62a254bdfe69236a89c0866 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:18:38 -0400 Subject: [PATCH 021/116] feat(engine): add AngleResult class and Description to NestProgress --- OpenNest.Engine/NestProgress.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/OpenNest.Engine/NestProgress.cs b/OpenNest.Engine/NestProgress.cs index 90a3fc9..ba4172e 100644 --- a/OpenNest.Engine/NestProgress.cs +++ b/OpenNest.Engine/NestProgress.cs @@ -10,6 +10,27 @@ namespace OpenNest Remainder } + public class PhaseResult + { + public NestPhase Phase { get; set; } + public int PartCount { get; set; } + public long TimeMs { get; set; } + + public PhaseResult(NestPhase phase, int partCount, long timeMs) + { + Phase = phase; + PartCount = partCount; + TimeMs = timeMs; + } + } + + public class AngleResult + { + public double AngleDeg { get; set; } + public NestDirection Direction { get; set; } + public int PartCount { get; set; } + } + public class NestProgress { public NestPhase Phase { get; set; } @@ -18,5 +39,6 @@ namespace OpenNest public double BestDensity { get; set; } public double UsableRemnantArea { get; set; } public List BestParts { get; set; } + public string Description { get; set; } } } From 09ed9c228f51eb8f2c7cf28bdae89003ca30db35 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:21:14 -0400 Subject: [PATCH 022/116] feat(engine): add ForceFullAngleSweep flag and per-angle result collection Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 80 +++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index ca39d10..c934aad 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -24,6 +24,14 @@ namespace OpenNest public int PlateNumber { get; set; } + public NestPhase WinnerPhase { get; private set; } + + public List PhaseResults { get; } = new(); + + public bool ForceFullAngleSweep { get; set; } + + public List AngleResults { get; } = new(); + public bool Fill(NestItem item) { return Fill(item, Plate.WorkArea()); @@ -48,18 +56,24 @@ namespace OpenNest public List Fill(NestItem item, Box workArea, IProgress progress, CancellationToken token) { + PhaseResults.Clear(); + AngleResults.Clear(); var best = FindBestFill(item, workArea, progress, token); if (token.IsCancellationRequested) return best ?? new List(); // Try improving by filling the remainder strip separately. + var remainderSw = Stopwatch.StartNew(); var improved = TryRemainderImprovement(item, workArea, best); + remainderSw.Stop(); if (IsBetterFill(improved, best, workArea)) { Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); best = improved; + WinnerPhase = NestPhase.Remainder; + PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, remainderSw.ElapsedMilliseconds)); ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea); } @@ -105,6 +119,16 @@ namespace OpenNest } } + if (ForceFullAngleSweep) + { + var step = Angle.ToRadians(5); + for (var a = 0.0; a < System.Math.PI; a += step) + { + if (!angles.Any(existing => existing.IsEqualTo(a))) + angles.Add(a); + } + } + // Try pair-based approach first. var pairResult = FillWithPairs(item, workArea); var best = pairResult; @@ -180,17 +204,33 @@ namespace OpenNest } } + if (ForceFullAngleSweep) + { + var step = Angle.ToRadians(5); + for (var a = 0.0; a < System.Math.PI; a += step) + { + if (!angles.Any(existing => existing.IsEqualTo(a))) + angles.Add(a); + } + } + // Pairs phase first - var pairResult = FillWithPairs(item, workArea, token); + var pairSw = Stopwatch.StartNew(); + var pairResult = FillWithPairs(item, workArea, token, progress); + pairSw.Stop(); best = pairResult; var bestScore = FillScore.Compute(best, workArea); + WinnerPhase = NestPhase.Pairs; + PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, pairSw.ElapsedMilliseconds)); Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts"); ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea); token.ThrowIfCancellationRequested(); // Linear phase + var linearSw = Stopwatch.StartNew(); var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); + var angleBag = new System.Collections.Concurrent.ConcurrentBag(); System.Threading.Tasks.Parallel.ForEach(angles, new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, @@ -200,20 +240,43 @@ namespace OpenNest var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal); var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical); + var angleDeg = Angle.ToDegrees(angle); if (h != null && h.Count > 0) + { linearBag.Add((FillScore.Compute(h, workArea), h)); + angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count }); + } if (v != null && v.Count > 0) + { linearBag.Add((FillScore.Compute(v, workArea), v)); - }); + angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count }); + } + var bestDir = (h?.Count ?? 0) >= (v?.Count ?? 0) ? "H" : "V"; + var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0); + progress?.Report(new NestProgress + { + Phase = NestPhase.Linear, + PlateNumber = PlateNumber, + Description = $"Linear: {angleDeg:F0}° {bestDir} - {bestCount} parts" + }); + }); + linearSw.Stop(); + AngleResults.AddRange(angleBag); + + var bestLinearCount = 0; foreach (var (score, parts) in linearBag) { + if (parts.Count > bestLinearCount) + bestLinearCount = parts.Count; if (score > bestScore) { best = parts; bestScore = score; + WinnerPhase = NestPhase.Linear; } } + PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds)); Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); @@ -221,13 +284,17 @@ namespace OpenNest token.ThrowIfCancellationRequested(); // RectBestFit phase + var rectSw = Stopwatch.StartNew(); var rectResult = FillRectangleBestFit(item, workArea); + rectSw.Stop(); var rectScore = rectResult != null ? FillScore.Compute(rectResult, workArea) : default; Debug.WriteLine($"[FindBestFill] RectBestFit: {rectScore.Count} parts"); + PhaseResults.Add(new PhaseResult(NestPhase.RectBestFit, rectResult?.Count ?? 0, rectSw.ElapsedMilliseconds)); if (rectScore > bestScore) { best = rectResult; + WinnerPhase = NestPhase.RectBestFit; ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); } } @@ -382,7 +449,7 @@ namespace OpenNest return best ?? new List(); } - private List FillWithPairs(NestItem item, Box workArea, CancellationToken token) + private List FillWithPairs(NestItem item, Box workArea, CancellationToken token, IProgress progress = null) { var bestFits = BestFitCache.GetOrCompute( item.Drawing, Plate.Size.Width, Plate.Size.Length, @@ -407,6 +474,13 @@ namespace OpenNest if (filled != null && filled.Count > 0) resultBag.Add((FillScore.Compute(filled, workArea), filled)); + + progress?.Report(new NestProgress + { + Phase = NestPhase.Pairs, + PlateNumber = PlateNumber, + Description = $"Pairs: candidate {i + 1}/{candidates.Count} - {filled?.Count ?? 0} parts" + }); }); } catch (OperationCanceledException) From 8e46ed11759293a69763ffe921c5be40b11492e9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:22:38 -0400 Subject: [PATCH 023/116] feat(engine): pass per-angle results through BruteForceResult Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/ML/BruteForceRunner.cs | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/OpenNest.Engine/ML/BruteForceRunner.cs b/OpenNest.Engine/ML/BruteForceRunner.cs index c3d8c59..efaa6e8 100644 --- a/OpenNest.Engine/ML/BruteForceRunner.cs +++ b/OpenNest.Engine/ML/BruteForceRunner.cs @@ -12,6 +12,15 @@ namespace OpenNest.Engine.ML public long TimeMs { get; set; } public string LayoutData { get; set; } public List PlacedParts { get; set; } + public string WinnerEngine { get; set; } = ""; + public long WinnerTimeMs { get; set; } + public string RunnerUpEngine { get; set; } = ""; + public int RunnerUpPartCount { get; set; } + public long RunnerUpTimeMs { get; set; } + public string ThirdPlaceEngine { get; set; } = ""; + public int ThirdPlacePartCount { get; set; } + public long ThirdPlaceTimeMs { get; set; } + public List AngleResults { get; set; } = new(); } public static class BruteForceRunner @@ -28,13 +37,30 @@ namespace OpenNest.Engine.ML if (parts == null || parts.Count == 0) return null; + // Rank phase results — winner is explicit, runners-up sorted by count. + var winner = engine.PhaseResults + .FirstOrDefault(r => r.Phase == engine.WinnerPhase); + var runnerUps = engine.PhaseResults + .Where(r => r.PartCount > 0 && r.Phase != engine.WinnerPhase) + .OrderByDescending(r => r.PartCount) + .ToList(); + return new BruteForceResult { PartCount = parts.Count, Utilization = CalculateUtilization(parts, plate.Area()), TimeMs = sw.ElapsedMilliseconds, LayoutData = SerializeLayout(parts), - PlacedParts = parts + PlacedParts = parts, + WinnerEngine = engine.WinnerPhase.ToString(), + WinnerTimeMs = winner?.TimeMs ?? 0, + RunnerUpEngine = runnerUps.Count > 0 ? runnerUps[0].Phase.ToString() : "", + RunnerUpPartCount = runnerUps.Count > 0 ? runnerUps[0].PartCount : 0, + RunnerUpTimeMs = runnerUps.Count > 0 ? runnerUps[0].TimeMs : 0, + ThirdPlaceEngine = runnerUps.Count > 1 ? runnerUps[1].Phase.ToString() : "", + ThirdPlacePartCount = runnerUps.Count > 1 ? runnerUps[1].PartCount : 0, + ThirdPlaceTimeMs = runnerUps.Count > 1 ? runnerUps[1].TimeMs : 0, + AngleResults = engine.AngleResults.ToList() }; } From dddd81fd90bd08b02e5d1bba823f98177bf82181 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:23:38 -0400 Subject: [PATCH 024/116] feat(training): add TrainingAngleResult entity and DbSet Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Training/Data/TrainingAngleResult.cs | 20 +++++++++++++++++++ OpenNest.Training/Data/TrainingDbContext.cs | 9 +++++++++ OpenNest.Training/Data/TrainingRun.cs | 11 ++++++++++ 3 files changed, 40 insertions(+) create mode 100644 OpenNest.Training/Data/TrainingAngleResult.cs diff --git a/OpenNest.Training/Data/TrainingAngleResult.cs b/OpenNest.Training/Data/TrainingAngleResult.cs new file mode 100644 index 0000000..b01a0ce --- /dev/null +++ b/OpenNest.Training/Data/TrainingAngleResult.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenNest.Training.Data +{ + [Table("AngleResults")] + public class TrainingAngleResult + { + [Key] + public long Id { get; set; } + + public long RunId { get; set; } + public double AngleDeg { get; set; } + public string Direction { get; set; } + public int PartCount { get; set; } + + [ForeignKey(nameof(RunId))] + public TrainingRun Run { get; set; } + } +} diff --git a/OpenNest.Training/Data/TrainingDbContext.cs b/OpenNest.Training/Data/TrainingDbContext.cs index 12bc817..5ffcaaa 100644 --- a/OpenNest.Training/Data/TrainingDbContext.cs +++ b/OpenNest.Training/Data/TrainingDbContext.cs @@ -6,6 +6,7 @@ namespace OpenNest.Training.Data { public DbSet Parts { get; set; } public DbSet Runs { get; set; } + public DbSet AngleResults { get; set; } private readonly string _dbPath; @@ -33,6 +34,14 @@ namespace OpenNest.Training.Data .WithMany(p => p.Runs) .HasForeignKey(r => r.PartId); }); + + modelBuilder.Entity(e => + { + e.HasIndex(a => a.RunId).HasDatabaseName("idx_angleresults_runid"); + e.HasOne(a => a.Run) + .WithMany(r => r.AngleResults) + .HasForeignKey(a => a.RunId); + }); } } } diff --git a/OpenNest.Training/Data/TrainingRun.cs b/OpenNest.Training/Data/TrainingRun.cs index a8ae46a..c968ff4 100644 --- a/OpenNest.Training/Data/TrainingRun.cs +++ b/OpenNest.Training/Data/TrainingRun.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -18,8 +19,18 @@ namespace OpenNest.Training.Data public long TimeMs { get; set; } public string LayoutData { get; set; } public string FilePath { get; set; } + public string WinnerEngine { get; set; } = ""; + public long WinnerTimeMs { get; set; } + public string RunnerUpEngine { get; set; } = ""; + public int RunnerUpPartCount { get; set; } + public long RunnerUpTimeMs { get; set; } + public string ThirdPlaceEngine { get; set; } = ""; + public int ThirdPlacePartCount { get; set; } + public long ThirdPlaceTimeMs { get; set; } [ForeignKey(nameof(PartId))] public TrainingPart Part { get; set; } + + public List AngleResults { get; set; } = new(); } } From a145fd3c60c4fd6426287a73e92b5fdd64993535 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:24:40 -0400 Subject: [PATCH 025/116] feat(training): add AngleResults table migration and batch insert Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Training/TrainingDatabase.cs | 74 ++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/OpenNest.Training/TrainingDatabase.cs b/OpenNest.Training/TrainingDatabase.cs index 2579299..97eb46e 100644 --- a/OpenNest.Training/TrainingDatabase.cs +++ b/OpenNest.Training/TrainingDatabase.cs @@ -21,6 +21,7 @@ namespace OpenNest.Training _db = new TrainingDbContext(dbPath); _db.Database.EnsureCreated(); + MigrateSchema(); } public long GetOrAddPart(string fileName, PartFeatures features, string geometryData) @@ -61,7 +62,7 @@ namespace OpenNest.Training return _db.Runs.Count(r => r.Part.FileName == fileName); } - public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath) + public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath, List angleResults = null) { var run = new TrainingRun { @@ -73,10 +74,33 @@ namespace OpenNest.Training Utilization = result.Utilization, TimeMs = result.TimeMs, LayoutData = result.LayoutData ?? "", - FilePath = filePath ?? "" + FilePath = filePath ?? "", + WinnerEngine = result.WinnerEngine ?? "", + WinnerTimeMs = result.WinnerTimeMs, + RunnerUpEngine = result.RunnerUpEngine ?? "", + RunnerUpPartCount = result.RunnerUpPartCount, + RunnerUpTimeMs = result.RunnerUpTimeMs, + ThirdPlaceEngine = result.ThirdPlaceEngine ?? "", + ThirdPlacePartCount = result.ThirdPlacePartCount, + ThirdPlaceTimeMs = result.ThirdPlaceTimeMs }; _db.Runs.Add(run); + + if (angleResults != null && angleResults.Count > 0) + { + foreach (var ar in angleResults) + { + _db.AngleResults.Add(new Data.TrainingAngleResult + { + Run = run, + AngleDeg = ar.AngleDeg, + Direction = ar.Direction.ToString(), + PartCount = ar.PartCount + }); + } + } + _db.SaveChanges(); } @@ -118,6 +142,52 @@ namespace OpenNest.Training return updated; } + private void MigrateSchema() + { + var columns = new[] + { + ("WinnerEngine", "TEXT NOT NULL DEFAULT ''"), + ("WinnerTimeMs", "INTEGER NOT NULL DEFAULT 0"), + ("RunnerUpEngine", "TEXT NOT NULL DEFAULT ''"), + ("RunnerUpPartCount", "INTEGER NOT NULL DEFAULT 0"), + ("RunnerUpTimeMs", "INTEGER NOT NULL DEFAULT 0"), + ("ThirdPlaceEngine", "TEXT NOT NULL DEFAULT ''"), + ("ThirdPlacePartCount", "INTEGER NOT NULL DEFAULT 0"), + ("ThirdPlaceTimeMs", "INTEGER NOT NULL DEFAULT 0"), + }; + + foreach (var (name, type) in columns) + { + try + { + _db.Database.ExecuteSqlRaw($"ALTER TABLE Runs ADD COLUMN {name} {type}"); + } + catch + { + // Column already exists. + } + } + + try + { + _db.Database.ExecuteSqlRaw(@" + CREATE TABLE IF NOT EXISTS AngleResults ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + RunId INTEGER NOT NULL, + AngleDeg REAL NOT NULL, + Direction TEXT NOT NULL, + PartCount INTEGER NOT NULL, + FOREIGN KEY (RunId) REFERENCES Runs(Id) + )"); + _db.Database.ExecuteSqlRaw( + "CREATE INDEX IF NOT EXISTS idx_angleresults_runid ON AngleResults (RunId)"); + } + catch + { + // Table already exists or other non-fatal issue. + } + } + public void SaveChanges() { _db.SaveChanges(); From 71fc1e61ef4c4c40589049ae7ba472385392e9eb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:25:33 -0400 Subject: [PATCH 026/116] feat(training): enable forced full angle sweep and store per-angle results Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/ML/BruteForceRunner.cs | 3 ++- OpenNest.Training/Program.cs | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/OpenNest.Engine/ML/BruteForceRunner.cs b/OpenNest.Engine/ML/BruteForceRunner.cs index efaa6e8..f273e94 100644 --- a/OpenNest.Engine/ML/BruteForceRunner.cs +++ b/OpenNest.Engine/ML/BruteForceRunner.cs @@ -25,9 +25,10 @@ namespace OpenNest.Engine.ML public static class BruteForceRunner { - public static BruteForceResult Run(Drawing drawing, Plate plate) + public static BruteForceResult Run(Drawing drawing, Plate plate, bool forceFullAngleSweep = false) { var engine = new NestEngine(plate); + engine.ForceFullAngleSweep = forceFullAngleSweep; var item = new NestItem { Drawing = drawing }; var sw = Stopwatch.StartNew(); diff --git a/OpenNest.Training/Program.cs b/OpenNest.Training/Program.cs index 6859c80..421e00c 100644 --- a/OpenNest.Training/Program.cs +++ b/OpenNest.Training/Program.cs @@ -200,7 +200,7 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin } var sizeSw = Stopwatch.StartNew(); - var result = BruteForceRunner.Run(drawing, runPlate); + var result = BruteForceRunner.Run(drawing, runPlate, forceFullAngleSweep: true); sizeSw.Stop(); if (result == null) @@ -215,7 +215,12 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin bestCount = result.PartCount; } - Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms"); + var engineInfo = $"{result.WinnerEngine}({result.WinnerTimeMs}ms)"; + if (!string.IsNullOrEmpty(result.RunnerUpEngine)) + engineInfo += $", 2nd={result.RunnerUpEngine}({result.RunnerUpPartCount}pcs/{result.RunnerUpTimeMs}ms)"; + if (!string.IsNullOrEmpty(result.ThirdPlaceEngine)) + engineInfo += $", 3rd={result.ThirdPlaceEngine}({result.ThirdPlacePartCount}pcs/{result.ThirdPlaceTimeMs}ms)"; + Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms [{engineInfo}] angles={result.AngleResults.Count}"); string savedFilePath = null; if (saveDir != null) @@ -258,7 +263,7 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin writer.Write(savedFilePath); } - db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath); + db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath, result.AngleResults); runsThisPart++; totalRuns++; } From c466a24486de5cb3fb4cca0707305607f925fbfd Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:26:26 -0400 Subject: [PATCH 027/116] chore(engine): add Microsoft.ML.OnnxRuntime package Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/OpenNest.Engine.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/OpenNest.Engine/OpenNest.Engine.csproj b/OpenNest.Engine/OpenNest.Engine.csproj index 5379833..a4b3e98 100644 --- a/OpenNest.Engine/OpenNest.Engine.csproj +++ b/OpenNest.Engine/OpenNest.Engine.csproj @@ -7,4 +7,7 @@ + + + From 2a58a8e12385c680ae49b01bc2a37277cc02176d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:26:47 -0400 Subject: [PATCH 028/116] feat(ui): add description row to nest progress form Co-Authored-By: Claude Sonnet 4.6 --- OpenNest/Forms/NestProgressForm.Designer.cs | 26 +++++++++++++++++++-- OpenNest/Forms/NestProgressForm.cs | 3 +++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/OpenNest/Forms/NestProgressForm.Designer.cs b/OpenNest/Forms/NestProgressForm.Designer.cs index 6670048..c11090d 100644 --- a/OpenNest/Forms/NestProgressForm.Designer.cs +++ b/OpenNest/Forms/NestProgressForm.Designer.cs @@ -41,6 +41,8 @@ namespace OpenNest.Forms this.remnantValue = new System.Windows.Forms.Label(); this.elapsedLabel = new System.Windows.Forms.Label(); this.elapsedValue = new System.Windows.Forms.Label(); + this.descriptionLabel = new System.Windows.Forms.Label(); + this.descriptionValue = new System.Windows.Forms.Label(); this.stopButton = new System.Windows.Forms.Button(); this.buttonPanel = new System.Windows.Forms.FlowLayoutPanel(); this.table.SuspendLayout(); @@ -64,11 +66,14 @@ namespace OpenNest.Forms this.table.Controls.Add(this.remnantValue, 1, 4); this.table.Controls.Add(this.elapsedLabel, 0, 5); this.table.Controls.Add(this.elapsedValue, 1, 5); + this.table.Controls.Add(this.descriptionLabel, 0, 6); + this.table.Controls.Add(this.descriptionValue, 1, 6); this.table.Dock = System.Windows.Forms.DockStyle.Top; this.table.Location = new System.Drawing.Point(0, 0); this.table.Name = "table"; this.table.Padding = new System.Windows.Forms.Padding(8); - this.table.RowCount = 6; + this.table.RowCount = 7; + this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); @@ -169,6 +174,21 @@ namespace OpenNest.Forms this.elapsedValue.Name = "elapsedValue"; this.elapsedValue.Text = "0:00"; // + // descriptionLabel + // + this.descriptionLabel.AutoSize = true; + this.descriptionLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); + this.descriptionLabel.Margin = new System.Windows.Forms.Padding(4); + this.descriptionLabel.Name = "descriptionLabel"; + this.descriptionLabel.Text = "Detail:"; + // + // descriptionValue + // + this.descriptionValue.AutoSize = true; + this.descriptionValue.Margin = new System.Windows.Forms.Padding(4); + this.descriptionValue.Name = "descriptionValue"; + this.descriptionValue.Text = "\u2014"; + // // stopButton // this.stopButton.Anchor = System.Windows.Forms.AnchorStyles.None; @@ -194,7 +214,7 @@ namespace OpenNest.Forms // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(264, 207); + this.ClientSize = new System.Drawing.Size(264, 230); this.Controls.Add(this.buttonPanel); this.Controls.Add(this.table); this.Controls.SetChildIndex(this.table, 0); @@ -228,6 +248,8 @@ namespace OpenNest.Forms private System.Windows.Forms.Label remnantValue; private System.Windows.Forms.Label elapsedLabel; private System.Windows.Forms.Label elapsedValue; + private System.Windows.Forms.Label descriptionLabel; + private System.Windows.Forms.Label descriptionValue; private System.Windows.Forms.Button stopButton; private System.Windows.Forms.FlowLayoutPanel buttonPanel; } diff --git a/OpenNest/Forms/NestProgressForm.cs b/OpenNest/Forms/NestProgressForm.cs index 79813c8..4053e45 100644 --- a/OpenNest/Forms/NestProgressForm.cs +++ b/OpenNest/Forms/NestProgressForm.cs @@ -37,6 +37,9 @@ namespace OpenNest.Forms partsValue.Text = progress.BestPartCount.ToString(); densityValue.Text = progress.BestDensity.ToString("P1"); remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in"; + + if (!string.IsNullOrEmpty(progress.Description)) + descriptionValue.Text = progress.Description; } public void ShowCompleted() From eee2d0e3fe4103bf8d9ebd6b9653713f54603070 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:27:41 -0400 Subject: [PATCH 029/116] feat(training): add training notebook skeleton and requirements Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/Models/.gitkeep | 0 OpenNest.Training/notebooks/requirements.txt | 7 + .../notebooks/train_angle_model.ipynb | 264 ++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 OpenNest.Engine/Models/.gitkeep create mode 100644 OpenNest.Training/notebooks/requirements.txt create mode 100644 OpenNest.Training/notebooks/train_angle_model.ipynb diff --git a/OpenNest.Engine/Models/.gitkeep b/OpenNest.Engine/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/OpenNest.Training/notebooks/requirements.txt b/OpenNest.Training/notebooks/requirements.txt new file mode 100644 index 0000000..840594e --- /dev/null +++ b/OpenNest.Training/notebooks/requirements.txt @@ -0,0 +1,7 @@ +pandas>=2.0 +scikit-learn>=1.3 +xgboost>=2.0 +onnxmltools>=1.12 +skl2onnx>=1.16 +matplotlib>=3.7 +jupyter>=1.0 diff --git a/OpenNest.Training/notebooks/train_angle_model.ipynb b/OpenNest.Training/notebooks/train_angle_model.ipynb new file mode 100644 index 0000000..75c1b7c --- /dev/null +++ b/OpenNest.Training/notebooks/train_angle_model.ipynb @@ -0,0 +1,264 @@ +{ + "nbformat": 4, + "nbformat_minor": 5, + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "cells": [ + { + "cell_type": "markdown", + "id": "a1b2c3d4-0001-0000-0000-000000000001", + "metadata": {}, + "source": [ + "# Angle Prediction Model Training\n", + "Trains an XGBoost multi-label classifier to predict which rotation angles are competitive for a given part geometry and sheet size.\n", + "\n", + "**Input:** SQLite database from OpenNest.Training data collection runs\n", + "**Output:** `angle_predictor.onnx` model file for `OpenNest.Engine/Models/`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1b2c3d4-0002-0000-0000-000000000002", + "metadata": {}, + "outputs": [], + "source": [ + "import sqlite3\n", + "import pandas as pd\n", + "import numpy as np\n", + "from pathlib import Path\n", + "\n", + "DB_PATH = \"../OpenNestTraining.db\" # Adjust to your database location\n", + "OUTPUT_PATH = \"../../OpenNest.Engine/Models/angle_predictor.onnx\"\n", + "COMPETITIVE_THRESHOLD = 0.95 # Angle is \"competitive\" if >= 95% of best" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1b2c3d4-0003-0000-0000-000000000003", + "metadata": {}, + "outputs": [], + "source": [ + "# Extract training data from SQLite\n", + "conn = sqlite3.connect(DB_PATH)\n", + "\n", + "query = \"\"\"\n", + "SELECT\n", + " p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity,\n", + " p.PerimeterToAreaRatio, p.VertexCount,\n", + " r.SheetWidth, r.SheetHeight, r.Id as RunId,\n", + " a.AngleDeg, a.Direction, a.PartCount\n", + "FROM AngleResults a\n", + "JOIN Runs r ON a.RunId = r.Id\n", + "JOIN Parts p ON r.PartId = p.Id\n", + "WHERE a.PartCount > 0\n", + "\"\"\"\n", + "\n", + "df = pd.read_sql_query(query, conn)\n", + "conn.close()\n", + "\n", + "print(f\"Loaded {len(df)} angle result rows\")\n", + "print(f\"Unique runs: {df['RunId'].nunique()}\")\n", + "print(f\"Angle range: {df['AngleDeg'].min()}-{df['AngleDeg'].max()}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1b2c3d4-0004-0000-0000-000000000004", + "metadata": {}, + "outputs": [], + "source": [ + "# For each run, find best PartCount (max of H and V per angle),\n", + "# then label angles within 95% of best as positive.\n", + "\n", + "# Best count per angle per run (max of H and V)\n", + "angle_best = df.groupby(['RunId', 'AngleDeg'])['PartCount'].max().reset_index()\n", + "angle_best.columns = ['RunId', 'AngleDeg', 'BestCount']\n", + "\n", + "# Best count per run (overall best angle)\n", + "run_best = angle_best.groupby('RunId')['BestCount'].max().reset_index()\n", + "run_best.columns = ['RunId', 'RunBest']\n", + "\n", + "# Merge and compute labels\n", + "labels = angle_best.merge(run_best, on='RunId')\n", + "labels['IsCompetitive'] = (labels['BestCount'] >= labels['RunBest'] * COMPETITIVE_THRESHOLD).astype(int)\n", + "\n", + "# Pivot to 36-column binary label matrix\n", + "label_matrix = labels.pivot_table(\n", + " index='RunId', columns='AngleDeg', values='IsCompetitive', fill_value=0\n", + ")\n", + "\n", + "# Ensure all 36 angle columns exist (0, 5, 10, ..., 175)\n", + "all_angles = [i * 5 for i in range(36)]\n", + "for a in all_angles:\n", + " if a not in label_matrix.columns:\n", + " label_matrix[a] = 0\n", + "label_matrix = label_matrix[all_angles]\n", + "\n", + "print(f\"Label matrix: {label_matrix.shape}\")\n", + "print(f\"Average competitive angles per run: {label_matrix.sum(axis=1).mean():.1f}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1b2c3d4-0005-0000-0000-000000000005", + "metadata": {}, + "outputs": [], + "source": [ + "# Build feature matrix - one row per run\n", + "features_query = \"\"\"\n", + "SELECT DISTINCT\n", + " r.Id as RunId, p.FileName,\n", + " p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity,\n", + " p.PerimeterToAreaRatio, p.VertexCount,\n", + " r.SheetWidth, r.SheetHeight\n", + "FROM Runs r\n", + "JOIN Parts p ON r.PartId = p.Id\n", + "WHERE r.Id IN ({})\n", + "\"\"\".format(','.join(str(x) for x in label_matrix.index))\n", + "\n", + "conn = sqlite3.connect(DB_PATH)\n", + "features_df = pd.read_sql_query(features_query, conn)\n", + "conn.close()\n", + "\n", + "features_df = features_df.set_index('RunId')\n", + "\n", + "# Derived features\n", + "features_df['SheetAspectRatio'] = features_df['SheetWidth'] / features_df['SheetHeight']\n", + "features_df['PartToSheetAreaRatio'] = features_df['Area'] / (features_df['SheetWidth'] * features_df['SheetHeight'])\n", + "\n", + "# Filter outliers (title blocks, etc.)\n", + "mask = (features_df['BBFill'] >= 0.01) & (features_df['Area'] > 0.1)\n", + "print(f\"Filtering: {(~mask).sum()} outlier runs removed\")\n", + "features_df = features_df[mask]\n", + "label_matrix = label_matrix.loc[features_df.index]\n", + "\n", + "feature_cols = ['Area', 'Convexity', 'AspectRatio', 'BBFill', 'Circularity',\n", + " 'PerimeterToAreaRatio', 'VertexCount',\n", + " 'SheetWidth', 'SheetHeight', 'SheetAspectRatio', 'PartToSheetAreaRatio']\n", + "\n", + "X = features_df[feature_cols].values\n", + "y = label_matrix.values\n", + "\n", + "print(f\"Features: {X.shape}, Labels: {y.shape}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1b2c3d4-0006-0000-0000-000000000006", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.model_selection import GroupShuffleSplit\n", + "from sklearn.multioutput import MultiOutputClassifier\n", + "import xgboost as xgb\n", + "\n", + "# Split by part (all sheet sizes for a part stay in the same split)\n", + "groups = features_df['FileName']\n", + "splitter = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)\n", + "train_idx, test_idx = next(splitter.split(X, y, groups))\n", + "\n", + "X_train, X_test = X[train_idx], X[test_idx]\n", + "y_train, y_test = y[train_idx], y[test_idx]\n", + "\n", + "print(f\"Train: {len(train_idx)}, Test: {len(test_idx)}\")\n", + "\n", + "# Train XGBoost multi-label classifier\n", + "base_clf = xgb.XGBClassifier(\n", + " n_estimators=200,\n", + " max_depth=6,\n", + " learning_rate=0.1,\n", + " use_label_encoder=False,\n", + " eval_metric='logloss',\n", + " random_state=42\n", + ")\n", + "\n", + "clf = MultiOutputClassifier(base_clf, n_jobs=-1)\n", + "clf.fit(X_train, y_train)\n", + "print(\"Training complete\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1b2c3d4-0007-0000-0000-000000000007", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.metrics import recall_score, precision_score\n", + "import matplotlib.pyplot as plt\n", + "\n", + "y_pred = clf.predict(X_test)\n", + "y_prob = np.array([est.predict_proba(X_test)[:, 1] for est in clf.estimators_]).T\n", + "\n", + "# Per-angle metrics\n", + "recalls = []\n", + "precisions = []\n", + "for i in range(36):\n", + " if y_test[:, i].sum() > 0:\n", + " recalls.append(recall_score(y_test[:, i], y_pred[:, i], zero_division=0))\n", + " precisions.append(precision_score(y_test[:, i], y_pred[:, i], zero_division=0))\n", + "\n", + "print(f\"Mean recall: {np.mean(recalls):.3f}\")\n", + "print(f\"Mean precision: {np.mean(precisions):.3f}\")\n", + "\n", + "# Average angles predicted per run\n", + "avg_predicted = y_pred.sum(axis=1).mean()\n", + "print(f\"Avg angles predicted per run: {avg_predicted:.1f}\")\n", + "\n", + "# Plot\n", + "fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n", + "axes[0].bar(range(len(recalls)), recalls)\n", + "axes[0].set_title('Recall per Angle Bin')\n", + "axes[0].set_xlabel('Angle (5-deg bins)')\n", + "axes[0].axhline(y=0.95, color='r', linestyle='--', label='Target 95%')\n", + "axes[0].legend()\n", + "\n", + "axes[1].bar(range(len(precisions)), precisions)\n", + "axes[1].set_title('Precision per Angle Bin')\n", + "axes[1].set_xlabel('Angle (5-deg bins)')\n", + "axes[1].axhline(y=0.60, color='r', linestyle='--', label='Target 60%')\n", + "axes[1].legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1b2c3d4-0008-0000-0000-000000000008", + "metadata": {}, + "outputs": [], + "source": [ + "from skl2onnx import convert_sklearn\n", + "from skl2onnx.common.data_types import FloatTensorType\n", + "from pathlib import Path\n", + "\n", + "initial_type = [('features', FloatTensorType([None, 11]))]\n", + "onnx_model = convert_sklearn(clf, initial_types=initial_type)\n", + "\n", + "output_path = Path(OUTPUT_PATH)\n", + "output_path.parent.mkdir(parents=True, exist_ok=True)\n", + "\n", + "with open(output_path, 'wb') as f:\n", + " f.write(onnx_model.SerializeToString())\n", + "\n", + "print(f\"Model saved to {output_path} ({output_path.stat().st_size / 1024:.0f} KB)\")" + ] + } + ] +} From 8cc14997afa0fe70e84e573a4cbfb7c6ffd54fa6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:27:44 -0400 Subject: [PATCH 030/116] feat(engine): add AnglePredictor ONNX inference class Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/ML/AnglePredictor.cs | 119 +++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 OpenNest.Engine/ML/AnglePredictor.cs diff --git a/OpenNest.Engine/ML/AnglePredictor.cs b/OpenNest.Engine/ML/AnglePredictor.cs new file mode 100644 index 0000000..3f2386c --- /dev/null +++ b/OpenNest.Engine/ML/AnglePredictor.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; +using OpenNest.Math; + +namespace OpenNest.Engine.ML +{ + public static class AnglePredictor + { + private static InferenceSession _session; + private static bool _loadAttempted; + private static readonly object _lock = new(); + + public static List PredictAngles( + PartFeatures features, double sheetWidth, double sheetHeight, + double threshold = 0.3) + { + var session = GetSession(); + if (session == null) + return null; + + try + { + var input = new float[11]; + input[0] = (float)features.Area; + input[1] = (float)features.Convexity; + input[2] = (float)features.AspectRatio; + input[3] = (float)features.BoundingBoxFill; + input[4] = (float)features.Circularity; + input[5] = (float)features.PerimeterToAreaRatio; + input[6] = features.VertexCount; + input[7] = (float)sheetWidth; + input[8] = (float)sheetHeight; + input[9] = (float)(sheetWidth / (sheetHeight > 0 ? sheetHeight : 1.0)); + input[10] = (float)(features.Area / (sheetWidth * sheetHeight)); + + var tensor = new DenseTensor(input, new[] { 1, 11 }); + var inputs = new List + { + NamedOnnxValue.CreateFromTensor("features", tensor) + }; + + using var results = session.Run(inputs); + var probabilities = results.First().AsEnumerable().ToArray(); + + var angles = new List<(double angleDeg, float prob)>(); + for (var i = 0; i < 36 && i < probabilities.Length; i++) + { + if (probabilities[i] >= threshold) + angles.Add((i * 5.0, probabilities[i])); + } + + // Minimum 3 angles — take top by probability if fewer pass threshold. + if (angles.Count < 3) + { + angles = probabilities + .Select((p, i) => (angleDeg: i * 5.0, prob: p)) + .OrderByDescending(x => x.prob) + .Take(3) + .ToList(); + } + + // Always include 0 and 90 as safety fallback. + var result = angles.Select(a => Angle.ToRadians(a.angleDeg)).ToList(); + + if (!result.Any(a => a.IsEqualTo(0))) + result.Add(0); + if (!result.Any(a => a.IsEqualTo(Angle.HalfPI))) + result.Add(Angle.HalfPI); + + return result; + } + catch (Exception ex) + { + Debug.WriteLine($"[AnglePredictor] Inference failed: {ex.Message}"); + return null; + } + } + + private static InferenceSession GetSession() + { + if (_loadAttempted) + return _session; + + lock (_lock) + { + if (_loadAttempted) + return _session; + + _loadAttempted = true; + + try + { + var dir = Path.GetDirectoryName(typeof(AnglePredictor).Assembly.Location); + var modelPath = Path.Combine(dir, "Models", "angle_predictor.onnx"); + + if (!File.Exists(modelPath)) + { + Debug.WriteLine($"[AnglePredictor] Model not found: {modelPath}"); + return null; + } + + _session = new InferenceSession(modelPath); + Debug.WriteLine("[AnglePredictor] Model loaded successfully"); + } + catch (Exception ex) + { + Debug.WriteLine($"[AnglePredictor] Failed to load model: {ex.Message}"); + } + + return _session; + } + } + } +} From e33b5ba06394ddf66d215e7458b4759ae783642a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:28:50 -0400 Subject: [PATCH 031/116] feat(engine): integrate AnglePredictor into FindBestFill angle selection Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/NestEngine.cs | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index c934aad..5092bc3 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using OpenNest.Converters; using OpenNest.Engine.BestFit; +using OpenNest.Engine.ML; using OpenNest.Geometry; using OpenNest.Math; using OpenNest.RectanglePacking; @@ -129,6 +130,32 @@ namespace OpenNest } } + // When the work area triggers a full sweep (and we're not forcing it for training), + // try ML angle prediction to reduce the sweep. + if (!ForceFullAngleSweep && angles.Count > 2) + { + var features = FeatureExtractor.Extract(item.Drawing); + if (features != null) + { + var predicted = AnglePredictor.PredictAngles( + features, workArea.Width, workArea.Length); + + if (predicted != null) + { + // Use predicted angles, but always keep bestRotation and bestRotation + 90. + var mlAngles = new List(predicted); + + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation))) + mlAngles.Add(bestRotation); + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI))) + mlAngles.Add(bestRotation + Angle.HalfPI); + + Debug.WriteLine($"[FindBestFill] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); + angles = mlAngles; + } + } + } + // Try pair-based approach first. var pairResult = FillWithPairs(item, workArea); var best = pairResult; @@ -214,6 +241,32 @@ namespace OpenNest } } + // When the work area triggers a full sweep (and we're not forcing it for training), + // try ML angle prediction to reduce the sweep. + if (!ForceFullAngleSweep && angles.Count > 2) + { + var features = FeatureExtractor.Extract(item.Drawing); + if (features != null) + { + var predicted = AnglePredictor.PredictAngles( + features, workArea.Width, workArea.Length); + + if (predicted != null) + { + // Use predicted angles, but always keep bestRotation and bestRotation + 90. + var mlAngles = new List(predicted); + + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation))) + mlAngles.Add(bestRotation); + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI))) + mlAngles.Add(bestRotation + Angle.HalfPI); + + Debug.WriteLine($"[FindBestFill] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); + angles = mlAngles; + } + } + } + // Pairs phase first var pairSw = Stopwatch.StartNew(); var pairResult = FillWithPairs(item, workArea, token, progress); From 9cc6cfa1b1cb7cbc8895ca503145e2b0df96be84 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:33:07 -0400 Subject: [PATCH 032/116] fix(engine): add volatile to AnglePredictor lock field and Models content copy - Mark _loadAttempted as volatile for correct double-checked locking - Add Content item to copy Models/ directory to output for ONNX inference Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/ML/AnglePredictor.cs | 2 +- OpenNest.Engine/OpenNest.Engine.csproj | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/OpenNest.Engine/ML/AnglePredictor.cs b/OpenNest.Engine/ML/AnglePredictor.cs index 3f2386c..da864a8 100644 --- a/OpenNest.Engine/ML/AnglePredictor.cs +++ b/OpenNest.Engine/ML/AnglePredictor.cs @@ -12,7 +12,7 @@ namespace OpenNest.Engine.ML public static class AnglePredictor { private static InferenceSession _session; - private static bool _loadAttempted; + private static volatile bool _loadAttempted; private static readonly object _lock = new(); public static List PredictAngles( diff --git a/OpenNest.Engine/OpenNest.Engine.csproj b/OpenNest.Engine/OpenNest.Engine.csproj index a4b3e98..bcf6d9e 100644 --- a/OpenNest.Engine/OpenNest.Engine.csproj +++ b/OpenNest.Engine/OpenNest.Engine.csproj @@ -10,4 +10,7 @@ + + + From 930dd59213fb4cd155f21ffddb590e6d7bd39ac4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:40:25 -0400 Subject: [PATCH 033/116] fix(ui): set Description in phase-level progress reports ReportProgress was not setting Description, so the Detail row always showed the default em-dash. Now each phase report includes a meaningful description, and UpdateProgress always updates the label (resetting to em-dash when null). Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 3 ++- OpenNest/Forms/NestProgressForm.cs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 5092bc3..4337b46 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -865,7 +865,8 @@ namespace OpenNest BestPartCount = score.Count, BestDensity = score.Density, UsableRemnantArea = workArea.Area() - totalPartArea, - BestParts = clonedParts + BestParts = clonedParts, + Description = $"{phase}: {score.Count} parts, {score.Density:P1}" }); } diff --git a/OpenNest/Forms/NestProgressForm.cs b/OpenNest/Forms/NestProgressForm.cs index 4053e45..256a121 100644 --- a/OpenNest/Forms/NestProgressForm.cs +++ b/OpenNest/Forms/NestProgressForm.cs @@ -38,8 +38,9 @@ namespace OpenNest.Forms densityValue.Text = progress.BestDensity.ToString("P1"); remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in"; - if (!string.IsNullOrEmpty(progress.Description)) - descriptionValue.Text = progress.Description; + descriptionValue.Text = !string.IsNullOrEmpty(progress.Description) + ? progress.Description + : "\u2014"; } public void ShowCompleted() From c2f775258d862d87fc159148f4db7c979d65ff39 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:48:58 -0400 Subject: [PATCH 034/116] fix(ui): show live per-angle/per-candidate detail during nesting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't overwrite the Detail label with phase-level reports — let the per-angle and per-candidate descriptions from the parallel loops remain visible. Only clear the label on completion. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 2 +- OpenNest/Forms/NestProgressForm.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 4337b46..e6993f2 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -866,7 +866,7 @@ namespace OpenNest BestDensity = score.Density, UsableRemnantArea = workArea.Area() - totalPartArea, BestParts = clonedParts, - Description = $"{phase}: {score.Count} parts, {score.Density:P1}" + Description = null }); } diff --git a/OpenNest/Forms/NestProgressForm.cs b/OpenNest/Forms/NestProgressForm.cs index 256a121..a9512c3 100644 --- a/OpenNest/Forms/NestProgressForm.cs +++ b/OpenNest/Forms/NestProgressForm.cs @@ -38,9 +38,8 @@ namespace OpenNest.Forms densityValue.Text = progress.BestDensity.ToString("P1"); remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in"; - descriptionValue.Text = !string.IsNullOrEmpty(progress.Description) - ? progress.Description - : "\u2014"; + if (!string.IsNullOrEmpty(progress.Description)) + descriptionValue.Text = progress.Description; } public void ShowCompleted() @@ -53,6 +52,7 @@ namespace OpenNest.Forms UpdateElapsed(); phaseValue.Text = "Done"; + descriptionValue.Text = "\u2014"; stopButton.Text = "Close"; stopButton.Enabled = true; stopButton.Click -= StopButton_Click; From 91281c88135b5c3f6d70bbfe832127e9b762f1f1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:51:29 -0400 Subject: [PATCH 035/116] fix(engine): throttle progress reports to 150ms intervals Parallel loops were flooding the UI with per-angle/per-candidate reports faster than WinForms could render them. Use Interlocked timestamp checks to report at most every 150ms, keeping descriptions readable. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 39 ++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index e6993f2..51615a4 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -285,6 +285,8 @@ namespace OpenNest var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); var angleBag = new System.Collections.Concurrent.ConcurrentBag(); + long lastLinearReport = 0; + System.Threading.Tasks.Parallel.ForEach(angles, new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, angle => @@ -305,14 +307,19 @@ namespace OpenNest angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count }); } - var bestDir = (h?.Count ?? 0) >= (v?.Count ?? 0) ? "H" : "V"; - var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0); - progress?.Report(new NestProgress + var now = linearSw.ElapsedMilliseconds; + if (progress != null && now - Interlocked.Read(ref lastLinearReport) >= 150) { - Phase = NestPhase.Linear, - PlateNumber = PlateNumber, - Description = $"Linear: {angleDeg:F0}° {bestDir} - {bestCount} parts" - }); + Interlocked.Exchange(ref lastLinearReport, now); + var bestDir = (h?.Count ?? 0) >= (v?.Count ?? 0) ? "H" : "V"; + var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0); + progress.Report(new NestProgress + { + Phase = NestPhase.Linear, + PlateNumber = PlateNumber, + Description = $"Linear: {angleDeg:F0}° {bestDir} - {bestCount} parts" + }); + } }); linearSw.Stop(); AngleResults.AddRange(angleBag); @@ -515,6 +522,9 @@ namespace OpenNest try { + var pairsSw = Stopwatch.StartNew(); + long lastPairsReport = 0; + System.Threading.Tasks.Parallel.For(0, candidates.Count, new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, i => @@ -528,12 +538,17 @@ namespace OpenNest if (filled != null && filled.Count > 0) resultBag.Add((FillScore.Compute(filled, workArea), filled)); - progress?.Report(new NestProgress + var now = pairsSw.ElapsedMilliseconds; + if (progress != null && now - Interlocked.Read(ref lastPairsReport) >= 150) { - Phase = NestPhase.Pairs, - PlateNumber = PlateNumber, - Description = $"Pairs: candidate {i + 1}/{candidates.Count} - {filled?.Count ?? 0} parts" - }); + Interlocked.Exchange(ref lastPairsReport, now); + progress.Report(new NestProgress + { + Phase = NestPhase.Pairs, + PlateNumber = PlateNumber, + Description = $"Pairs: candidate {i + 1}/{candidates.Count} - {filled?.Count ?? 0} parts" + }); + } }); } catch (OperationCanceledException) From 9783d417bd24e92c30072fb5039199b932861724 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 20:57:01 -0400 Subject: [PATCH 036/116] feat(engine): show running ledger in progress descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear phase shows "Linear: 12/36 angles, 45° = 48 parts" with a running count. Pairs phase shows "Pairs: 8/50 candidates, best = 252 parts" tracking the best result seen so far. Reports on every completion so the UI always reflects current state. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 52 ++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 51615a4..1eaf05c 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -285,7 +285,7 @@ namespace OpenNest var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); var angleBag = new System.Collections.Concurrent.ConcurrentBag(); - long lastLinearReport = 0; + var anglesCompleted = 0; System.Threading.Tasks.Parallel.ForEach(angles, new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, @@ -307,19 +307,14 @@ namespace OpenNest angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count }); } - var now = linearSw.ElapsedMilliseconds; - if (progress != null && now - Interlocked.Read(ref lastLinearReport) >= 150) + var done = Interlocked.Increment(ref anglesCompleted); + var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0); + progress?.Report(new NestProgress { - Interlocked.Exchange(ref lastLinearReport, now); - var bestDir = (h?.Count ?? 0) >= (v?.Count ?? 0) ? "H" : "V"; - var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0); - progress.Report(new NestProgress - { - Phase = NestPhase.Linear, - PlateNumber = PlateNumber, - Description = $"Linear: {angleDeg:F0}° {bestDir} - {bestCount} parts" - }); - } + Phase = NestPhase.Linear, + PlateNumber = PlateNumber, + Description = $"Linear: {done}/{angles.Count} angles, {angleDeg:F0}° = {bestCount} parts" + }); }); linearSw.Stop(); AngleResults.AddRange(angleBag); @@ -522,8 +517,8 @@ namespace OpenNest try { - var pairsSw = Stopwatch.StartNew(); - long lastPairsReport = 0; + var pairsCompleted = 0; + var pairsBestCount = 0; System.Threading.Tasks.Parallel.For(0, candidates.Count, new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, @@ -538,17 +533,14 @@ namespace OpenNest if (filled != null && filled.Count > 0) resultBag.Add((FillScore.Compute(filled, workArea), filled)); - var now = pairsSw.ElapsedMilliseconds; - if (progress != null && now - Interlocked.Read(ref lastPairsReport) >= 150) + var done = Interlocked.Increment(ref pairsCompleted); + InterlockedMax(ref pairsBestCount, filled?.Count ?? 0); + progress?.Report(new NestProgress { - Interlocked.Exchange(ref lastPairsReport, now); - progress.Report(new NestProgress - { - Phase = NestPhase.Pairs, - PlateNumber = PlateNumber, - Description = $"Pairs: candidate {i + 1}/{candidates.Count} - {filled?.Count ?? 0} parts" - }); - } + Phase = NestPhase.Pairs, + PlateNumber = PlateNumber, + Description = $"Pairs: {done}/{candidates.Count} candidates, best = {pairsBestCount} parts" + }); }); } catch (OperationCanceledException) @@ -1103,5 +1095,15 @@ namespace OpenNest return result; } + private static void InterlockedMax(ref int location, int value) + { + int current; + do + { + current = location; + if (value <= current) return; + } while (Interlocked.CompareExchange(ref location, value, current) != current); + } + } } From eddcc7602d7f1020f4f2a57038f3e1e2a9ba9253 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 21:50:01 -0400 Subject: [PATCH 037/116] feat(console): refactor Program.cs and add DXF file import support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor flat top-level statements into NestConsole class with Options and focused methods. Add support for passing .dxf files directly as input — auto-imports geometry via DxfImporter and creates a fresh nest with a plate when --size is specified. Supports three modes: nest-only, DXF-only (requires --size), and mixed nest+DXF. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/Program.cs | 642 ++++++++++++++++++++++-------------- 1 file changed, 398 insertions(+), 244 deletions(-) diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 24451ee..c66deda 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -4,268 +4,422 @@ using System.Diagnostics; using System.IO; using System.Linq; using OpenNest; +using OpenNest.Converters; using OpenNest.Geometry; using OpenNest.IO; -// Parse arguments. -var nestFile = (string)null; -var drawingName = (string)null; -var plateIndex = 0; -var outputFile = (string)null; -var quantity = 0; -var spacing = (double?)null; -var plateWidth = (double?)null; -var plateHeight = (double?)null; -var checkOverlaps = false; -var noSave = false; -var noLog = false; -var keepParts = false; -var autoNest = false; -var templateFile = (string)null; +return NestConsole.Run(args); -for (var i = 0; i < args.Length; i++) +static class NestConsole { - switch (args[i]) + public static int Run(string[] args) { - case "--drawing" when i + 1 < args.Length: - drawingName = args[++i]; - break; - case "--plate" when i + 1 < args.Length: - plateIndex = int.Parse(args[++i]); - break; - case "--output" when i + 1 < args.Length: - outputFile = args[++i]; - break; - case "--quantity" when i + 1 < args.Length: - quantity = int.Parse(args[++i]); - break; - case "--spacing" when i + 1 < args.Length: - spacing = double.Parse(args[++i]); - break; - case "--size" when i + 1 < args.Length: - var parts = args[++i].Split('x'); - if (parts.Length == 2) - { - plateWidth = double.Parse(parts[0]); - plateHeight = double.Parse(parts[1]); - } - break; - case "--check-overlaps": - checkOverlaps = true; - break; - case "--no-save": - noSave = true; - break; - case "--no-log": - noLog = true; - break; - case "--keep-parts": - keepParts = true; - break; - case "--template" when i + 1 < args.Length: - templateFile = args[++i]; - break; - case "--autonest": - autoNest = true; - break; - case "--help": - case "-h": + var options = ParseArgs(args); + + if (options == null) + return 0; // --help was requested + + if (options.InputFiles.Count == 0) + { PrintUsage(); + return 1; + } + + foreach (var f in options.InputFiles) + { + if (!File.Exists(f)) + { + Console.Error.WriteLine($"Error: file not found: {f}"); + return 1; + } + } + + using var log = SetUpLog(options); + var nest = LoadOrCreateNest(options); + + if (nest == null) + return 1; + + var plate = nest.Plates[options.PlateIndex]; + + ApplyTemplate(plate, options); + ApplyOverrides(plate, options); + + var drawing = ResolveDrawing(nest, options); + + if (drawing == null) + return 1; + + var existingCount = plate.Parts.Count; + + if (!options.KeepParts) + plate.Parts.Clear(); + + PrintHeader(nest, plate, drawing, existingCount, options); + + var (success, elapsed) = Fill(nest, plate, drawing, options); + + var overlapCount = CheckOverlaps(plate, options); + + // Flush and close the log before printing results. + Trace.Flush(); + log?.Dispose(); + + PrintResults(success, plate, elapsed); + Save(nest, options); + + return options.CheckOverlaps && overlapCount > 0 ? 1 : 0; + } + + static Options ParseArgs(string[] args) + { + var o = new Options(); + + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--drawing" when i + 1 < args.Length: + o.DrawingName = args[++i]; + break; + case "--plate" when i + 1 < args.Length: + o.PlateIndex = int.Parse(args[++i]); + break; + case "--output" when i + 1 < args.Length: + o.OutputFile = args[++i]; + break; + case "--quantity" when i + 1 < args.Length: + o.Quantity = int.Parse(args[++i]); + break; + case "--spacing" when i + 1 < args.Length: + o.Spacing = double.Parse(args[++i]); + break; + case "--size" when i + 1 < args.Length: + var parts = args[++i].Split('x'); + if (parts.Length == 2) + { + o.PlateWidth = double.Parse(parts[0]); + o.PlateHeight = double.Parse(parts[1]); + } + break; + case "--check-overlaps": + o.CheckOverlaps = true; + break; + case "--no-save": + o.NoSave = true; + break; + case "--no-log": + o.NoLog = true; + break; + case "--keep-parts": + o.KeepParts = true; + break; + case "--template" when i + 1 < args.Length: + o.TemplateFile = args[++i]; + break; + case "--autonest": + o.AutoNest = true; + break; + case "--help": + case "-h": + PrintUsage(); + return null; + default: + if (!args[i].StartsWith("--")) + o.InputFiles.Add(args[i]); + break; + } + } + + return o; + } + + static StreamWriter SetUpLog(Options options) + { + if (options.NoLog) + return null; + + var baseDir = Path.GetDirectoryName(options.InputFiles[0]); + var logDir = Path.Combine(baseDir, "test-harness-logs"); + Directory.CreateDirectory(logDir); + var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log"); + var writer = new StreamWriter(logFile) { AutoFlush = true }; + Trace.Listeners.Add(new TextWriterTraceListener(writer)); + Console.WriteLine($"Debug log: {logFile}"); + return writer; + } + + static Nest LoadOrCreateNest(Options options) + { + var nestFile = options.InputFiles.FirstOrDefault(f => + f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); + var dxfFiles = options.InputFiles.Where(f => + f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToList(); + + // If we have a nest file, load it and optionally add DXFs. + if (nestFile != null) + { + var nest = new NestReader(nestFile).Read(); + + if (nest.Plates.Count == 0) + { + Console.Error.WriteLine("Error: nest file contains no plates"); + return null; + } + + if (options.PlateIndex >= nest.Plates.Count) + { + Console.Error.WriteLine($"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})"); + return null; + } + + foreach (var dxf in dxfFiles) + { + var drawing = ImportDxf(dxf); + + if (drawing == null) + return null; + + nest.Drawings.Add(drawing); + Console.WriteLine($"Imported: {drawing.Name}"); + } + + return nest; + } + + // DXF-only mode: create a fresh nest. + if (dxfFiles.Count == 0) + { + Console.Error.WriteLine("Error: no nest (.zip) or DXF (.dxf) files specified"); + return null; + } + + if (!options.PlateWidth.HasValue || !options.PlateHeight.HasValue) + { + Console.Error.WriteLine("Error: --size WxH is required when importing DXF files without a nest"); + return null; + } + + var newNest = new Nest { Name = "DXF Import" }; + var plate = new Plate { Size = new Size(options.PlateWidth.Value, options.PlateHeight.Value) }; + newNest.Plates.Add(plate); + + foreach (var dxf in dxfFiles) + { + var drawing = ImportDxf(dxf); + + if (drawing == null) + return null; + + newNest.Drawings.Add(drawing); + Console.WriteLine($"Imported: {drawing.Name}"); + } + + return newNest; + } + + static Drawing ImportDxf(string path) + { + var importer = new DxfImporter(); + + if (!importer.GetGeometry(path, out var geometry)) + { + Console.Error.WriteLine($"Error: failed to read DXF file: {path}"); + return null; + } + + if (geometry.Count == 0) + { + Console.Error.WriteLine($"Error: no geometry found in DXF file: {path}"); + return null; + } + + var pgm = ConvertGeometry.ToProgram(geometry); + + if (pgm == null) + { + Console.Error.WriteLine($"Error: failed to convert geometry: {path}"); + return null; + } + + var name = Path.GetFileNameWithoutExtension(path); + return new Drawing(name, pgm); + } + + static void ApplyTemplate(Plate plate, Options options) + { + if (options.TemplateFile == null) + return; + + if (!File.Exists(options.TemplateFile)) + { + Console.Error.WriteLine($"Error: Template not found: {options.TemplateFile}"); + return; + } + + var templatePlate = new NestReader(options.TemplateFile).Read().PlateDefaults.CreateNew(); + plate.Thickness = templatePlate.Thickness; + plate.Quadrant = templatePlate.Quadrant; + plate.Material = templatePlate.Material; + plate.EdgeSpacing = templatePlate.EdgeSpacing; + plate.PartSpacing = templatePlate.PartSpacing; + Console.WriteLine($"Template: {options.TemplateFile}"); + } + + static void ApplyOverrides(Plate plate, Options options) + { + if (options.Spacing.HasValue) + plate.PartSpacing = options.Spacing.Value; + + // Only apply size override when it wasn't already used to create the plate. + var hasDxfOnly = !options.InputFiles.Any(f => f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); + + if (options.PlateWidth.HasValue && options.PlateHeight.HasValue && !hasDxfOnly) + plate.Size = new Size(options.PlateWidth.Value, options.PlateHeight.Value); + } + + static Drawing ResolveDrawing(Nest nest, Options options) + { + var drawing = options.DrawingName != null + ? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName) + : nest.Drawings.FirstOrDefault(); + + if (drawing != null) + return drawing; + + Console.Error.WriteLine(options.DrawingName != null + ? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}" + : "Error: nest file contains no drawings"); + + return null; + } + + static void PrintHeader(Nest nest, Plate plate, Drawing drawing, int existingCount, Options options) + { + Console.WriteLine($"Nest: {nest.Name}"); + Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}"); + Console.WriteLine($"Drawing: {drawing.Name}"); + Console.WriteLine(options.KeepParts + ? $"Keeping {existingCount} existing parts" + : $"Cleared {existingCount} existing parts"); + Console.WriteLine("---"); + } + + static (bool success, long elapsedMs) Fill(Nest nest, Plate plate, Drawing drawing, Options options) + { + var sw = Stopwatch.StartNew(); + bool success; + + if (options.AutoNest) + { + var nestItems = new List(); + var qty = options.Quantity > 0 ? options.Quantity : 1; + + if (options.DrawingName != null) + { + nestItems.Add(new NestItem { Drawing = drawing, Quantity = qty }); + } + else + { + foreach (var d in nest.Drawings) + nestItems.Add(new NestItem { Drawing = d, Quantity = qty }); + } + + Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); + + var nestParts = NestEngine.AutoNest(nestItems, plate); + plate.Parts.AddRange(nestParts); + success = nestParts.Count > 0; + } + else + { + var engine = new NestEngine(plate); + var item = new NestItem { Drawing = drawing, Quantity = options.Quantity }; + success = engine.Fill(item); + } + + sw.Stop(); + return (success, sw.ElapsedMilliseconds); + } + + static int CheckOverlaps(Plate plate, Options options) + { + if (!options.CheckOverlaps || plate.Parts.Count == 0) return 0; - default: - if (!args[i].StartsWith("--") && nestFile == null) - nestFile = args[i]; - break; + + var hasOverlaps = plate.HasOverlappingParts(out var overlapPts); + Console.WriteLine(hasOverlaps + ? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points" + : "Overlap check: PASS"); + + return overlapPts.Count; } -} -if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile)) -{ - PrintUsage(); - return 1; -} - -// Set up debug log file. -StreamWriter logWriter = null; - -if (!noLog) -{ - var logDir = Path.Combine(Path.GetDirectoryName(nestFile), "test-harness-logs"); - Directory.CreateDirectory(logDir); - var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log"); - logWriter = new StreamWriter(logFile) { AutoFlush = true }; - Trace.Listeners.Add(new TextWriterTraceListener(logWriter)); - Console.WriteLine($"Debug log: {logFile}"); -} - -// Load nest. -var reader = new NestReader(nestFile); -var nest = reader.Read(); - -if (nest.Plates.Count == 0) -{ - Console.Error.WriteLine("Error: nest file contains no plates"); - return 1; -} - -if (plateIndex >= nest.Plates.Count) -{ - Console.Error.WriteLine($"Error: plate index {plateIndex} out of range (0-{nest.Plates.Count - 1})"); - return 1; -} - -var plate = nest.Plates[plateIndex]; - -// Apply template defaults. -if (templateFile != null) -{ - if (!File.Exists(templateFile)) + static void PrintResults(bool success, Plate plate, long elapsedMs) { - Console.Error.WriteLine($"Error: Template not found: {templateFile}"); - return 1; + Console.WriteLine($"Result: {(success ? "success" : "failed")}"); + Console.WriteLine($"Parts placed: {plate.Parts.Count}"); + Console.WriteLine($"Utilization: {plate.Utilization():P1}"); + Console.WriteLine($"Time: {elapsedMs}ms"); } - var templateNest = new NestReader(templateFile).Read(); - var templatePlate = templateNest.PlateDefaults.CreateNew(); - plate.Thickness = templatePlate.Thickness; - plate.Quadrant = templatePlate.Quadrant; - plate.Material = templatePlate.Material; - plate.EdgeSpacing = templatePlate.EdgeSpacing; - plate.PartSpacing = templatePlate.PartSpacing; - Console.WriteLine($"Template: {templateFile}"); -} -// Apply overrides. -if (spacing.HasValue) - plate.PartSpacing = spacing.Value; - -if (plateWidth.HasValue && plateHeight.HasValue) - plate.Size = new Size(plateWidth.Value, plateHeight.Value); - -// Find drawing. -var drawing = drawingName != null - ? nest.Drawings.FirstOrDefault(d => d.Name == drawingName) - : nest.Drawings.FirstOrDefault(); - -if (drawing == null) -{ - Console.Error.WriteLine(drawingName != null - ? $"Error: drawing '{drawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}" - : "Error: nest file contains no drawings"); - return 1; -} - -// Clear existing parts. -var existingCount = plate.Parts.Count; - -if (!keepParts) - plate.Parts.Clear(); - -Console.WriteLine($"Nest: {nest.Name}"); -Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}"); -Console.WriteLine($"Drawing: {drawing.Name}"); - -if (!keepParts) - Console.WriteLine($"Cleared {existingCount} existing parts"); -else - Console.WriteLine($"Keeping {existingCount} existing parts"); - -Console.WriteLine("---"); - -// Run fill or autonest. -var sw = Stopwatch.StartNew(); -bool success; - -if (autoNest) -{ - // AutoNest: use all drawings (or specific drawing if --drawing given). - var nestItems = new List(); - - if (drawingName != null) + static void Save(Nest nest, Options options) { - nestItems.Add(new NestItem { Drawing = drawing, Quantity = quantity > 0 ? quantity : 1 }); + if (options.NoSave) + return; + + var firstInput = options.InputFiles[0]; + var outputFile = options.OutputFile ?? Path.Combine( + Path.GetDirectoryName(firstInput), + $"{Path.GetFileNameWithoutExtension(firstInput)}-result.zip"); + + new NestWriter(nest).Write(outputFile); + Console.WriteLine($"Saved: {outputFile}"); } - else + + static void PrintUsage() { - foreach (var d in nest.Drawings) - nestItems.Add(new NestItem { Drawing = d, Quantity = quantity > 0 ? quantity : 1 }); + Console.Error.WriteLine("Usage: OpenNest.Console [options]"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Arguments:"); + Console.Error.WriteLine(" input-files One or more .zip nest files or .dxf drawing files"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Modes:"); + Console.Error.WriteLine(" Load nest and fill (existing behavior)"); + Console.Error.WriteLine(" --size WxH Import DXF, create plate, and fill"); + Console.Error.WriteLine(" Load nest and add imported DXF drawings"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Options:"); + Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); + Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); + Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); + Console.Error.WriteLine(" --spacing Override part spacing"); + Console.Error.WriteLine(" --size Override plate size (e.g. 120x60); required for DXF-only mode"); + Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); + Console.Error.WriteLine(" --template Nest template for plate defaults (thickness, quadrant, material, spacing)"); + Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); + Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling"); + Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); + Console.Error.WriteLine(" --no-save Skip saving output file"); + Console.Error.WriteLine(" --no-log Skip writing debug log file"); + Console.Error.WriteLine(" -h, --help Show this help"); } - Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); - - var nestParts = NestEngine.AutoNest(nestItems, plate); - plate.Parts.AddRange(nestParts); - success = nestParts.Count > 0; -} -else -{ - var engine = new NestEngine(plate); - var item = new NestItem { Drawing = drawing, Quantity = quantity }; - success = engine.Fill(item); -} - -sw.Stop(); - -// Check overlaps. -var overlapCount = 0; - -if (checkOverlaps && plate.Parts.Count > 0) -{ - List overlapPts; - var hasOverlaps = plate.HasOverlappingParts(out overlapPts); - overlapCount = overlapPts.Count; - - if (hasOverlaps) - Console.WriteLine($"OVERLAPS DETECTED: {overlapCount} intersection points"); - else - Console.WriteLine("Overlap check: PASS"); -} - -// Flush and close the log. -Trace.Flush(); -logWriter?.Dispose(); - -// Print results. -Console.WriteLine($"Result: {(success ? "success" : "failed")}"); -Console.WriteLine($"Parts placed: {plate.Parts.Count}"); -Console.WriteLine($"Utilization: {plate.Utilization():P1}"); -Console.WriteLine($"Time: {sw.ElapsedMilliseconds}ms"); - -// Save output. -if (!noSave) -{ - if (outputFile == null) + class Options { - var dir = Path.GetDirectoryName(nestFile); - var name = Path.GetFileNameWithoutExtension(nestFile); - outputFile = Path.Combine(dir, $"{name}-result.zip"); + public List InputFiles = new(); + public string DrawingName; + public int PlateIndex; + public string OutputFile; + public int Quantity; + public double? Spacing; + public double? PlateWidth; + public double? PlateHeight; + public bool CheckOverlaps; + public bool NoSave; + public bool NoLog; + public bool KeepParts; + public bool AutoNest; + public string TemplateFile; } - - var writer = new NestWriter(nest); - writer.Write(outputFile); - Console.WriteLine($"Saved: {outputFile}"); -} - -return checkOverlaps && overlapCount > 0 ? 1 : 0; - -void PrintUsage() -{ - Console.Error.WriteLine("Usage: OpenNest.Console [options]"); - Console.Error.WriteLine(); - Console.Error.WriteLine("Arguments:"); - Console.Error.WriteLine(" nest-file Path to a .zip nest file"); - Console.Error.WriteLine(); - Console.Error.WriteLine("Options:"); - Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); - Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); - Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); - Console.Error.WriteLine(" --spacing Override part spacing"); - Console.Error.WriteLine(" --size Override plate size (e.g. 120x60)"); - Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); - Console.Error.WriteLine(" --template Nest template for plate defaults (thickness, quadrant, material, spacing)"); - Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); - Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling"); - Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); - Console.Error.WriteLine(" --no-save Skip saving output file"); - Console.Error.WriteLine(" --no-log Skip writing debug log file"); - Console.Error.WriteLine(" -h, --help Show this help"); } From 6993d169e471cefdd0a989150e6a29d8fed2b594 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 22:51:44 -0400 Subject: [PATCH 038/116] perf(core): optimize geometry with edge pruning and vertex dedup Vector implements IEquatable with proper GetHashCode for HashSet usage. Polygon.FindCrossing uses bounding-box pruning to skip non-overlapping edge pairs. Helper.DirectionalDistance deduplicates vertices via HashSet, sorts edges for early-exit pruning, and adds a new array-based overload that avoids allocations. PartBoundary sorts directional edges and exposes GetEdges for zero-alloc access. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/Polygon.cs | 24 +++ OpenNest.Core/Geometry/Vector.cs | 40 +++-- OpenNest.Core/Helper.cs | 237 ++++++++++++++++++++++-------- OpenNest.Engine/PartBoundary.cs | 17 ++- 4 files changed, 238 insertions(+), 80 deletions(-) diff --git a/OpenNest.Core/Geometry/Polygon.cs b/OpenNest.Core/Geometry/Polygon.cs index c03f209..e48bf24 100644 --- a/OpenNest.Core/Geometry/Polygon.cs +++ b/OpenNest.Core/Geometry/Polygon.cs @@ -493,13 +493,37 @@ namespace OpenNest.Geometry { var n = Vertices.Count - 1; + // Pre-calculate edge bounding boxes to speed up intersection checks. + var edgeBounds = new (double minX, double maxX, double minY, double maxY)[n]; for (var i = 0; i < n; i++) { + var v1 = Vertices[i]; + var v2 = Vertices[i + 1]; + edgeBounds[i] = ( + System.Math.Min(v1.X, v2.X) - Tolerance.Epsilon, + System.Math.Max(v1.X, v2.X) + Tolerance.Epsilon, + System.Math.Min(v1.Y, v2.Y) - Tolerance.Epsilon, + System.Math.Max(v1.Y, v2.Y) + Tolerance.Epsilon + ); + } + + for (var i = 0; i < n; i++) + { + var bi = edgeBounds[i]; for (var j = i + 2; j < n; j++) { if (i == 0 && j == n - 1) continue; + var bj = edgeBounds[j]; + + // Prune with bounding box check. + if (bi.maxX < bj.minX || bj.maxX < bi.minX || + bi.maxY < bj.minY || bj.maxY < bi.minY) + { + continue; + } + if (SegmentsIntersect(Vertices[i], Vertices[i + 1], Vertices[j], Vertices[j + 1], out pt)) { edgeI = i; diff --git a/OpenNest.Core/Geometry/Vector.cs b/OpenNest.Core/Geometry/Vector.cs index 64dae7f..22eccda 100644 --- a/OpenNest.Core/Geometry/Vector.cs +++ b/OpenNest.Core/Geometry/Vector.cs @@ -3,7 +3,7 @@ using OpenNest.Math; namespace OpenNest.Geometry { - public struct Vector + public struct Vector : IEquatable { public static readonly Vector Invalid = new Vector(double.NaN, double.NaN); public static readonly Vector Zero = new Vector(0, 0); @@ -17,6 +17,29 @@ namespace OpenNest.Geometry Y = y; } + public bool Equals(Vector other) + { + return X.IsEqualTo(other.X) && Y.IsEqualTo(other.Y); + } + + public override bool Equals(object obj) + { + return obj is Vector other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + // Use a simple but effective hash combine. + // We use a small epsilon-safe rounding if needed, but for uniqueness in HashSet + // during a single operation, raw bits or slightly rounded is usually fine. + // However, IsEqualTo uses Tolerance.Epsilon, so we should probably round to some precision. + // But typically for these geometric algorithms, exact matches (or very close) are what we want to prune. + return (X.GetHashCode() * 397) ^ Y.GetHashCode(); + } + } + public double DistanceTo(Vector pt) { var vx = pt.X - X; @@ -186,21 +209,6 @@ namespace OpenNest.Geometry return new Vector(X, Y); } - public override bool Equals(object obj) - { - if (!(obj is Vector)) - return false; - - var pt = (Vector)obj; - - return (X.IsEqualTo(pt.X)) && (Y.IsEqualTo(pt.Y)); - } - - public override int GetHashCode() - { - return base.GetHashCode(); - } - public override string ToString() { return string.Format("[Vector: X:{0}, Y:{1}]", X, Y); diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index c2c23af..ecea400 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -882,7 +882,7 @@ namespace OpenNest case PushDirection.Right: { var dy = p2y - p1y; - if (dy > -Tolerance.Epsilon && dy < Tolerance.Epsilon) + if (System.Math.Abs(dy) < Tolerance.Epsilon) return double.MaxValue; var t = (vy - p1y) / dy; @@ -891,6 +891,7 @@ namespace OpenNest var ix = p1x + t * (p2x - p1x); var dist = direction == PushDirection.Left ? vx - ix : ix - vx; + if (dist > Tolerance.Epsilon) return dist; if (dist >= -Tolerance.Epsilon) return 0; return double.MaxValue; @@ -900,7 +901,7 @@ namespace OpenNest case PushDirection.Up: { var dx = p2x - p1x; - if (dx > -Tolerance.Epsilon && dx < Tolerance.Epsilon) + if (System.Math.Abs(dx) < Tolerance.Epsilon) return double.MaxValue; var t = (vx - p1x) / dx; @@ -909,6 +910,7 @@ namespace OpenNest var iy = p1y + t * (p2y - p1y); var dist = direction == PushDirection.Down ? vy - iy : iy - vy; + if (dist > Tolerance.Epsilon) return dist; if (dist >= -Tolerance.Epsilon) return 0; return double.MaxValue; @@ -928,38 +930,52 @@ namespace OpenNest { var minDist = double.MaxValue; - // Case 1: Each moving vertex → each stationary edge + // Case 1: Each moving vertex -> each stationary edge + var movingVertices = new HashSet(); for (int i = 0; i < movingLines.Count; i++) { - var movingStart = movingLines[i].pt1; - var movingEnd = movingLines[i].pt2; - - for (int j = 0; j < stationaryLines.Count; j++) - { - var d = RayEdgeDistance(movingStart, stationaryLines[j], direction); - if (d < minDist) minDist = d; - - d = RayEdgeDistance(movingEnd, stationaryLines[j], direction); - if (d < minDist) minDist = d; - } + movingVertices.Add(movingLines[i].pt1); + movingVertices.Add(movingLines[i].pt2); } - // Case 2: Each stationary vertex → each moving edge (opposite direction) - var opposite = OppositeDirection(direction); + var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count]; + for (int i = 0; i < stationaryLines.Count; i++) + stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2); + // Sort edges for pruning if not already sorted (usually they aren't here) + if (direction == PushDirection.Left || direction == PushDirection.Right) + stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + + foreach (var mv in movingVertices) + { + var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction); + if (d < minDist) minDist = d; + } + + // Case 2: Each stationary vertex -> each moving edge (opposite direction) + var opposite = OppositeDirection(direction); + var stationaryVertices = new HashSet(); for (int i = 0; i < stationaryLines.Count; i++) { - var stationaryStart = stationaryLines[i].pt1; - var stationaryEnd = stationaryLines[i].pt2; + stationaryVertices.Add(stationaryLines[i].pt1); + stationaryVertices.Add(stationaryLines[i].pt2); + } - for (int j = 0; j < movingLines.Count; j++) - { - var d = RayEdgeDistance(stationaryStart, movingLines[j], opposite); - if (d < minDist) minDist = d; + var movingEdges = new (Vector start, Vector end)[movingLines.Count]; + for (int i = 0; i < movingLines.Count; i++) + movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2); - d = RayEdgeDistance(stationaryEnd, movingLines[j], opposite); - if (d < minDist) minDist = d; - } + if (opposite == PushDirection.Left || opposite == PushDirection.Right) + movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + + foreach (var sv in stationaryVertices) + { + var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite); + if (d < minDist) minDist = d; } return minDist; @@ -974,51 +990,53 @@ namespace OpenNest List stationaryLines, PushDirection direction) { var minDist = double.MaxValue; + var movingOffset = new Vector(movingDx, movingDy); - // Case 1: Each moving vertex → each stationary edge + // Case 1: Each moving vertex -> each stationary edge + var movingVertices = new HashSet(); for (int i = 0; i < movingLines.Count; i++) { - var ml = movingLines[i]; - var mx1 = ml.pt1.X + movingDx; - var my1 = ml.pt1.Y + movingDy; - var mx2 = ml.pt2.X + movingDx; - var my2 = ml.pt2.Y + movingDy; - - for (int j = 0; j < stationaryLines.Count; j++) - { - var se = stationaryLines[j]; - var d = RayEdgeDistance(mx1, my1, se.pt1.X, se.pt1.Y, se.pt2.X, se.pt2.Y, direction); - if (d < minDist) minDist = d; - - d = RayEdgeDistance(mx2, my2, se.pt1.X, se.pt1.Y, se.pt2.X, se.pt2.Y, direction); - if (d < minDist) minDist = d; - } + movingVertices.Add(movingLines[i].pt1 + movingOffset); + movingVertices.Add(movingLines[i].pt2 + movingOffset); } - // Case 2: Each stationary vertex → each moving edge (opposite direction) - var opposite = OppositeDirection(direction); + var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count]; + for (int i = 0; i < stationaryLines.Count; i++) + stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2); + if (direction == PushDirection.Left || direction == PushDirection.Right) + stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + + foreach (var mv in movingVertices) + { + var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction); + if (d < minDist) minDist = d; + } + + // Case 2: Each stationary vertex -> each moving edge (opposite direction) + var opposite = OppositeDirection(direction); + var stationaryVertices = new HashSet(); for (int i = 0; i < stationaryLines.Count; i++) { - var sl = stationaryLines[i]; + stationaryVertices.Add(stationaryLines[i].pt1); + stationaryVertices.Add(stationaryLines[i].pt2); + } - for (int j = 0; j < movingLines.Count; j++) - { - var me = movingLines[j]; - var d = RayEdgeDistance( - sl.pt1.X, sl.pt1.Y, - me.pt1.X + movingDx, me.pt1.Y + movingDy, - me.pt2.X + movingDx, me.pt2.Y + movingDy, - opposite); - if (d < minDist) minDist = d; + var movingEdges = new (Vector start, Vector end)[movingLines.Count]; + for (int i = 0; i < movingLines.Count; i++) + movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2); - d = RayEdgeDistance( - sl.pt2.X, sl.pt2.Y, - me.pt1.X + movingDx, me.pt1.Y + movingDy, - me.pt2.X + movingDx, me.pt2.Y + movingDy, - opposite); - if (d < minDist) minDist = d; - } + if (opposite == PushDirection.Left || opposite == PushDirection.Right) + movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + + foreach (var sv in stationaryVertices) + { + var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); + if (d < minDist) minDist = d; } return minDist; @@ -1041,6 +1059,105 @@ namespace OpenNest return result; } + /// + /// Computes the minimum directional distance using raw edge arrays and location offsets + /// to avoid all intermediate object allocations. + /// + public static double DirectionalDistance( + (Vector start, Vector end)[] movingEdges, Vector movingOffset, + (Vector start, Vector end)[] stationaryEdges, Vector stationaryOffset, + PushDirection direction) + { + var minDist = double.MaxValue; + + // Extract unique vertices from moving edges. + var movingVertices = new HashSet(); + for (var i = 0; i < movingEdges.Length; i++) + { + movingVertices.Add(movingEdges[i].start + movingOffset); + movingVertices.Add(movingEdges[i].end + movingOffset); + } + + // Case 1: Each moving vertex -> each stationary edge + foreach (var mv in movingVertices) + { + var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction); + if (d < minDist) minDist = d; + } + + // Case 2: Each stationary vertex -> each moving edge (opposite direction) + var opposite = OppositeDirection(direction); + var stationaryVertices = new HashSet(); + for (var i = 0; i < stationaryEdges.Length; i++) + { + stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset); + stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset); + } + + foreach (var sv in stationaryVertices) + { + var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); + if (d < minDist) minDist = d; + } + + return minDist; + } + + private static double OneWayDistance( + Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset, + PushDirection direction) + { + var minDist = double.MaxValue; + var vx = vertex.X; + var vy = vertex.Y; + + // Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary. + if (direction == PushDirection.Left || direction == PushDirection.Right) + { + for (var i = 0; i < edges.Length; i++) + { + var e1 = edges[i].start + edgeOffset; + var e2 = edges[i].end + edgeOffset; + + var minY = e1.Y < e2.Y ? e1.Y : e2.Y; + var maxY = e1.Y > e2.Y ? e1.Y : e2.Y; + + // Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY. + if (vy < minY - Tolerance.Epsilon) + break; + + if (vy > maxY + Tolerance.Epsilon) + continue; + + var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); + if (d < minDist) minDist = d; + } + } + else // Up/Down + { + for (var i = 0; i < edges.Length; i++) + { + var e1 = edges[i].start + edgeOffset; + var e2 = edges[i].end + edgeOffset; + + var minX = e1.X < e2.X ? e1.X : e2.X; + var maxX = e1.X > e2.X ? e1.X : e2.X; + + // Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX. + if (vx < minX - Tolerance.Epsilon) + break; + + if (vx > maxX + Tolerance.Epsilon) + continue; + + var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); + if (d < minDist) minDist = d; + } + } + + return minDist; + } + public static PushDirection OppositeDirection(PushDirection direction) { switch (direction) diff --git a/OpenNest.Engine/PartBoundary.cs b/OpenNest.Engine/PartBoundary.cs index 2545b93..44c14bc 100644 --- a/OpenNest.Engine/PartBoundary.cs +++ b/OpenNest.Engine/PartBoundary.cs @@ -93,10 +93,10 @@ namespace OpenNest } } - leftEdges = left.ToArray(); - rightEdges = right.ToArray(); - upEdges = up.ToArray(); - downEdges = down.ToArray(); + leftEdges = left.OrderBy(e => System.Math.Min(e.Item1.Y, e.Item2.Y)).ToArray(); + rightEdges = right.OrderBy(e => System.Math.Min(e.Item1.Y, e.Item2.Y)).ToArray(); + upEdges = up.OrderBy(e => System.Math.Min(e.Item1.X, e.Item2.X)).ToArray(); + downEdges = down.OrderBy(e => System.Math.Min(e.Item1.X, e.Item2.X)).ToArray(); } /// @@ -152,5 +152,14 @@ namespace OpenNest default: return _leftEdges; } } + + /// + /// Returns the pre-computed edge arrays for the given direction. + /// These are in part-local coordinates (no translation applied). + /// + public (Vector start, Vector end)[] GetEdges(PushDirection direction) + { + return GetDirectionalEdges(direction); + } } } From ce6b25c12ab0d3fa2fa198aeb9908c54f8a5f873 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 22:51:50 -0400 Subject: [PATCH 039/116] refactor(engine): simplify FillLinear with iterative grid fill Replace recursive FillRecursive with flat FillGrid that tiles along primary axis, then perpendicular. Extract FindPlacedEdge, BuildRemainingStrip, BuildRotationSet, FindBestFill helpers. Use array-based DirectionalDistance to eliminate allocations in FindCopyDistance and FindPatternCopyDistance. Simplify FindSinglePartPatternCopyDistance to delegate to FindCopyDistance. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/FillLinear.cs | 209 ++++++++++++++++------------------ 1 file changed, 98 insertions(+), 111 deletions(-) diff --git a/OpenNest.Engine/FillLinear.cs b/OpenNest.Engine/FillLinear.cs index 584f5fe..deb2deb 100644 --- a/OpenNest.Engine/FillLinear.cs +++ b/OpenNest.Engine/FillLinear.cs @@ -77,17 +77,16 @@ namespace OpenNest { var bboxDim = GetDimension(partA.BoundingBox, direction); var pushDir = GetPushDirection(direction); - var opposite = Helper.OppositeDirection(pushDir); - var locationB = partA.Location + MakeOffset(direction, bboxDim); + var locationBOffset = MakeOffset(direction, bboxDim); - var movingLines = boundary.GetLines(locationB, pushDir); - var stationaryLines = boundary.GetLines(partA.Location, opposite); - var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir); + // Use the most efficient array-based overload to avoid all allocations. + var slideDistance = Helper.DirectionalDistance( + boundary.GetEdges(pushDir), partA.Location + locationBOffset, + boundary.GetEdges(Helper.OppositeDirection(pushDir)), partA.Location, + pushDir); - var copyDist = ComputeCopyDistance(bboxDim, slideDistance); - //System.Diagnostics.Debug.WriteLine($"[FindCopyDistance] dir={direction} bboxDim={bboxDim:F4} slide={slideDistance:F4} copyDist={copyDist:F4} spacing={PartSpacing:F4} locA={partA.Location} locB={locationB} movingEdges={movingLines.Count} stationaryEdges={stationaryLines.Count}"); - return copyDist; + return ComputeCopyDistance(bboxDim, slideDistance); } /// @@ -107,7 +106,6 @@ namespace OpenNest // Compute a starting offset large enough that every part-pair in // patternB has its offset geometry beyond patternA's offset geometry. - // max(aUpper_i - bLower_j) = max(aUpper) - min(bLower). var maxUpper = double.MinValue; var minLower = double.MaxValue; @@ -126,22 +124,28 @@ namespace OpenNest var offset = MakeOffset(direction, startOffset); - // Pre-compute stationary lines for patternA parts. - var stationaryCache = new List[patternA.Parts.Count]; + // Pre-cache edge arrays. + var movingEdges = new (Vector start, Vector end)[patternA.Parts.Count][]; + var stationaryEdges = new (Vector start, Vector end)[patternA.Parts.Count][]; for (var i = 0; i < patternA.Parts.Count; i++) - stationaryCache[i] = boundaries[i].GetLines(patternA.Parts[i].Location, opposite); + { + movingEdges[i] = boundaries[i].GetEdges(pushDir); + stationaryEdges[i] = boundaries[i].GetEdges(opposite); + } var maxCopyDistance = 0.0; for (var j = 0; j < patternA.Parts.Count; j++) { var locationB = patternA.Parts[j].Location + offset; - var movingLines = boundaries[j].GetLines(locationB, pushDir); for (var i = 0; i < patternA.Parts.Count; i++) { - var slideDistance = Helper.DirectionalDistance(movingLines, stationaryCache[i], pushDir); + var slideDistance = Helper.DirectionalDistance( + movingEdges[j], locationB, + stationaryEdges[i], patternA.Parts[i].Location, + pushDir); if (slideDistance >= double.MaxValue || slideDistance < 0) continue; @@ -153,9 +157,7 @@ namespace OpenNest } } - // Fallback: if no pair interacted (shouldn't happen for real parts), - // use the simple bounding-box + spacing distance. - if (maxCopyDistance <= 0) + if (maxCopyDistance < Tolerance.Epsilon) return bboxDim + PartSpacing; return maxCopyDistance; @@ -166,19 +168,8 @@ namespace OpenNest /// private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary) { - var bboxDim = GetDimension(patternA.BoundingBox, direction); - var pushDir = GetPushDirection(direction); - var opposite = Helper.OppositeDirection(pushDir); - - var offset = MakeOffset(direction, bboxDim); - - var movingLines = GetOffsetPatternLines(patternA, offset, boundary, pushDir); - var stationaryLines = GetPatternLines(patternA, boundary, opposite); - var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir); - - var copyDist = ComputeCopyDistance(bboxDim, slideDistance); - //System.Diagnostics.Debug.WriteLine($"[FindSinglePartPatternCopyDist] dir={direction} bboxDim={bboxDim:F4} slide={slideDistance:F4} copyDist={copyDist:F4} spacing={PartSpacing:F4} patternParts={patternA.Parts.Count} movingEdges={movingLines.Count} stationaryEdges={stationaryLines.Count}"); - return copyDist; + var template = patternA.Parts[0]; + return FindCopyDistance(template, direction, boundary); } /// @@ -330,54 +321,46 @@ namespace OpenNest } /// - /// Recursively fills the work area. At depth 0, tiles the pattern along the - /// primary axis, then recurses perpendicular. At depth 1, tiles and returns. + /// Fills the work area by tiling the pattern along the primary axis to form + /// a row, then tiling that row along the perpendicular axis to form a grid. /// After the grid is formed, fills the remaining strip with individual parts. /// - private List FillRecursive(Pattern pattern, NestDirection direction, int depth) + private List FillGrid(Pattern pattern, NestDirection direction) { + var perpAxis = PerpendicularAxis(direction); var boundaries = CreateBoundaries(pattern); - var result = new List(pattern.Parts); - result.AddRange(TilePattern(pattern, direction, boundaries)); - if (depth == 0 && result.Count > pattern.Parts.Count) + // Step 1: Tile along primary axis + var row = new List(pattern.Parts); + row.AddRange(TilePattern(pattern, direction, boundaries)); + + // If primary tiling didn't produce copies, just tile along perpendicular + if (row.Count <= pattern.Parts.Count) { - var rowPattern = new Pattern(); - rowPattern.Parts.AddRange(result); - rowPattern.UpdateBounds(); - var perpAxis = PerpendicularAxis(direction); - var gridResult = FillRecursive(rowPattern, perpAxis, depth + 1); - - //System.Diagnostics.Debug.WriteLine($"[FillRecursive] Grid: {gridResult.Count} parts, rowSize={rowPattern.Parts.Count}, dir={direction}"); - - // Fill the remaining strip (after the last full row/column) - // with individual parts from the seed pattern. - var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction); - - //System.Diagnostics.Debug.WriteLine($"[FillRecursive] Remainder: {remaining.Count} parts"); - - if (remaining.Count > 0) - gridResult.AddRange(remaining); - - // Try one fewer row/column — the larger remainder strip may - // fit more parts than the extra row contained. - var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction); - - //System.Diagnostics.Debug.WriteLine($"[FillRecursive] TryFewerRows: {fewerResult?.Count ?? -1} vs grid+remainder={gridResult.Count}"); - - if (fewerResult != null && fewerResult.Count > gridResult.Count) - return fewerResult; - - return gridResult; + row.AddRange(TilePattern(pattern, perpAxis, boundaries)); + return row; } - if (depth == 0) - { - // Single part didn't tile along primary — still try perpendicular. - return FillRecursive(pattern, PerpendicularAxis(direction), depth + 1); - } + // Step 2: Build row pattern and tile along perpendicular axis + var rowPattern = new Pattern(); + rowPattern.Parts.AddRange(row); + rowPattern.UpdateBounds(); - return result; + var rowBoundaries = CreateBoundaries(rowPattern); + var gridResult = new List(rowPattern.Parts); + gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries)); + + // Step 3: Fill remaining strip + var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction); + if (remaining.Count > 0) + gridResult.AddRange(remaining); + + // Step 4: Try fewer rows optimization + var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction); + if (fewerResult != null && fewerResult.Count > gridResult.Count) + return fewerResult; + + return gridResult; } /// @@ -390,37 +373,16 @@ namespace OpenNest { var rowPartCount = rowPattern.Parts.Count; - //System.Diagnostics.Debug.WriteLine($"[TryFewerRows] fullResult={fullResult.Count}, rowPartCount={rowPartCount}, tiledAxis={tiledAxis}"); - - // Need at least 2 rows for this to make sense (remove 1, keep 1+). if (fullResult.Count < rowPartCount * 2) - { - //System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Skipped: too few parts for 2 rows"); return null; - } - // Remove the last row's worth of parts. var fewerParts = new List(fullResult.Count - rowPartCount); for (var i = 0; i < fullResult.Count - rowPartCount; i++) fewerParts.Add(fullResult[i]); - // Find the top/right edge of the kept parts for logging. - var edge = double.MinValue; - foreach (var part in fewerParts) - { - var e = tiledAxis == NestDirection.Vertical - ? part.BoundingBox.Top - : part.BoundingBox.Right; - if (e > edge) edge = e; - } - - //System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Kept {fewerParts.Count} parts, edge={edge:F2}, workArea={WorkArea}"); - var remaining = FillRemainingStrip(fewerParts, seedPattern, tiledAxis, primaryAxis); - //System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Remainder fill: {remaining.Count} parts (need > {rowPartCount} to improve)"); - if (remaining.Count <= rowPartCount) return null; @@ -438,7 +400,18 @@ namespace OpenNest List placedParts, Pattern seedPattern, NestDirection tiledAxis, NestDirection primaryAxis) { - // Find the furthest edge of placed parts along the tiled axis. + var placedEdge = FindPlacedEdge(placedParts, tiledAxis); + var remainingStrip = BuildRemainingStrip(placedEdge, tiledAxis); + + if (remainingStrip == null) + return new List(); + + var rotations = BuildRotationSet(seedPattern); + return FindBestFill(rotations, remainingStrip); + } + + private static double FindPlacedEdge(List placedParts, NestDirection tiledAxis) + { var placedEdge = double.MinValue; foreach (var part in placedParts) @@ -451,18 +424,20 @@ namespace OpenNest placedEdge = edge; } - // Build the remaining strip with a spacing gap from the last tiled row. - Box remainingStrip; + return placedEdge; + } + private Box BuildRemainingStrip(double placedEdge, NestDirection tiledAxis) + { if (tiledAxis == NestDirection.Vertical) { var bottom = placedEdge + PartSpacing; var height = WorkArea.Top - bottom; if (height <= Tolerance.Epsilon) - return new List(); + return null; - remainingStrip = new Box(WorkArea.X, bottom, WorkArea.Width, height); + return new Box(WorkArea.X, bottom, WorkArea.Width, height); } else { @@ -470,18 +445,20 @@ namespace OpenNest var width = WorkArea.Right - left; if (width <= Tolerance.Epsilon) - return new List(); + return null; - remainingStrip = new Box(left, WorkArea.Y, width, WorkArea.Length); + return new Box(left, WorkArea.Y, width, WorkArea.Length); } + } - // Build rotation set: always try cardinal orientations (0° and 90°), - // plus any unique rotations from the seed pattern. - var filler = new FillLinear(remainingStrip, PartSpacing); - List best = null; + /// + /// Builds a set of (drawing, rotation) candidates: cardinal orientations + /// (0° and 90°) for each unique drawing, plus any seed pattern rotations + /// not already covered. + /// + private static List<(Drawing drawing, double rotation)> BuildRotationSet(Pattern seedPattern) + { var rotations = new List<(Drawing drawing, double rotation)>(); - - // Cardinal rotations for each unique drawing. var drawings = new List(); foreach (var seedPart in seedPattern.Parts) @@ -507,7 +484,6 @@ namespace OpenNest rotations.Add((drawing, Angle.HalfPI)); } - // Add seed pattern rotations that aren't already covered. foreach (var seedPart in seedPattern.Parts) { var skip = false; @@ -525,20 +501,31 @@ namespace OpenNest rotations.Add((seedPart.BaseDrawing, seedPart.Rotation)); } + return rotations; + } + + /// + /// Tries all rotation candidates in both directions in parallel, returns the + /// fill with the most parts. + /// + private List FindBestFill(List<(Drawing drawing, double rotation)> rotations, Box strip) + { var bag = new System.Collections.Concurrent.ConcurrentBag>(); - System.Threading.Tasks.Parallel.ForEach(rotations, entry => + foreach (var entry in rotations) { - var localFiller = new FillLinear(remainingStrip, PartSpacing); - var h = localFiller.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal); - var v = localFiller.Fill(entry.drawing, entry.rotation, NestDirection.Vertical); + var filler = new FillLinear(strip, PartSpacing); + var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal); + var v = filler.Fill(entry.drawing, entry.rotation, NestDirection.Vertical); if (h != null && h.Count > 0) bag.Add(h); if (v != null && v.Count > 0) bag.Add(v); - }); + } + + List best = null; foreach (var candidate in bag) { @@ -604,7 +591,7 @@ namespace OpenNest basePattern.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon) return new List(); - return FillRecursive(basePattern, primaryAxis, depth: 0); + return FillGrid(basePattern, primaryAxis); } /// @@ -618,7 +605,7 @@ namespace OpenNest if (seed.Parts.Count == 0) return new List(); - return FillRecursive(seed, primaryAxis, depth: 0); + return FillGrid(seed, primaryAxis); } } } From ae010212ac6f75620600adf50433e7453e28943a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 22:51:57 -0400 Subject: [PATCH 040/116] refactor(engine): extract AutoNester and reorganize NestEngine Move NFP-based AutoNest logic (polygon extraction, rotation computation, simulated annealing) into dedicated AutoNester class. Consolidate duplicate FillWithPairs overloads, extract BuildCandidateAngles and BuildProgressSummary, reorganize NestEngine into logical sections. Update callers in Console, MCP tools, and MainForm to use AutoNester.Nest. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/Program.cs | 2 +- OpenNest.Engine/AutoNester.cs | 223 +++++++ OpenNest.Engine/NestEngine.cs | 969 ++++++++++------------------- OpenNest.Mcp/Tools/NestingTools.cs | 2 +- OpenNest/Forms/MainForm.cs | 10 +- 5 files changed, 553 insertions(+), 653 deletions(-) create mode 100644 OpenNest.Engine/AutoNester.cs diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index c66deda..53bb30f 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -327,7 +327,7 @@ static class NestConsole Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); - var nestParts = NestEngine.AutoNest(nestItems, plate); + var nestParts = AutoNester.Nest(nestItems, plate); plate.Parts.AddRange(nestParts); success = nestParts.Count > 0; } diff --git a/OpenNest.Engine/AutoNester.cs b/OpenNest.Engine/AutoNester.cs new file mode 100644 index 0000000..0140544 --- /dev/null +++ b/OpenNest.Engine/AutoNester.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + /// + /// Mixed-part geometry-aware nesting using NFP-based collision avoidance + /// and simulated annealing optimization. + /// + public static class AutoNester + { + public static List Nest(List items, Plate plate, + CancellationToken cancellation = default) + { + var workArea = plate.WorkArea(); + var halfSpacing = plate.PartSpacing / 2.0; + var nfpCache = new NfpCache(); + var candidateRotations = new Dictionary>(); + + // Extract perimeter polygons for each unique drawing. + foreach (var item in items) + { + var drawing = item.Drawing; + + if (candidateRotations.ContainsKey(drawing.Id)) + continue; + + var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing); + + if (perimeterPolygon == null) + { + Debug.WriteLine($"[AutoNest] Skipping drawing '{drawing.Name}': no valid perimeter"); + continue; + } + + // Compute candidate rotations for this drawing. + var rotations = ComputeCandidateRotations(item, perimeterPolygon, workArea); + candidateRotations[drawing.Id] = rotations; + + // Register polygons at each candidate rotation. + foreach (var rotation in rotations) + { + var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation); + nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon); + } + } + + if (candidateRotations.Count == 0) + return new List(); + + // Pre-compute all NFPs. + nfpCache.PreComputeAll(); + + Debug.WriteLine($"[AutoNest] NFP cache: {nfpCache.Count} entries for {candidateRotations.Count} drawings"); + + // Run simulated annealing optimizer. + var optimizer = new SimulatedAnnealing(); + var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation); + + if (result.Sequence == null || result.Sequence.Count == 0) + return new List(); + + // Final BLF placement with the best solution. + var blf = new BottomLeftFill(workArea, nfpCache); + var placedParts = blf.Fill(result.Sequence); + var parts = BottomLeftFill.ToNestParts(placedParts); + + Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations"); + + return parts; + } + + /// + /// Extracts the perimeter polygon from a drawing, inflated by half-spacing. + /// + private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing) + { + var entities = ConvertProgram.ToGeometry(drawing.Program) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + + if (entities.Count == 0) + return null; + + var definedShape = new ShapeProfile(entities); + var perimeter = definedShape.Perimeter; + + if (perimeter == null) + return null; + + // Inflate by half-spacing if spacing is non-zero. + Shape inflated; + + if (halfSpacing > 0) + { + var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right); + inflated = offsetEntity as Shape ?? perimeter; + } + else + { + inflated = perimeter; + } + + // Convert to polygon with circumscribed arcs for tight nesting. + var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true); + + if (polygon.Vertices.Count < 3) + return null; + + // Normalize: move reference point to origin. + polygon.UpdateBounds(); + var bb = polygon.BoundingBox; + polygon.Offset(-bb.Left, -bb.Bottom); + + return polygon; + } + + /// + /// Computes candidate rotation angles for a drawing. + /// + private static List ComputeCandidateRotations(NestItem item, + Polygon perimeterPolygon, Box workArea) + { + var rotations = new List { 0 }; + + // Add hull-edge angles from the polygon itself. + var hullAngles = ComputeHullEdgeAngles(perimeterPolygon); + + foreach (var angle in hullAngles) + { + if (!rotations.Any(r => r.IsEqualTo(angle))) + rotations.Add(angle); + } + + // Add 90-degree rotation. + if (!rotations.Any(r => r.IsEqualTo(Angle.HalfPI))) + rotations.Add(Angle.HalfPI); + + // For narrow work areas, add sweep angles. + var partBounds = perimeterPolygon.BoundingBox; + var partLongest = System.Math.Max(partBounds.Width, partBounds.Length); + var workShort = System.Math.Min(workArea.Width, workArea.Length); + + if (workShort < partLongest) + { + var step = Angle.ToRadians(5); + + for (var a = 0.0; a < System.Math.PI; a += step) + { + if (!rotations.Any(r => r.IsEqualTo(a))) + rotations.Add(a); + } + } + + return rotations; + } + + /// + /// Computes convex hull edge angles from a polygon for candidate rotations. + /// + private static List ComputeHullEdgeAngles(Polygon polygon) + { + var angles = new List(); + + if (polygon.Vertices.Count < 3) + return angles; + + var hull = ConvexHull.Compute(polygon.Vertices); + var verts = hull.Vertices; + var n = hull.IsClosed() ? verts.Count - 1 : verts.Count; + + for (var i = 0; i < n; i++) + { + var next = (i + 1) % n; + var dx = verts[next].X - verts[i].X; + var dy = verts[next].Y - verts[i].Y; + + if (dx * dx + dy * dy < Tolerance.Epsilon) + continue; + + var angle = -System.Math.Atan2(dy, dx); + + if (!angles.Any(a => a.IsEqualTo(angle))) + angles.Add(angle); + } + + return angles; + } + + /// + /// Creates a rotated copy of a polygon around the origin. + /// + private static Polygon RotatePolygon(Polygon polygon, double angle) + { + if (angle.IsEqualTo(0)) + return polygon; + + var result = new Polygon(); + var cos = System.Math.Cos(angle); + var sin = System.Math.Sin(angle); + + foreach (var v in polygon.Vertices) + { + result.Vertices.Add(new Vector( + v.X * cos - v.Y * sin, + v.X * sin + v.Y * cos)); + } + + // Re-normalize to origin. + result.UpdateBounds(); + var bb = result.BoundingBox; + result.Offset(-bb.Left, -bb.Bottom); + + return result; + } + } +} diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 1eaf05c..271629a 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; -using OpenNest.Converters; using OpenNest.Engine.BestFit; using OpenNest.Engine.ML; using OpenNest.Geometry; @@ -33,16 +32,13 @@ namespace OpenNest public List AngleResults { get; } = new(); + // --- Public Fill API --- + public bool Fill(NestItem item) { return Fill(item, Plate.WorkArea()); } - public bool Fill(List groupParts) - { - return Fill(groupParts, Plate.WorkArea()); - } - public bool Fill(NestItem item, Box workArea) { var parts = Fill(item, workArea, null, CancellationToken.None); @@ -75,7 +71,7 @@ namespace OpenNest best = improved; WinnerPhase = NestPhase.Remainder; PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, remainderSw.ElapsedMilliseconds)); - ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea); + ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary()); } if (best == null || best.Count == 0) @@ -87,187 +83,124 @@ namespace OpenNest return best; } - private List FindBestFill(NestItem item, Box workArea) + public bool Fill(List groupParts) { - var bestRotation = RotationAnalysis.FindBestRotation(item); - - var engine = new FillLinear(workArea, Plate.PartSpacing); - - // Build candidate rotation angles — always try the best rotation and +90°. - var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; - - // When the work area is narrow relative to the part, sweep rotation - // angles so we can find one that fits the part into the tight strip. - var testPart = new Part(item.Drawing); - - if (!bestRotation.IsEqualTo(0)) - testPart.Rotate(bestRotation); - - testPart.UpdateBounds(); - - var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length); - var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length); - - if (workAreaShortSide < partLongestSide) - { - // Try every 5° from 0 to 175° to find rotations that fit. - var step = Angle.ToRadians(5); - - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!angles.Any(existing => existing.IsEqualTo(a))) - angles.Add(a); - } - } - - if (ForceFullAngleSweep) - { - var step = Angle.ToRadians(5); - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!angles.Any(existing => existing.IsEqualTo(a))) - angles.Add(a); - } - } - - // When the work area triggers a full sweep (and we're not forcing it for training), - // try ML angle prediction to reduce the sweep. - if (!ForceFullAngleSweep && angles.Count > 2) - { - var features = FeatureExtractor.Extract(item.Drawing); - if (features != null) - { - var predicted = AnglePredictor.PredictAngles( - features, workArea.Width, workArea.Length); - - if (predicted != null) - { - // Use predicted angles, but always keep bestRotation and bestRotation + 90. - var mlAngles = new List(predicted); - - if (!mlAngles.Any(a => a.IsEqualTo(bestRotation))) - mlAngles.Add(bestRotation); - if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI))) - mlAngles.Add(bestRotation + Angle.HalfPI); - - Debug.WriteLine($"[FindBestFill] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); - angles = mlAngles; - } - } - } - - // Try pair-based approach first. - var pairResult = FillWithPairs(item, workArea); - var best = pairResult; - var bestScore = FillScore.Compute(best, workArea); - - Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts"); - - // Try linear phase. - var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); - - System.Threading.Tasks.Parallel.ForEach(angles, angle => - { - var localEngine = new FillLinear(workArea, Plate.PartSpacing); - var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal); - var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical); - - if (h != null && h.Count > 0) - linearBag.Add((FillScore.Compute(h, workArea), h)); - - if (v != null && v.Count > 0) - linearBag.Add((FillScore.Compute(v, workArea), v)); - }); - - foreach (var (score, parts) in linearBag) - { - if (score > bestScore) - { - best = parts; - bestScore = score; - } - } - - Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); - - // Try rectangle best-fit (mixes orientations to fill remnant strips). - var rectResult = FillRectangleBestFit(item, workArea); - var rectScore = rectResult != null ? FillScore.Compute(rectResult, workArea) : default; - - Debug.WriteLine($"[FindBestFill] RectBestFit: {rectScore.Count} parts"); - - if (rectScore > bestScore) - best = rectResult; - - return best; + return Fill(groupParts, Plate.WorkArea()); } - private List FindBestFill(NestItem item, Box workArea, + public bool Fill(List groupParts, Box workArea) + { + var parts = Fill(groupParts, workArea, null, CancellationToken.None); + + if (parts == null || parts.Count == 0) + return false; + + Plate.Parts.AddRange(parts); + return true; + } + + public List Fill(List groupParts, Box workArea, IProgress progress, CancellationToken token) + { + if (groupParts == null || groupParts.Count == 0) + return new List(); + + PhaseResults.Clear(); + var engine = new FillLinear(workArea, Plate.PartSpacing); + var angles = RotationAnalysis.FindHullEdgeAngles(groupParts); + var best = FillPattern(engine, groupParts, angles, workArea); + PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0)); + + Debug.WriteLine($"[Fill(groupParts,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}"); + + ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary()); + + if (groupParts.Count == 1) + { + try + { + token.ThrowIfCancellationRequested(); + + var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing }; + var rectResult = FillRectangleBestFit(nestItem, workArea); + PhaseResults.Add(new PhaseResult(NestPhase.RectBestFit, rectResult?.Count ?? 0, 0)); + + Debug.WriteLine($"[Fill(groupParts,Box)] RectBestFit: {rectResult?.Count ?? 0} parts"); + + if (IsBetterFill(rectResult, best, workArea)) + { + best = rectResult; + ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea, BuildProgressSummary()); + } + + token.ThrowIfCancellationRequested(); + + var pairResult = FillWithPairs(nestItem, workArea, token, progress); + PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, 0)); + + Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}"); + + if (IsBetterFill(pairResult, best, workArea)) + { + best = pairResult; + ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, BuildProgressSummary()); + } + + // Try improving by filling the remainder strip separately. + var improved = TryRemainderImprovement(nestItem, workArea, best); + + if (IsBetterFill(improved, best, workArea)) + { + Debug.WriteLine($"[Fill(groupParts,Box)] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); + best = improved; + PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, 0)); + ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary()); + } + } + catch (OperationCanceledException) + { + Debug.WriteLine("[Fill(groupParts,Box)] Cancelled, returning current best"); + } + } + + return best ?? new List(); + } + + // --- Pack API --- + + public bool Pack(List items) + { + var workArea = Plate.WorkArea(); + return PackArea(workArea, items); + } + + public bool PackArea(Box box, List items) + { + var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); + var bin = BinConverter.CreateBin(box, Plate.PartSpacing); + + var engine = new PackBottomLeft(bin); + engine.Pack(binItems); + + var parts = BinConverter.ToParts(bin, items); + Plate.Parts.AddRange(parts); + + return parts.Count > 0; + } + + // --- FindBestFill: core orchestration --- + + private List FindBestFill(NestItem item, Box workArea, + IProgress progress = null, CancellationToken token = default) { List best = null; try { var bestRotation = RotationAnalysis.FindBestRotation(item); - var engine = new FillLinear(workArea, Plate.PartSpacing); - var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; + var angles = BuildCandidateAngles(item, bestRotation, workArea); - var testPart = new Part(item.Drawing); - if (!bestRotation.IsEqualTo(0)) - testPart.Rotate(bestRotation); - testPart.UpdateBounds(); - - var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length); - var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length); - - if (workAreaShortSide < partLongestSide) - { - var step = Angle.ToRadians(5); - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!angles.Any(existing => existing.IsEqualTo(a))) - angles.Add(a); - } - } - - if (ForceFullAngleSweep) - { - var step = Angle.ToRadians(5); - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!angles.Any(existing => existing.IsEqualTo(a))) - angles.Add(a); - } - } - - // When the work area triggers a full sweep (and we're not forcing it for training), - // try ML angle prediction to reduce the sweep. - if (!ForceFullAngleSweep && angles.Count > 2) - { - var features = FeatureExtractor.Extract(item.Drawing); - if (features != null) - { - var predicted = AnglePredictor.PredictAngles( - features, workArea.Width, workArea.Length); - - if (predicted != null) - { - // Use predicted angles, but always keep bestRotation and bestRotation + 90. - var mlAngles = new List(predicted); - - if (!mlAngles.Any(a => a.IsEqualTo(bestRotation))) - mlAngles.Add(bestRotation); - if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI))) - mlAngles.Add(bestRotation + Angle.HalfPI); - - Debug.WriteLine($"[FindBestFill] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); - angles = mlAngles; - } - } - } - - // Pairs phase first + // Pairs phase var pairSw = Stopwatch.StartNew(); var pairResult = FillWithPairs(item, workArea, token, progress); pairSw.Stop(); @@ -277,14 +210,13 @@ namespace OpenNest PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, pairSw.ElapsedMilliseconds)); Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts"); - ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea); + ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, BuildProgressSummary()); token.ThrowIfCancellationRequested(); // Linear phase var linearSw = Stopwatch.StartNew(); var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); var angleBag = new System.Collections.Concurrent.ConcurrentBag(); - var anglesCompleted = 0; System.Threading.Tasks.Parallel.ForEach(angles, @@ -335,7 +267,7 @@ namespace OpenNest Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); - ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea); + ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary()); token.ThrowIfCancellationRequested(); // RectBestFit phase @@ -350,7 +282,7 @@ namespace OpenNest { best = rectResult; WinnerPhase = NestPhase.RectBestFit; - ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); + ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea, BuildProgressSummary()); } } catch (OperationCanceledException) @@ -361,98 +293,62 @@ namespace OpenNest return best ?? new List(); } - public bool Fill(List groupParts, Box workArea) + // --- Angle building --- + + private List BuildCandidateAngles(NestItem item, double bestRotation, Box workArea) { - var parts = Fill(groupParts, workArea, null, CancellationToken.None); + var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; - if (parts == null || parts.Count == 0) - return false; + // When the work area is narrow relative to the part, sweep rotation + // angles so we can find one that fits the part into the tight strip. + var testPart = new Part(item.Drawing); + if (!bestRotation.IsEqualTo(0)) + testPart.Rotate(bestRotation); + testPart.UpdateBounds(); - Plate.Parts.AddRange(parts); - return true; - } + var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length); + var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length); + var needsSweep = workAreaShortSide < partLongestSide || ForceFullAngleSweep; - public List Fill(List groupParts, Box workArea, - IProgress progress, CancellationToken token) - { - if (groupParts == null || groupParts.Count == 0) - return new List(); - - var engine = new FillLinear(workArea, Plate.PartSpacing); - var angles = RotationAnalysis.FindHullEdgeAngles(groupParts); - var best = FillPattern(engine, groupParts, angles, workArea); - - Debug.WriteLine($"[Fill(groupParts,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}"); - - ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea); - - if (groupParts.Count == 1) + if (needsSweep) { - try + var step = Angle.ToRadians(5); + for (var a = 0.0; a < System.Math.PI; a += step) { - token.ThrowIfCancellationRequested(); - - 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, workArea)) - { - best = rectResult; - ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); - } - - token.ThrowIfCancellationRequested(); - - var pairResult = FillWithPairs(nestItem, workArea, token); - - Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}"); - - if (IsBetterFill(pairResult, best, workArea)) - { - best = pairResult; - ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea); - } - - // Try improving by filling the remainder strip separately. - var improved = TryRemainderImprovement(nestItem, workArea, best); - - if (IsBetterFill(improved, best, workArea)) - { - Debug.WriteLine($"[Fill(groupParts,Box)] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); - best = improved; - ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea); - } - } - catch (OperationCanceledException) - { - Debug.WriteLine("[Fill(groupParts,Box)] Cancelled, returning current best"); + if (!angles.Any(existing => existing.IsEqualTo(a))) + angles.Add(a); } } - return best ?? new List(); + // When the work area triggers a full sweep (and we're not forcing it for training), + // try ML angle prediction to reduce the sweep. + if (!ForceFullAngleSweep && angles.Count > 2) + { + var features = FeatureExtractor.Extract(item.Drawing); + if (features != null) + { + var predicted = AnglePredictor.PredictAngles( + features, workArea.Width, workArea.Length); + + if (predicted != null) + { + var mlAngles = new List(predicted); + + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation))) + mlAngles.Add(bestRotation); + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI))) + mlAngles.Add(bestRotation + Angle.HalfPI); + + Debug.WriteLine($"[BuildCandidateAngles] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); + angles = mlAngles; + } + } + } + + return angles; } - public bool Pack(List items) - { - var workArea = Plate.WorkArea(); - return PackArea(workArea, items); - } - - public bool PackArea(Box box, List items) - { - var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); - var bin = BinConverter.CreateBin(box, Plate.PartSpacing); - - var engine = new PackBottomLeft(bin); - engine.Pack(binItems); - - var parts = BinConverter.ToParts(bin, items); - Plate.Parts.AddRange(parts); - - return parts.Count > 0; - } + // --- Fill strategies --- private List FillRectangleBestFit(NestItem item, Box workArea) { @@ -465,46 +361,8 @@ namespace OpenNest return BinConverter.ToParts(bin, new List { item }); } - private List FillWithPairs(NestItem item, Box workArea) - { - var bestFits = BestFitCache.GetOrCompute( - item.Drawing, Plate.Size.Width, Plate.Size.Length, - Plate.PartSpacing); - - var candidates = SelectPairCandidates(bestFits, workArea); - Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); - - var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); - - System.Threading.Tasks.Parallel.For(0, candidates.Count, i => - { - var result = candidates[i]; - var pairParts = result.BuildParts(item.Drawing); - var angles = result.HullAngles; - var engine = new FillLinear(workArea, Plate.PartSpacing); - var filled = FillPattern(engine, pairParts, angles, workArea); - - if (filled != null && filled.Count > 0) - resultBag.Add((FillScore.Compute(filled, workArea), filled)); - }); - - List best = null; - var bestScore = default(FillScore); - - foreach (var (score, parts) in resultBag) - { - if (best == null || score > bestScore) - { - best = parts; - bestScore = score; - } - } - - Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}"); - return best ?? new List(); - } - - private List FillWithPairs(NestItem item, Box workArea, CancellationToken token, IProgress progress = null) + private List FillWithPairs(NestItem item, Box workArea, + CancellationToken token = default, IProgress progress = null) { var bestFits = BestFitCache.GetOrCompute( item.Drawing, Plate.Size.Width, Plate.Size.Length, @@ -567,8 +425,7 @@ namespace OpenNest /// /// Selects pair candidates to try for the given work area. Always includes /// the top 50 by area. For narrow work areas, also includes all pairs whose - /// shortest side fits the strip width — these are candidates that can only - /// be evaluated by actually tiling them into the narrow space. + /// shortest side fits the strip width. /// private List SelectPairCandidates(List bestFits, Box workArea) { @@ -599,104 +456,82 @@ namespace OpenNest return top; } - private bool HasOverlaps(List parts, double spacing) + // --- Pattern helpers --- + + private Pattern BuildRotatedPattern(List groupParts, double angle) { - if (parts == null || parts.Count <= 1) - return false; + var pattern = new Pattern(); + var center = ((IEnumerable)groupParts).GetBoundingBox().Center; - for (var i = 0; i < parts.Count; i++) + foreach (var part in groupParts) { - var box1 = parts[i].BoundingBox; + var clone = (Part)part.Clone(); + clone.UpdateBounds(); - for (var j = i + 1; j < parts.Count; j++) + if (!angle.IsEqualTo(0)) + clone.Rotate(angle, center); + + pattern.Parts.Add(clone); + } + + pattern.UpdateBounds(); + return pattern; + } + + private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) + { + List best = null; + var bestScore = default(FillScore); + + foreach (var angle in angles) + { + var pattern = BuildRotatedPattern(groupParts, angle); + + if (pattern.Parts.Count == 0) + continue; + + var h = engine.Fill(pattern, NestDirection.Horizontal); + var scoreH = h != null && h.Count > 0 ? FillScore.Compute(h, workArea) : default; + + if (scoreH.Count > 0 && (best == null || scoreH > bestScore)) { - var box2 = parts[j].BoundingBox; + best = h; + bestScore = scoreH; + } - // Fast bounding box rejection — if boxes don't overlap, - // the parts can't intersect. Eliminates nearly all pairs - // in grid layouts. - if (box1.Right < box2.Left || box2.Right < box1.Left || - box1.Top < box2.Bottom || box2.Top < box1.Bottom) - continue; + var v = engine.Fill(pattern, NestDirection.Vertical); + var scoreV = v != null && v.Count > 0 ? FillScore.Compute(v, workArea) : default; - List pts; - - if (parts[i].Intersects(parts[j], out pts)) - { - var b1 = parts[i].BoundingBox; - var b2 = parts[j].BoundingBox; - Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" + - $" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" + - $" intersections={pts?.Count ?? 0}"); - return true; - } + if (scoreV.Count > 0 && (best == null || scoreV > bestScore)) + { + best = v; + bestScore = scoreV; } } - return false; + return best; } - private bool IsBetterFill(List candidate, List current, Box workArea) + // --- Remainder improvement --- + + private List TryRemainderImprovement(NestItem item, Box workArea, List currentBest) { - if (candidate == null || candidate.Count == 0) - return false; + if (currentBest == null || currentBest.Count < 3) + return null; - if (current == null || current.Count == 0) - return true; + List best = null; - return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea); - } + var hResult = TryStripRefill(item, workArea, currentBest, horizontal: true); - private bool IsBetterValidFill(List candidate, List current, Box workArea) - { - if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing)) - { - Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})"); - return false; - } + if (IsBetterFill(hResult, best, workArea)) + best = hResult; - return IsBetterFill(candidate, current, workArea); - } + var vResult = TryStripRefill(item, workArea, currentBest, horizontal: false); - /// - /// Groups parts into positional clusters along the given axis. - /// Parts whose center positions are separated by more than half - /// the part dimension start a new cluster. - /// - private static List> ClusterParts(List parts, bool horizontal) - { - var sorted = horizontal - ? parts.OrderBy(p => p.BoundingBox.Center.X).ToList() - : parts.OrderBy(p => p.BoundingBox.Center.Y).ToList(); + if (IsBetterFill(vResult, best, workArea)) + best = vResult; - var refDim = horizontal - ? sorted.Max(p => p.BoundingBox.Width) - : sorted.Max(p => p.BoundingBox.Length); - var gapThreshold = refDim * 0.5; - - var clusters = new List>(); - var current = new List { sorted[0] }; - - for (var i = 1; i < sorted.Count; i++) - { - var prevCenter = horizontal - ? sorted[i - 1].BoundingBox.Center.X - : sorted[i - 1].BoundingBox.Center.Y; - var currCenter = horizontal - ? sorted[i].BoundingBox.Center.X - : sorted[i].BoundingBox.Center.Y; - - if (currCenter - prevCenter > gapThreshold) - { - clusters.Add(current); - current = new List(); - } - - current.Add(sorted[i]); - } - - clusters.Add(current); - return clusters; + return best; } private List TryStripRefill(NestItem item, Box workArea, List parts, bool horizontal) @@ -771,86 +606,115 @@ namespace OpenNest return combined; } - private List TryRemainderImprovement(NestItem item, Box workArea, List currentBest) + /// + /// Groups parts into positional clusters along the given axis. + /// Parts whose center positions are separated by more than half + /// the part dimension start a new cluster. + /// + private static List> ClusterParts(List parts, bool horizontal) { - if (currentBest == null || currentBest.Count < 3) - return null; + var sorted = horizontal + ? parts.OrderBy(p => p.BoundingBox.Center.X).ToList() + : parts.OrderBy(p => p.BoundingBox.Center.Y).ToList(); - List best = null; + var refDim = horizontal + ? sorted.Max(p => p.BoundingBox.Width) + : sorted.Max(p => p.BoundingBox.Length); + var gapThreshold = refDim * 0.5; - var hResult = TryStripRefill(item, workArea, currentBest, horizontal: true); + var clusters = new List>(); + var current = new List { sorted[0] }; - if (IsBetterFill(hResult, best, workArea)) - best = hResult; - - var vResult = TryStripRefill(item, workArea, currentBest, horizontal: false); - - if (IsBetterFill(vResult, best, workArea)) - best = vResult; - - return best; - } - - private Pattern BuildRotatedPattern(List groupParts, double angle) - { - var pattern = new Pattern(); - var center = ((IEnumerable)groupParts).GetBoundingBox().Center; - - foreach (var part in groupParts) + for (var i = 1; i < sorted.Count; i++) { - var clone = (Part)part.Clone(); - clone.UpdateBounds(); + var prevCenter = horizontal + ? sorted[i - 1].BoundingBox.Center.X + : sorted[i - 1].BoundingBox.Center.Y; + var currCenter = horizontal + ? sorted[i].BoundingBox.Center.X + : sorted[i].BoundingBox.Center.Y; - if (!angle.IsEqualTo(0)) - clone.Rotate(angle, center); - - pattern.Parts.Add(clone); - } - - pattern.UpdateBounds(); - return pattern; - } - - private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) - { - List best = null; - var bestScore = default(FillScore); - - foreach (var angle in angles) - { - var pattern = BuildRotatedPattern(groupParts, angle); - - if (pattern.Parts.Count == 0) - continue; - - var h = engine.Fill(pattern, NestDirection.Horizontal); - var scoreH = h != null && h.Count > 0 ? FillScore.Compute(h, workArea) : default; - - if (scoreH.Count > 0 && (best == null || scoreH > bestScore)) + if (currCenter - prevCenter > gapThreshold) { - best = h; - bestScore = scoreH; + clusters.Add(current); + current = new List(); } - var v = engine.Fill(pattern, NestDirection.Vertical); - var scoreV = v != null && v.Count > 0 ? FillScore.Compute(v, workArea) : default; + current.Add(sorted[i]); + } - if (scoreV.Count > 0 && (best == null || scoreV > bestScore)) + clusters.Add(current); + return clusters; + } + + // --- Scoring / comparison --- + + private bool IsBetterFill(List candidate, List current, Box workArea) + { + if (candidate == null || candidate.Count == 0) + return false; + + if (current == null || current.Count == 0) + return true; + + return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea); + } + + private bool IsBetterValidFill(List candidate, List current, Box workArea) + { + if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing)) + { + Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})"); + return false; + } + + return IsBetterFill(candidate, current, workArea); + } + + private bool HasOverlaps(List parts, double spacing) + { + if (parts == null || parts.Count <= 1) + return false; + + for (var i = 0; i < parts.Count; i++) + { + var box1 = parts[i].BoundingBox; + + for (var j = i + 1; j < parts.Count; j++) { - best = v; - bestScore = scoreV; + var box2 = parts[j].BoundingBox; + + // Fast bounding box rejection. + if (box1.Right < box2.Left || box2.Right < box1.Left || + box1.Top < box2.Bottom || box2.Top < box1.Bottom) + continue; + + List pts; + + if (parts[i].Intersects(parts[j], out pts)) + { + var b1 = parts[i].BoundingBox; + var b2 = parts[j].BoundingBox; + Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" + + $" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" + + $" intersections={pts?.Count ?? 0}"); + return true; + } } } - return best; + return false; } + // --- Progress reporting --- + private static void ReportProgress( IProgress progress, NestPhase phase, int plateNumber, List best, - Box workArea) + Box workArea, + string description) { if (progress == null || best == null || best.Count == 0) return; @@ -873,227 +737,36 @@ namespace OpenNest BestDensity = score.Density, UsableRemnantArea = workArea.Area() - totalPartArea, BestParts = clonedParts, - Description = null + Description = description }); } - /// - /// Mixed-part geometry-aware nesting using NFP-based collision avoidance - /// and simulated annealing optimization. - /// - public List AutoNest(List items, CancellationToken cancellation = default) + private string BuildProgressSummary() { - return AutoNest(items, Plate, cancellation); - } - - /// - /// Mixed-part geometry-aware nesting using NFP-based collision avoidance - /// and simulated annealing optimization. - /// - public static List AutoNest(List items, Plate plate, - CancellationToken cancellation = default) - { - var workArea = plate.WorkArea(); - var halfSpacing = plate.PartSpacing / 2.0; - var nfpCache = new NfpCache(); - var candidateRotations = new Dictionary>(); - - // Extract perimeter polygons for each unique drawing. - foreach (var item in items) - { - var drawing = item.Drawing; - - if (candidateRotations.ContainsKey(drawing.Id)) - continue; - - var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing); - - if (perimeterPolygon == null) - { - Debug.WriteLine($"[AutoNest] Skipping drawing '{drawing.Name}': no valid perimeter"); - continue; - } - - // Compute candidate rotations for this drawing. - var rotations = ComputeCandidateRotations(item, perimeterPolygon, workArea); - candidateRotations[drawing.Id] = rotations; - - // Register polygons at each candidate rotation. - foreach (var rotation in rotations) - { - var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation); - nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon); - } - } - - if (candidateRotations.Count == 0) - return new List(); - - // Pre-compute all NFPs. - nfpCache.PreComputeAll(); - - Debug.WriteLine($"[AutoNest] NFP cache: {nfpCache.Count} entries for {candidateRotations.Count} drawings"); - - // Run simulated annealing optimizer. - var optimizer = new SimulatedAnnealing(); - var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation); - - if (result.Sequence == null || result.Sequence.Count == 0) - return new List(); - - // Final BLF placement with the best solution. - var blf = new BottomLeftFill(workArea, nfpCache); - var placedParts = blf.Fill(result.Sequence); - var parts = BottomLeftFill.ToNestParts(placedParts); - - Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations"); - - return parts; - } - - /// - /// Extracts the perimeter polygon from a drawing, inflated by half-spacing. - /// - private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing) - { - var entities = ConvertProgram.ToGeometry(drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) - .ToList(); - - if (entities.Count == 0) + if (PhaseResults.Count == 0) return null; - var definedShape = new ShapeProfile(entities); - var perimeter = definedShape.Perimeter; + var parts = new List(PhaseResults.Count); - if (perimeter == null) - return null; + foreach (var r in PhaseResults) + parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}"); - // Inflate by half-spacing if spacing is non-zero. - Shape inflated; - - if (halfSpacing > 0) - { - var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right); - inflated = offsetEntity as Shape ?? perimeter; - } - else - { - inflated = perimeter; - } - - // Convert to polygon with circumscribed arcs for tight nesting. - var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true); - - if (polygon.Vertices.Count < 3) - return null; - - // Normalize: move reference point to origin. - polygon.UpdateBounds(); - var bb = polygon.BoundingBox; - polygon.Offset(-bb.Left, -bb.Bottom); - - return polygon; + return string.Join(" | ", parts); } - /// - /// Computes candidate rotation angles for a drawing. - /// - private static List ComputeCandidateRotations(NestItem item, - Polygon perimeterPolygon, Box workArea) + private static string FormatPhaseName(NestPhase phase) { - var rotations = new List { 0 }; - - // Add hull-edge angles from the polygon itself. - var hullAngles = ComputeHullEdgeAngles(perimeterPolygon); - - foreach (var angle in hullAngles) + switch (phase) { - if (!rotations.Any(r => r.IsEqualTo(angle))) - rotations.Add(angle); + case NestPhase.Pairs: return "Pairs"; + case NestPhase.Linear: return "Linear"; + case NestPhase.RectBestFit: return "BestFit"; + case NestPhase.Remainder: return "Remainder"; + default: return phase.ToString(); } - - // Add 90-degree rotation. - if (!rotations.Any(r => r.IsEqualTo(Angle.HalfPI))) - rotations.Add(Angle.HalfPI); - - // For narrow work areas, add sweep angles. - var partBounds = perimeterPolygon.BoundingBox; - var partLongest = System.Math.Max(partBounds.Width, partBounds.Length); - var workShort = System.Math.Min(workArea.Width, workArea.Length); - - if (workShort < partLongest) - { - var step = Angle.ToRadians(5); - - for (var a = 0.0; a < System.Math.PI; a += step) - { - if (!rotations.Any(r => r.IsEqualTo(a))) - rotations.Add(a); - } - } - - return rotations; } - /// - /// Computes convex hull edge angles from a polygon for candidate rotations. - /// - private static List ComputeHullEdgeAngles(Polygon polygon) - { - var angles = new List(); - - if (polygon.Vertices.Count < 3) - return angles; - - var hull = ConvexHull.Compute(polygon.Vertices); - var verts = hull.Vertices; - var n = hull.IsClosed() ? verts.Count - 1 : verts.Count; - - for (var i = 0; i < n; i++) - { - var next = (i + 1) % n; - var dx = verts[next].X - verts[i].X; - var dy = verts[next].Y - verts[i].Y; - - if (dx * dx + dy * dy < Tolerance.Epsilon) - continue; - - var angle = -System.Math.Atan2(dy, dx); - - if (!angles.Any(a => a.IsEqualTo(angle))) - angles.Add(angle); - } - - return angles; - } - - /// - /// Creates a rotated copy of a polygon around the origin. - /// - private static Polygon RotatePolygon(Polygon polygon, double angle) - { - if (angle.IsEqualTo(0)) - return polygon; - - var result = new Polygon(); - var cos = System.Math.Cos(angle); - var sin = System.Math.Sin(angle); - - foreach (var v in polygon.Vertices) - { - result.Vertices.Add(new Vector( - v.X * cos - v.Y * sin, - v.X * sin + v.Y * cos)); - } - - // Re-normalize to origin. - result.UpdateBounds(); - var bb = result.BoundingBox; - result.Offset(-bb.Left, -bb.Bottom); - - return result; - } + // --- Utilities --- private static void InterlockedMax(ref int location, int value) { diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index cdd96ce..99d8aef 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -233,7 +233,7 @@ namespace OpenNest.Mcp.Tools items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); } - var parts = NestEngine.AutoNest(items, plate); + var parts = AutoNester.Nest(items, plate); plate.Parts.AddRange(parts); var sb = new StringBuilder(); diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 6f526e0..0ef8fac 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -762,7 +762,7 @@ namespace OpenNest.Forms activeForm.LoadLastPlate(); var parts = await Task.Run(() => - NestEngine.AutoNest(remaining, plate, token)); + AutoNester.Nest(remaining, plate, token)); if (parts.Count == 0) break; @@ -862,7 +862,9 @@ namespace OpenNest.Forms var progress = new Progress(p => { progressForm.UpdateProgress(p); - activeForm.PlateView.SetTemporaryParts(p.BestParts); + + if (p.BestParts != null) + activeForm.PlateView.SetTemporaryParts(p.BestParts); }); progressForm.Show(this); @@ -922,7 +924,9 @@ namespace OpenNest.Forms var progress = new Progress(p => { progressForm.UpdateProgress(p); - activeForm.PlateView.SetTemporaryParts(p.BestParts); + + if (p.BestParts != null) + activeForm.PlateView.SetTemporaryParts(p.BestParts); }); Action> onComplete = parts => From 3c59da17c26f5e319d2a2a5392d75498c72cb2ba Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 01:14:07 -0400 Subject: [PATCH 041/116] fix(engine): fix pair candidate filtering for narrow plates and strips The BestFitFilter's aspect ratio cap of 5.0 was rejecting valid pair candidates needed for narrow plates (e.g. 60x6.5, aspect 9.2) and remainder strips on normal plates. Three fixes: - BestFitFinder: derive MaxAspectRatio from the plate's own aspect ratio so narrow plates don't reject all elongated pairs - SelectPairCandidates: search the full unfiltered candidate list (not just Keep=true) in strip mode, so pairs rejected by aspect ratio for the main plate can still be used for narrow remainder strips - BestFitCache.Populate: skip caching empty result lists so stale pre-computed data from nest files doesn't prevent recomputation Also fixes console --size parsing to use LxW format matching Size.Parse convention, and includes prior engine refactoring (sequential fill loops, parallel FillPattern, pre-sorted edge arrays in RotationSlideStrategy). Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/Program.cs | 11 +- OpenNest.Core/Helper.cs | 2 +- OpenNest.Engine/BestFit/BestFitCache.cs | 3 + OpenNest.Engine/BestFit/BestFitFinder.cs | 5 +- .../BestFit/RotationSlideStrategy.cs | 78 ++++++- OpenNest.Engine/FillLinear.cs | 5 +- OpenNest.Engine/NestEngine.cs | 200 ++++++++---------- OpenNest/Forms/MainForm.cs | 8 +- 8 files changed, 185 insertions(+), 127 deletions(-) diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 53bb30f..49ac31d 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -98,8 +98,8 @@ static class NestConsole var parts = args[++i].Split('x'); if (parts.Length == 2) { - o.PlateWidth = double.Parse(parts[0]); - o.PlateHeight = double.Parse(parts[1]); + o.PlateHeight = double.Parse(parts[0]); + o.PlateWidth = double.Parse(parts[1]); } break; case "--check-overlaps": @@ -297,7 +297,8 @@ static class NestConsole static void PrintHeader(Nest nest, Plate plate, Drawing drawing, int existingCount, Options options) { Console.WriteLine($"Nest: {nest.Name}"); - Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}"); + var wa = plate.WorkArea(); + Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Length:F1} x {plate.Size.Width:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Length:F1}x{wa.Width:F1}"); Console.WriteLine($"Drawing: {drawing.Name}"); Console.WriteLine(options.KeepParts ? $"Keeping {existingCount} existing parts" @@ -386,7 +387,7 @@ static class NestConsole Console.Error.WriteLine(); Console.Error.WriteLine("Modes:"); Console.Error.WriteLine(" Load nest and fill (existing behavior)"); - Console.Error.WriteLine(" --size WxH Import DXF, create plate, and fill"); + Console.Error.WriteLine(" --size LxW Import DXF, create plate, and fill"); Console.Error.WriteLine(" Load nest and add imported DXF drawings"); Console.Error.WriteLine(); Console.Error.WriteLine("Options:"); @@ -394,7 +395,7 @@ static class NestConsole Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); Console.Error.WriteLine(" --spacing Override part spacing"); - Console.Error.WriteLine(" --size Override plate size (e.g. 120x60); required for DXF-only mode"); + Console.Error.WriteLine(" --size Override plate size (e.g. 120x60); required for DXF-only mode"); Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); Console.Error.WriteLine(" --template Nest template for plate defaults (thickness, quadrant, material, spacing)"); Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index ecea400..5b746c1 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -1103,7 +1103,7 @@ namespace OpenNest return minDist; } - private static double OneWayDistance( + public static double OneWayDistance( Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset, PushDirection direction) { diff --git a/OpenNest.Engine/BestFit/BestFitCache.cs b/OpenNest.Engine/BestFit/BestFitCache.cs index d7b1fc9..8a12faa 100644 --- a/OpenNest.Engine/BestFit/BestFitCache.cs +++ b/OpenNest.Engine/BestFit/BestFitCache.cs @@ -153,6 +153,9 @@ namespace OpenNest.Engine.BestFit public static void Populate(Drawing drawing, double plateWidth, double plateHeight, double spacing, List results) { + if (results == null || results.Count == 0) + return; + var key = new CacheKey(drawing, plateWidth, plateHeight, spacing); _cache.TryAdd(key, results); } diff --git a/OpenNest.Engine/BestFit/BestFitFinder.cs b/OpenNest.Engine/BestFit/BestFitFinder.cs index b456bf6..b86bc6a 100644 --- a/OpenNest.Engine/BestFit/BestFitFinder.cs +++ b/OpenNest.Engine/BestFit/BestFitFinder.cs @@ -20,10 +20,13 @@ namespace OpenNest.Engine.BestFit { _evaluator = evaluator ?? new PairEvaluator(); _slideComputer = slideComputer; + var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) / + System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001); _filter = new BestFitFilter { MaxPlateWidth = maxPlateWidth, - MaxPlateHeight = maxPlateHeight + MaxPlateHeight = maxPlateHeight, + MaxAspectRatio = System.Math.Max(5.0, plateAspect) }; } diff --git a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs index 7da14dd..395025f 100644 --- a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs +++ b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using OpenNest.Geometry; namespace OpenNest.Engine.BestFit @@ -147,11 +148,82 @@ namespace OpenNest.Engine.BestFit var results = new double[count]; - for (var i = 0; i < count; i++) + // Pre-calculate moving vertices in local space. + var movingVerticesLocal = new HashSet(); + for (var i = 0; i < part2TemplateLines.Count; i++) { - results[i] = Helper.DirectionalDistance( - part2TemplateLines, allDx[i], allDy[i], part1Lines, allDirs[i]); + movingVerticesLocal.Add(part2TemplateLines[i].StartPoint); + movingVerticesLocal.Add(part2TemplateLines[i].EndPoint); } + var movingVerticesArray = movingVerticesLocal.ToArray(); + + // Pre-calculate stationary vertices in local space. + var stationaryVerticesLocal = new HashSet(); + for (var i = 0; i < part1Lines.Count; i++) + { + stationaryVerticesLocal.Add(part1Lines[i].StartPoint); + stationaryVerticesLocal.Add(part1Lines[i].EndPoint); + } + var stationaryVerticesArray = stationaryVerticesLocal.ToArray(); + + // Pre-sort stationary and moving edges for all 4 directions. + var stationaryEdgesByDir = new Dictionary(); + var movingEdgesByDir = new Dictionary(); + + foreach (var dir in AllDirections) + { + var sEdges = new (Vector start, Vector end)[part1Lines.Count]; + for (var i = 0; i < part1Lines.Count; i++) + sEdges[i] = (part1Lines[i].StartPoint, part1Lines[i].EndPoint); + + if (dir == PushDirection.Left || dir == PushDirection.Right) + sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + stationaryEdgesByDir[dir] = sEdges; + + var opposite = Helper.OppositeDirection(dir); + var mEdges = new (Vector start, Vector end)[part2TemplateLines.Count]; + for (var i = 0; i < part2TemplateLines.Count; i++) + mEdges[i] = (part2TemplateLines[i].StartPoint, part2TemplateLines[i].EndPoint); + + if (opposite == PushDirection.Left || opposite == PushDirection.Right) + mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + movingEdgesByDir[dir] = mEdges; + } + + // Use Parallel.For for the heavy lifting. + System.Threading.Tasks.Parallel.For(0, count, i => + { + var dx = allDx[i]; + var dy = allDy[i]; + var dir = allDirs[i]; + var movingOffset = new Vector(dx, dy); + + var sEdges = stationaryEdgesByDir[dir]; + var mEdges = movingEdgesByDir[dir]; + var opposite = Helper.OppositeDirection(dir); + + var minDist = double.MaxValue; + + // Case 1: Moving vertices -> Stationary edges + foreach (var mv in movingVerticesArray) + { + var d = Helper.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir); + if (d < minDist) minDist = d; + } + + // Case 2: Stationary vertices -> Moving edges (translated) + foreach (var sv in stationaryVerticesArray) + { + var d = Helper.OneWayDistance(sv, mEdges, movingOffset, opposite); + if (d < minDist) minDist = d; + } + + results[i] = minDist; + }); return results; } diff --git a/OpenNest.Engine/FillLinear.cs b/OpenNest.Engine/FillLinear.cs index deb2deb..09c0ead 100644 --- a/OpenNest.Engine/FillLinear.cs +++ b/OpenNest.Engine/FillLinear.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading.Tasks; using OpenNest.Geometry; using OpenNest.Math; @@ -512,7 +513,7 @@ namespace OpenNest { var bag = new System.Collections.Concurrent.ConcurrentBag>(); - foreach (var entry in rotations) + Parallel.ForEach(rotations, entry => { var filler = new FillLinear(strip, PartSpacing); var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal); @@ -523,7 +524,7 @@ namespace OpenNest if (v != null && v.Count > 0) bag.Add(v); - } + }); List best = null; diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 271629a..7344925 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; +using System.Threading.Tasks; using OpenNest.Engine.BestFit; using OpenNest.Engine.ML; using OpenNest.Geometry; @@ -215,54 +216,48 @@ namespace OpenNest // Linear phase var linearSw = Stopwatch.StartNew(); - var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); - var angleBag = new System.Collections.Concurrent.ConcurrentBag(); - var anglesCompleted = 0; - - System.Threading.Tasks.Parallel.ForEach(angles, - new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, - angle => - { - var localEngine = new FillLinear(workArea, Plate.PartSpacing); - var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal); - var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical); - - var angleDeg = Angle.ToDegrees(angle); - if (h != null && h.Count > 0) - { - linearBag.Add((FillScore.Compute(h, workArea), h)); - angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count }); - } - if (v != null && v.Count > 0) - { - linearBag.Add((FillScore.Compute(v, workArea), v)); - angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count }); - } - - var done = Interlocked.Increment(ref anglesCompleted); - var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0); - progress?.Report(new NestProgress - { - Phase = NestPhase.Linear, - PlateNumber = PlateNumber, - Description = $"Linear: {done}/{angles.Count} angles, {angleDeg:F0}° = {bestCount} parts" - }); - }); - linearSw.Stop(); - AngleResults.AddRange(angleBag); - var bestLinearCount = 0; - foreach (var (score, parts) in linearBag) + + for (var ai = 0; ai < angles.Count; ai++) { - if (parts.Count > bestLinearCount) - bestLinearCount = parts.Count; - if (score > bestScore) + token.ThrowIfCancellationRequested(); + + var angle = angles[ai]; + var localEngine = new FillLinear(workArea, Plate.PartSpacing); + var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal); + var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical); + + var angleDeg = Angle.ToDegrees(angle); + if (h != null && h.Count > 0) { - best = parts; - bestScore = score; - WinnerPhase = NestPhase.Linear; + var scoreH = FillScore.Compute(h, workArea); + AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count }); + if (h.Count > bestLinearCount) bestLinearCount = h.Count; + if (scoreH > bestScore) + { + best = h; + bestScore = scoreH; + WinnerPhase = NestPhase.Linear; + } } + if (v != null && v.Count > 0) + { + var scoreV = FillScore.Compute(v, workArea); + AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count }); + if (v.Count > bestLinearCount) bestLinearCount = v.Count; + if (scoreV > bestScore) + { + best = v; + bestScore = scoreV; + WinnerPhase = NestPhase.Linear; + } + } + + ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, + $"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts"); } + + linearSw.Stop(); PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds)); Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); @@ -369,56 +364,51 @@ namespace OpenNest Plate.PartSpacing); var candidates = SelectPairCandidates(bestFits, workArea); - Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); + var diagMsg = $"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}\n" + + $"[FillWithPairs] Plate: {Plate.Size.Width:F2}x{Plate.Size.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}"; + Debug.WriteLine(diagMsg); + try { System.IO.File.AppendAllText( + System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"), + $"{DateTime.Now:HH:mm:ss} {diagMsg}\n"); } catch { } - var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); + List best = null; + var bestScore = default(FillScore); try { - var pairsCompleted = 0; - var pairsBestCount = 0; + for (var i = 0; i < candidates.Count; i++) + { + token.ThrowIfCancellationRequested(); - System.Threading.Tasks.Parallel.For(0, candidates.Count, - new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, - i => + var result = candidates[i]; + var pairParts = result.BuildParts(item.Drawing); + var angles = result.HullAngles; + var engine = new FillLinear(workArea, Plate.PartSpacing); + var filled = FillPattern(engine, pairParts, angles, workArea); + + if (filled != null && filled.Count > 0) { - var result = candidates[i]; - var pairParts = result.BuildParts(item.Drawing); - var angles = result.HullAngles; - var engine = new FillLinear(workArea, Plate.PartSpacing); - var filled = FillPattern(engine, pairParts, angles, workArea); - - if (filled != null && filled.Count > 0) - resultBag.Add((FillScore.Compute(filled, workArea), filled)); - - var done = Interlocked.Increment(ref pairsCompleted); - InterlockedMax(ref pairsBestCount, filled?.Count ?? 0); - progress?.Report(new NestProgress + var score = FillScore.Compute(filled, workArea); + if (best == null || score > bestScore) { - Phase = NestPhase.Pairs, - PlateNumber = PlateNumber, - Description = $"Pairs: {done}/{candidates.Count} candidates, best = {pairsBestCount} parts" - }); - }); + best = filled; + bestScore = score; + } + } + + ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, + $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts"); + } } catch (OperationCanceledException) { Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far"); } - List best = null; - var bestScore = default(FillScore); - - foreach (var (score, parts) in resultBag) - { - if (best == null || score > bestScore) - { - best = parts; - bestScore = score; - } - } - Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}"); + try { System.IO.File.AppendAllText( + System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"), + $"{DateTime.Now:HH:mm:ss} [FillWithPairs] Best: {bestScore.Count} parts, density={bestScore.Density:P1}\n"); } catch { } return best ?? new List(); } @@ -436,11 +426,14 @@ namespace OpenNest var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Length); // When the work area is significantly narrower than the plate, - // include all pairs that fit the narrow dimension. + // search ALL candidates (not just kept) for pairs that fit the + // narrow dimension. Pairs rejected by aspect ratio for the full + // plate may be exactly what's needed for a narrow remainder strip. if (workShortSide < plateShortSide * 0.5) { - var stripCandidates = kept - .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon); + var stripCandidates = bestFits + .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon + && r.Utilization >= 0.3); var existing = new HashSet(top); @@ -480,32 +473,33 @@ namespace OpenNest private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) { - List best = null; - var bestScore = default(FillScore); + var results = new System.Collections.Concurrent.ConcurrentBag<(List Parts, FillScore Score)>(); - foreach (var angle in angles) + Parallel.ForEach(angles, angle => { var pattern = BuildRotatedPattern(groupParts, angle); if (pattern.Parts.Count == 0) - continue; + return; var h = engine.Fill(pattern, NestDirection.Horizontal); - var scoreH = h != null && h.Count > 0 ? FillScore.Compute(h, workArea) : default; - - if (scoreH.Count > 0 && (best == null || scoreH > bestScore)) - { - best = h; - bestScore = scoreH; - } + if (h != null && h.Count > 0) + results.Add((h, FillScore.Compute(h, workArea))); var v = engine.Fill(pattern, NestDirection.Vertical); - var scoreV = v != null && v.Count > 0 ? FillScore.Compute(v, workArea) : default; + if (v != null && v.Count > 0) + results.Add((v, FillScore.Compute(v, workArea))); + }); - if (scoreV.Count > 0 && (best == null || scoreV > bestScore)) + List best = null; + var bestScore = default(FillScore); + + foreach (var res in results) + { + if (best == null || res.Score > bestScore) { - best = v; - bestScore = scoreV; + best = res.Parts; + bestScore = res.Score; } } @@ -766,17 +760,5 @@ namespace OpenNest } } - // --- Utilities --- - - private static void InterlockedMax(ref int location, int value) - { - int current; - do - { - current = location; - if (value <= current) return; - } while (Interlocked.CompareExchange(ref location, value, current) != current); - } - } } diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 0ef8fac..4329cd1 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -862,9 +862,7 @@ namespace OpenNest.Forms var progress = new Progress(p => { progressForm.UpdateProgress(p); - - if (p.BestParts != null) - activeForm.PlateView.SetTemporaryParts(p.BestParts); + activeForm.PlateView.SetTemporaryParts(p.BestParts); }); progressForm.Show(this); @@ -924,9 +922,7 @@ namespace OpenNest.Forms var progress = new Progress(p => { progressForm.UpdateProgress(p); - - if (p.BestParts != null) - activeForm.PlateView.SetTemporaryParts(p.BestParts); + activeForm.PlateView.SetTemporaryParts(p.BestParts); }); Action> onComplete = parts => From 8e0c082876ed23e8b81ab0140952ac665da65168 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 01:30:50 -0400 Subject: [PATCH 042/116] refactor(ui): optimize PushSelected with directional filtering and lazy line computation Extract direction helpers to Helper class (EdgeDistance, DirectionalGap, DirectionToOffset, IsHorizontalDirection) and use them to skip parts not ahead in the push direction or further than the current best distance. Defer line computation until parts survive bounding box checks. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Helper.cs | 41 ++++++++++++++++ OpenNest/Controls/PlateView.cs | 88 +++++++++++----------------------- 2 files changed, 69 insertions(+), 60 deletions(-) diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index 5b746c1..44aa015 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -1170,6 +1170,47 @@ namespace OpenNest } } + public static bool IsHorizontalDirection(PushDirection direction) + { + return direction is PushDirection.Left or PushDirection.Right; + } + + public static double EdgeDistance(Box box, Box boundary, PushDirection direction) + { + switch (direction) + { + case PushDirection.Left: return box.Left - boundary.Left; + case PushDirection.Right: return boundary.Right - box.Right; + case PushDirection.Up: return boundary.Top - box.Top; + case PushDirection.Down: return box.Bottom - boundary.Bottom; + default: return double.MaxValue; + } + } + + public static Vector DirectionToOffset(PushDirection direction, double distance) + { + switch (direction) + { + case PushDirection.Left: return new Vector(-distance, 0); + case PushDirection.Right: return new Vector(distance, 0); + case PushDirection.Up: return new Vector(0, distance); + case PushDirection.Down: return new Vector(0, -distance); + default: return new Vector(); + } + } + + public static double DirectionalGap(Box from, Box to, PushDirection direction) + { + switch (direction) + { + case PushDirection.Left: return from.Left - to.Right; + case PushDirection.Right: return to.Left - from.Right; + case PushDirection.Up: return to.Bottom - from.Top; + case PushDirection.Down: return from.Bottom - to.Top; + default: return double.MaxValue; + } + } + public static double ClosestDistanceLeft(Box box, List boxes) { var closestDistance = double.MaxValue; diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index 5ef9bba..051d82c 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -937,94 +937,62 @@ namespace OpenNest.Controls public void PushSelected(PushDirection direction) { - // Build line segments for all stationary parts. var stationaryParts = parts.Where(p => !p.IsSelected && !SelectedParts.Contains(p)).ToList(); - var stationaryLines = new List>(stationaryParts.Count); - var stationaryBoxes = new List(stationaryParts.Count); + var stationaryBoxes = new Box[stationaryParts.Count]; + var stationaryLines = new List[stationaryParts.Count]; var opposite = Helper.OppositeDirection(direction); var halfSpacing = Plate.PartSpacing / 2; + var isHorizontal = Helper.IsHorizontalDirection(direction); - foreach (var part in stationaryParts) - { - stationaryLines.Add(halfSpacing > 0 - ? Helper.GetOffsetPartLines(part.BasePart, halfSpacing, opposite, OffsetTolerance) - : Helper.GetPartLines(part.BasePart, opposite, OffsetTolerance)); - stationaryBoxes.Add(part.BoundingBox); - } + for (var i = 0; i < stationaryParts.Count; i++) + stationaryBoxes[i] = stationaryParts[i].BoundingBox; var workArea = Plate.WorkArea(); var distance = double.MaxValue; foreach (var selected in SelectedParts) { - // Get offset lines for the moving part (half-spacing, symmetric with stationary). - var movingLines = halfSpacing > 0 - ? Helper.GetOffsetPartLines(selected.BasePart, halfSpacing, direction, OffsetTolerance) - : Helper.GetPartLines(selected.BasePart, direction, OffsetTolerance); + // Check plate edge first to tighten the upper bound. + var edgeDist = Helper.EdgeDistance(selected.BoundingBox, workArea, direction); + if (edgeDist > 0 && edgeDist < distance) + distance = edgeDist; var movingBox = selected.BoundingBox; + List movingLines = null; - // Check geometry distance against each stationary part. - for (int i = 0; i < stationaryLines.Count; i++) + for (var i = 0; i < stationaryBoxes.Length; i++) { - // Early-out: skip if bounding boxes don't overlap on the perpendicular axis. - var stBox = stationaryBoxes[i]; - bool perpOverlap; + // Skip parts not ahead in the push direction or further than current best. + var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction); + if (gap < 0 || gap >= distance) + continue; - switch (direction) - { - case PushDirection.Left: - case PushDirection.Right: - perpOverlap = !(movingBox.Bottom >= stBox.Top || movingBox.Top <= stBox.Bottom); - break; - default: // Up, Down - perpOverlap = !(movingBox.Left >= stBox.Right || movingBox.Right <= stBox.Left); - break; - } + var perpOverlap = isHorizontal + ? movingBox.IsHorizontalTo(stationaryBoxes[i], out _) + : movingBox.IsVerticalTo(stationaryBoxes[i], out _); if (!perpOverlap) continue; + // Compute lines lazily — only for parts that survive bounding box checks. + movingLines ??= halfSpacing > 0 + ? Helper.GetOffsetPartLines(selected.BasePart, halfSpacing, direction, OffsetTolerance) + : Helper.GetPartLines(selected.BasePart, direction, OffsetTolerance); + + stationaryLines[i] ??= halfSpacing > 0 + ? Helper.GetOffsetPartLines(stationaryParts[i].BasePart, halfSpacing, opposite, OffsetTolerance) + : Helper.GetPartLines(stationaryParts[i].BasePart, opposite, OffsetTolerance); + var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction); if (d < distance) distance = d; } - - // Check distance to plate edge (actual geometry bbox, not offset). - double edgeDist; - switch (direction) - { - case PushDirection.Left: - edgeDist = selected.Left - workArea.Left; - break; - case PushDirection.Right: - edgeDist = workArea.Right - selected.Right; - break; - case PushDirection.Up: - edgeDist = workArea.Top - selected.Top; - break; - default: // Down - edgeDist = selected.Bottom - workArea.Bottom; - break; - } - - if (edgeDist > 0 && edgeDist < distance) - distance = edgeDist; } if (distance < double.MaxValue && distance > 0) { - var offset = new Vector(); - - switch (direction) - { - case PushDirection.Left: offset.X = -distance; break; - case PushDirection.Right: offset.X = distance; break; - case PushDirection.Up: offset.Y = distance; break; - case PushDirection.Down: offset.Y = -distance; break; - } - + var offset = Helper.DirectionToOffset(direction, distance); SelectedParts.ForEach(p => p.Offset(offset)); Invalidate(); } From 45509cfd3fdfac85898365bcf95f1255be007afb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 01:42:34 -0400 Subject: [PATCH 043/116] feat(ui): add nested dimensions and area to progress window Show width x length and total part area on the "Nested:" row in the nesting progress dialog, using the existing GetBoundingBox extension to compute the extents. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 5 + OpenNest.Engine/NestProgress.cs | 3 + OpenNest/Forms/NestProgressForm.Designer.cs | 444 ++++++++++++-------- OpenNest/Forms/NestProgressForm.cs | 1 + OpenNest/Forms/NestProgressForm.resx | 54 +-- 5 files changed, 295 insertions(+), 212 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 7344925..840b93c 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -723,12 +723,17 @@ namespace OpenNest totalPartArea += part.BaseDrawing.Area; } + var bounds = best.GetBoundingBox(); + progress.Report(new NestProgress { Phase = phase, PlateNumber = plateNumber, BestPartCount = score.Count, BestDensity = score.Density, + NestedWidth = bounds.Width, + NestedLength = bounds.Length, + NestedArea = totalPartArea, UsableRemnantArea = workArea.Area() - totalPartArea, BestParts = clonedParts, Description = description diff --git a/OpenNest.Engine/NestProgress.cs b/OpenNest.Engine/NestProgress.cs index ba4172e..0f87f7d 100644 --- a/OpenNest.Engine/NestProgress.cs +++ b/OpenNest.Engine/NestProgress.cs @@ -37,6 +37,9 @@ namespace OpenNest public int PlateNumber { get; set; } public int BestPartCount { get; set; } public double BestDensity { get; set; } + public double NestedWidth { get; set; } + public double NestedLength { get; set; } + public double NestedArea { get; set; } public double UsableRemnantArea { get; set; } public List BestParts { get; set; } public string Description { get; set; } diff --git a/OpenNest/Forms/NestProgressForm.Designer.cs b/OpenNest/Forms/NestProgressForm.Designer.cs index c11090d..9bed575 100644 --- a/OpenNest/Forms/NestProgressForm.Designer.cs +++ b/OpenNest/Forms/NestProgressForm.Designer.cs @@ -28,209 +28,281 @@ namespace OpenNest.Forms /// private void InitializeComponent() { - this.table = new System.Windows.Forms.TableLayoutPanel(); - this.phaseLabel = new System.Windows.Forms.Label(); - this.phaseValue = new System.Windows.Forms.Label(); - this.plateLabel = new System.Windows.Forms.Label(); - this.plateValue = new System.Windows.Forms.Label(); - this.partsLabel = new System.Windows.Forms.Label(); - this.partsValue = new System.Windows.Forms.Label(); - this.densityLabel = new System.Windows.Forms.Label(); - this.densityValue = new System.Windows.Forms.Label(); - this.remnantLabel = new System.Windows.Forms.Label(); - this.remnantValue = new System.Windows.Forms.Label(); - this.elapsedLabel = new System.Windows.Forms.Label(); - this.elapsedValue = new System.Windows.Forms.Label(); - this.descriptionLabel = new System.Windows.Forms.Label(); - this.descriptionValue = new System.Windows.Forms.Label(); - this.stopButton = new System.Windows.Forms.Button(); - this.buttonPanel = new System.Windows.Forms.FlowLayoutPanel(); - this.table.SuspendLayout(); - this.buttonPanel.SuspendLayout(); - this.SuspendLayout(); - // + table = new System.Windows.Forms.TableLayoutPanel(); + phaseLabel = new System.Windows.Forms.Label(); + phaseValue = new System.Windows.Forms.Label(); + plateLabel = new System.Windows.Forms.Label(); + plateValue = new System.Windows.Forms.Label(); + partsLabel = new System.Windows.Forms.Label(); + partsValue = new System.Windows.Forms.Label(); + densityLabel = new System.Windows.Forms.Label(); + densityValue = new System.Windows.Forms.Label(); + nestedAreaLabel = new System.Windows.Forms.Label(); + nestedAreaValue = new System.Windows.Forms.Label(); + remnantLabel = new System.Windows.Forms.Label(); + remnantValue = new System.Windows.Forms.Label(); + elapsedLabel = new System.Windows.Forms.Label(); + elapsedValue = new System.Windows.Forms.Label(); + descriptionLabel = new System.Windows.Forms.Label(); + descriptionValue = new System.Windows.Forms.Label(); + stopButton = new System.Windows.Forms.Button(); + buttonPanel = new System.Windows.Forms.FlowLayoutPanel(); + table.SuspendLayout(); + buttonPanel.SuspendLayout(); + SuspendLayout(); + // // table - // - this.table.ColumnCount = 2; - this.table.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 80F)); - this.table.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.Controls.Add(this.phaseLabel, 0, 0); - this.table.Controls.Add(this.phaseValue, 1, 0); - this.table.Controls.Add(this.plateLabel, 0, 1); - this.table.Controls.Add(this.plateValue, 1, 1); - this.table.Controls.Add(this.partsLabel, 0, 2); - this.table.Controls.Add(this.partsValue, 1, 2); - this.table.Controls.Add(this.densityLabel, 0, 3); - this.table.Controls.Add(this.densityValue, 1, 3); - this.table.Controls.Add(this.remnantLabel, 0, 4); - this.table.Controls.Add(this.remnantValue, 1, 4); - this.table.Controls.Add(this.elapsedLabel, 0, 5); - this.table.Controls.Add(this.elapsedValue, 1, 5); - this.table.Controls.Add(this.descriptionLabel, 0, 6); - this.table.Controls.Add(this.descriptionValue, 1, 6); - this.table.Dock = System.Windows.Forms.DockStyle.Top; - this.table.Location = new System.Drawing.Point(0, 0); - this.table.Name = "table"; - this.table.Padding = new System.Windows.Forms.Padding(8); - this.table.RowCount = 7; - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.AutoSize = true; - this.table.Size = new System.Drawing.Size(264, 156); - this.table.TabIndex = 0; - // + // + table.AutoSize = true; + table.ColumnCount = 2; + table.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 93F)); + table.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + table.Controls.Add(phaseLabel, 0, 0); + table.Controls.Add(phaseValue, 1, 0); + table.Controls.Add(plateLabel, 0, 1); + table.Controls.Add(plateValue, 1, 1); + table.Controls.Add(partsLabel, 0, 2); + table.Controls.Add(partsValue, 1, 2); + table.Controls.Add(densityLabel, 0, 3); + table.Controls.Add(densityValue, 1, 3); + table.Controls.Add(nestedAreaLabel, 0, 4); + table.Controls.Add(nestedAreaValue, 1, 4); + table.Controls.Add(remnantLabel, 0, 5); + table.Controls.Add(remnantValue, 1, 5); + table.Controls.Add(elapsedLabel, 0, 6); + table.Controls.Add(elapsedValue, 1, 6); + table.Controls.Add(descriptionLabel, 0, 7); + table.Controls.Add(descriptionValue, 1, 7); + table.Dock = System.Windows.Forms.DockStyle.Top; + table.Location = new System.Drawing.Point(0, 45); + table.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + table.Name = "table"; + table.Padding = new System.Windows.Forms.Padding(9, 9, 9, 9); + table.RowCount = 8; + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.Size = new System.Drawing.Size(425, 218); + table.TabIndex = 0; + // // phaseLabel - // - this.phaseLabel.AutoSize = true; - this.phaseLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.phaseLabel.Margin = new System.Windows.Forms.Padding(4); - this.phaseLabel.Name = "phaseLabel"; - this.phaseLabel.Text = "Phase:"; - // + // + phaseLabel.AutoSize = true; + phaseLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + phaseLabel.Location = new System.Drawing.Point(14, 14); + phaseLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + phaseLabel.Name = "phaseLabel"; + phaseLabel.Size = new System.Drawing.Size(46, 13); + phaseLabel.TabIndex = 0; + phaseLabel.Text = "Phase:"; + // // phaseValue - // - this.phaseValue.AutoSize = true; - this.phaseValue.Margin = new System.Windows.Forms.Padding(4); - this.phaseValue.Name = "phaseValue"; - this.phaseValue.Text = "\u2014"; - // + // + phaseValue.AutoSize = true; + phaseValue.Location = new System.Drawing.Point(107, 14); + phaseValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + phaseValue.Name = "phaseValue"; + phaseValue.Size = new System.Drawing.Size(19, 15); + phaseValue.TabIndex = 1; + phaseValue.Text = "—"; + // // plateLabel - // - this.plateLabel.AutoSize = true; - this.plateLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.plateLabel.Margin = new System.Windows.Forms.Padding(4); - this.plateLabel.Name = "plateLabel"; - this.plateLabel.Text = "Plate:"; - // + // + plateLabel.AutoSize = true; + plateLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + plateLabel.Location = new System.Drawing.Point(14, 39); + plateLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + plateLabel.Name = "plateLabel"; + plateLabel.Size = new System.Drawing.Size(40, 13); + plateLabel.TabIndex = 2; + plateLabel.Text = "Plate:"; + // // plateValue - // - this.plateValue.AutoSize = true; - this.plateValue.Margin = new System.Windows.Forms.Padding(4); - this.plateValue.Name = "plateValue"; - this.plateValue.Text = "\u2014"; - // + // + plateValue.AutoSize = true; + plateValue.Location = new System.Drawing.Point(107, 39); + plateValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + plateValue.Name = "plateValue"; + plateValue.Size = new System.Drawing.Size(19, 15); + plateValue.TabIndex = 3; + plateValue.Text = "—"; + // // partsLabel - // - this.partsLabel.AutoSize = true; - this.partsLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.partsLabel.Margin = new System.Windows.Forms.Padding(4); - this.partsLabel.Name = "partsLabel"; - this.partsLabel.Text = "Parts:"; - // + // + partsLabel.AutoSize = true; + partsLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + partsLabel.Location = new System.Drawing.Point(14, 64); + partsLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + partsLabel.Name = "partsLabel"; + partsLabel.Size = new System.Drawing.Size(40, 13); + partsLabel.TabIndex = 4; + partsLabel.Text = "Parts:"; + // // partsValue - // - this.partsValue.AutoSize = true; - this.partsValue.Margin = new System.Windows.Forms.Padding(4); - this.partsValue.Name = "partsValue"; - this.partsValue.Text = "\u2014"; - // + // + partsValue.AutoSize = true; + partsValue.Location = new System.Drawing.Point(107, 64); + partsValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + partsValue.Name = "partsValue"; + partsValue.Size = new System.Drawing.Size(19, 15); + partsValue.TabIndex = 5; + partsValue.Text = "—"; + // // densityLabel - // - this.densityLabel.AutoSize = true; - this.densityLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.densityLabel.Margin = new System.Windows.Forms.Padding(4); - this.densityLabel.Name = "densityLabel"; - this.densityLabel.Text = "Density:"; - // + // + densityLabel.AutoSize = true; + densityLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + densityLabel.Location = new System.Drawing.Point(14, 89); + densityLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + densityLabel.Name = "densityLabel"; + densityLabel.Size = new System.Drawing.Size(53, 13); + densityLabel.TabIndex = 6; + densityLabel.Text = "Density:"; + // // densityValue - // - this.densityValue.AutoSize = true; - this.densityValue.Margin = new System.Windows.Forms.Padding(4); - this.densityValue.Name = "densityValue"; - this.densityValue.Text = "\u2014"; - // + // + densityValue.AutoSize = true; + densityValue.Location = new System.Drawing.Point(107, 89); + densityValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + densityValue.Name = "densityValue"; + densityValue.Size = new System.Drawing.Size(19, 15); + densityValue.TabIndex = 7; + densityValue.Text = "—"; + // + // nestedAreaLabel + // + nestedAreaLabel.AutoSize = true; + nestedAreaLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + nestedAreaLabel.Location = new System.Drawing.Point(14, 114); + nestedAreaLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + nestedAreaLabel.Name = "nestedAreaLabel"; + nestedAreaLabel.Size = new System.Drawing.Size(51, 13); + nestedAreaLabel.TabIndex = 8; + nestedAreaLabel.Text = "Nested:"; + // + // nestedAreaValue + // + nestedAreaValue.AutoSize = true; + nestedAreaValue.Location = new System.Drawing.Point(107, 114); + nestedAreaValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + nestedAreaValue.Name = "nestedAreaValue"; + nestedAreaValue.Size = new System.Drawing.Size(19, 15); + nestedAreaValue.TabIndex = 9; + nestedAreaValue.Text = "—"; + // // remnantLabel - // - this.remnantLabel.AutoSize = true; - this.remnantLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.remnantLabel.Margin = new System.Windows.Forms.Padding(4); - this.remnantLabel.Name = "remnantLabel"; - this.remnantLabel.Text = "Unused:"; - // + // + remnantLabel.AutoSize = true; + remnantLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + remnantLabel.Location = new System.Drawing.Point(14, 139); + remnantLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + remnantLabel.Name = "remnantLabel"; + remnantLabel.Size = new System.Drawing.Size(54, 13); + remnantLabel.TabIndex = 10; + remnantLabel.Text = "Unused:"; + // // remnantValue - // - this.remnantValue.AutoSize = true; - this.remnantValue.Margin = new System.Windows.Forms.Padding(4); - this.remnantValue.Name = "remnantValue"; - this.remnantValue.Text = "\u2014"; - // + // + remnantValue.AutoSize = true; + remnantValue.Location = new System.Drawing.Point(107, 139); + remnantValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + remnantValue.Name = "remnantValue"; + remnantValue.Size = new System.Drawing.Size(19, 15); + remnantValue.TabIndex = 11; + remnantValue.Text = "—"; + // // elapsedLabel - // - this.elapsedLabel.AutoSize = true; - this.elapsedLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.elapsedLabel.Margin = new System.Windows.Forms.Padding(4); - this.elapsedLabel.Name = "elapsedLabel"; - this.elapsedLabel.Text = "Elapsed:"; - // + // + elapsedLabel.AutoSize = true; + elapsedLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + elapsedLabel.Location = new System.Drawing.Point(14, 164); + elapsedLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + elapsedLabel.Name = "elapsedLabel"; + elapsedLabel.Size = new System.Drawing.Size(56, 13); + elapsedLabel.TabIndex = 12; + elapsedLabel.Text = "Elapsed:"; + // // elapsedValue - // - this.elapsedValue.AutoSize = true; - this.elapsedValue.Margin = new System.Windows.Forms.Padding(4); - this.elapsedValue.Name = "elapsedValue"; - this.elapsedValue.Text = "0:00"; - // + // + elapsedValue.AutoSize = true; + elapsedValue.Location = new System.Drawing.Point(107, 164); + elapsedValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + elapsedValue.Name = "elapsedValue"; + elapsedValue.Size = new System.Drawing.Size(28, 15); + elapsedValue.TabIndex = 13; + elapsedValue.Text = "0:00"; + // // descriptionLabel - // - this.descriptionLabel.AutoSize = true; - this.descriptionLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.descriptionLabel.Margin = new System.Windows.Forms.Padding(4); - this.descriptionLabel.Name = "descriptionLabel"; - this.descriptionLabel.Text = "Detail:"; - // + // + descriptionLabel.AutoSize = true; + descriptionLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + descriptionLabel.Location = new System.Drawing.Point(14, 189); + descriptionLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + descriptionLabel.Name = "descriptionLabel"; + descriptionLabel.Size = new System.Drawing.Size(44, 13); + descriptionLabel.TabIndex = 14; + descriptionLabel.Text = "Detail:"; + // // descriptionValue - // - this.descriptionValue.AutoSize = true; - this.descriptionValue.Margin = new System.Windows.Forms.Padding(4); - this.descriptionValue.Name = "descriptionValue"; - this.descriptionValue.Text = "\u2014"; - // + // + descriptionValue.AutoSize = true; + descriptionValue.Location = new System.Drawing.Point(107, 189); + descriptionValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + descriptionValue.Name = "descriptionValue"; + descriptionValue.Size = new System.Drawing.Size(19, 15); + descriptionValue.TabIndex = 15; + descriptionValue.Text = "—"; + // // stopButton - // - this.stopButton.Anchor = System.Windows.Forms.AnchorStyles.None; - this.stopButton.Margin = new System.Windows.Forms.Padding(0, 8, 0, 8); - this.stopButton.Name = "stopButton"; - this.stopButton.Size = new System.Drawing.Size(80, 23); - this.stopButton.TabIndex = 0; - this.stopButton.Text = "Stop"; - this.stopButton.UseVisualStyleBackColor = true; - this.stopButton.Click += new System.EventHandler(this.StopButton_Click); - // + // + stopButton.Anchor = System.Windows.Forms.AnchorStyles.None; + stopButton.Location = new System.Drawing.Point(314, 9); + stopButton.Margin = new System.Windows.Forms.Padding(0, 9, 0, 9); + stopButton.Name = "stopButton"; + stopButton.Size = new System.Drawing.Size(93, 27); + stopButton.TabIndex = 0; + stopButton.Text = "Stop"; + stopButton.UseVisualStyleBackColor = true; + stopButton.Click += StopButton_Click; + // // buttonPanel - // - this.buttonPanel.AutoSize = true; - this.buttonPanel.Controls.Add(this.stopButton); - this.buttonPanel.Dock = System.Windows.Forms.DockStyle.Top; - this.buttonPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft; - this.buttonPanel.Name = "buttonPanel"; - this.buttonPanel.Padding = new System.Windows.Forms.Padding(8, 0, 8, 0); - this.buttonPanel.TabIndex = 1; - // + // + buttonPanel.AutoSize = true; + buttonPanel.Controls.Add(stopButton); + buttonPanel.Dock = System.Windows.Forms.DockStyle.Top; + buttonPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft; + buttonPanel.Location = new System.Drawing.Point(0, 0); + buttonPanel.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + buttonPanel.Name = "buttonPanel"; + buttonPanel.Padding = new System.Windows.Forms.Padding(9, 0, 9, 0); + buttonPanel.Size = new System.Drawing.Size(425, 45); + buttonPanel.TabIndex = 1; + // // NestProgressForm - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(264, 230); - this.Controls.Add(this.buttonPanel); - this.Controls.Add(this.table); - this.Controls.SetChildIndex(this.table, 0); - this.Controls.SetChildIndex(this.buttonPanel, 1); - this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow; - this.MaximizeBox = false; - this.MinimizeBox = false; - this.Name = "NestProgressForm"; - this.ShowInTaskbar = false; - this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; - this.Text = "Nesting Progress"; - this.table.ResumeLayout(false); - this.table.PerformLayout(); - this.buttonPanel.ResumeLayout(false); - this.ResumeLayout(false); - this.PerformLayout(); + // + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + ClientSize = new System.Drawing.Size(425, 266); + Controls.Add(table); + Controls.Add(buttonPanel); + FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow; + Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + MaximizeBox = false; + MinimizeBox = false; + Name = "NestProgressForm"; + ShowInTaskbar = false; + StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + Text = "Nesting Progress"; + table.ResumeLayout(false); + table.PerformLayout(); + buttonPanel.ResumeLayout(false); + ResumeLayout(false); + PerformLayout(); } #endregion @@ -244,6 +316,8 @@ namespace OpenNest.Forms private System.Windows.Forms.Label partsValue; private System.Windows.Forms.Label densityLabel; private System.Windows.Forms.Label densityValue; + private System.Windows.Forms.Label nestedAreaLabel; + private System.Windows.Forms.Label nestedAreaValue; private System.Windows.Forms.Label remnantLabel; private System.Windows.Forms.Label remnantValue; private System.Windows.Forms.Label elapsedLabel; diff --git a/OpenNest/Forms/NestProgressForm.cs b/OpenNest/Forms/NestProgressForm.cs index a9512c3..023e8fc 100644 --- a/OpenNest/Forms/NestProgressForm.cs +++ b/OpenNest/Forms/NestProgressForm.cs @@ -36,6 +36,7 @@ namespace OpenNest.Forms plateValue.Text = progress.PlateNumber.ToString(); partsValue.Text = progress.BestPartCount.ToString(); densityValue.Text = progress.BestDensity.ToString("P1"); + nestedAreaValue.Text = $"{progress.NestedWidth:F1} x {progress.NestedLength:F1} ({progress.NestedArea:F1} sq in)"; remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in"; if (!string.IsNullOrEmpty(progress.Description)) diff --git a/OpenNest/Forms/NestProgressForm.resx b/OpenNest/Forms/NestProgressForm.resx index 1af7de1..8b2ff64 100644 --- a/OpenNest/Forms/NestProgressForm.resx +++ b/OpenNest/Forms/NestProgressForm.resx @@ -1,17 +1,17 @@  - From 40d99b402f70db686f96f57e676d7ee943ed0fd6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 01:51:20 -0400 Subject: [PATCH 044/116] docs: add NestProgressForm redesign spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-15-nest-progress-redesign.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-15-nest-progress-redesign.md diff --git a/docs/superpowers/specs/2026-03-15-nest-progress-redesign.md b/docs/superpowers/specs/2026-03-15-nest-progress-redesign.md new file mode 100644 index 0000000..04bb29e --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-nest-progress-redesign.md @@ -0,0 +1,118 @@ +# NestProgressForm Redesign + +## Problem + +The current `NestProgressForm` is a flat list of label/value pairs with no visual hierarchy, no progress indicator, and default WinForms styling. It's functional but looks basic and gives no sense of where the engine is in its process. + +## Solution + +Redesign the form with three changes: +1. A custom-drawn **phase stepper** control showing which nesting phase is active +2. **Grouped sections** separating results from status information +3. **Modern styling** — Segoe UI fonts, subtle background contrast, better spacing + +## Phase Stepper Control + +**New file: `OpenNest/Controls/PhaseStepperControl.cs`** + +A custom `UserControl` that draws 4 connected circles with labels beneath: + +``` + ●━━━━━━━●━━━━━━━○━━━━━━━○ +Linear BestFit Pairs Remainder +``` + +### Visual States + +- **Completed:** Filled circle with accent color, bold label +- **Active:** Filled circle with accent color and slightly larger radius, bold label +- **Pending:** Hollow circle with border only, dimmed label text +- **All complete:** All 4 circles filled (used when `ShowCompleted()` is called) + +### Implementation + +- Single `OnPaint` override. Circles evenly spaced across control width. Connecting lines drawn between circle centers — completed segments use accent color, pending segments use a light gray. +- Colors defined as `static readonly Color` fields at the top of the class for easy tweaking. +- Exposes a `CurrentPhase` property (type `NestPhase?`). Setting it calls `Invalidate()`. A `null` value means no phase is active yet. An additional `bool IsComplete` property marks all phases as done. +- `DoubleBuffered = true` to prevent flicker on repaint. +- Fixed height (~60px), docks to fill width. + +## Form Layout + +Three vertical zones using `DockStyle.Top` stacking: + +``` +┌─────────────────────────────────────┐ +│ ●━━━━━━━●━━━━━━━○━━━━━━━○ │ Phase stepper +│ Linear BestFit Pairs Remainder │ +├─────────────────────────────────────┤ +│ Results │ Results group +│ Parts: 156 │ +│ Density: 68.3% │ +│ Nested: 24.1 x 36.0 (867.6 sq in)│ +│ Unused: 43.2 sq in │ +├─────────────────────────────────────┤ +│ Status │ Status group +│ Plate: 2 │ +│ Elapsed: 1:24 │ +│ Detail: Trying 45° rotation... │ +├─────────────────────────────────────┤ +│ [ Stop ] │ Button bar +└─────────────────────────────────────┘ +``` + +### Group Panels + +Each group is a `Panel` containing: +- A header label (Segoe UI 9pt bold) at the top +- A `TableLayoutPanel` with label/value rows beneath + +Group panels use `Color.White` (or very light gray) `BackColor` against the form's `SystemColors.Control` background to create visual separation without borders. Small padding/margins between groups. + +### Typography + +- All fonts: Segoe UI (replaces MS Sans Serif) +- Group headers: 9pt bold +- Row labels: 8.25pt bold +- Row values: 8.25pt regular +- Value labels use `ForeColor = SystemColors.ControlText` + +### Sizing + +- Width: ~450px (slightly wider than current 425px for breathing room) +- Height: auto-sizes to content via `AutoSize = true` on the table panels +- `FormBorderStyle.FixedToolWindow`, `StartPosition.CenterParent`, `ShowInTaskbar = false` + +### Plate Row Visibility + +The Plate row in the Status group is hidden when `showPlateRow: false` is passed to the constructor (same as current behavior). + +## Public API + +No signature changes. The form remains a drop-in replacement. + +### Constructor + +`NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true)` — unchanged. + +### UpdateProgress(NestProgress progress) + +Same as today, plus sets `phaseStepperControl.CurrentPhase = progress.Phase` to advance the stepper visual. + +### ShowCompleted() + +Same as today (stops timer, changes button to "Close"), plus sets `phaseStepperControl.IsComplete = true` to fill all circles. + +## No External Changes + +- `NestProgress` and `NestPhase` are unchanged. +- All callers (`MainForm`, `PlateView.FillWithProgress`) continue calling `UpdateProgress` and `ShowCompleted` with no code changes. +- The form file paths remain the same — this is a modification, not a new form. + +## Files Touched + +| File | Change | +|------|--------| +| `OpenNest/Controls/PhaseStepperControl.cs` | New — custom-drawn phase stepper control | +| `OpenNest/Forms/NestProgressForm.cs` | Rewritten — grouped layout, stepper integration | +| `OpenNest/Forms/NestProgressForm.Designer.cs` | Rewritten — new control layout | From cab603c50d5f4705b7b5ebdbd74266c09fd7b597 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 01:54:07 -0400 Subject: [PATCH 045/116] =?UTF-8?q?docs:=20fix=20spec=20issues=20from=20re?= =?UTF-8?q?view=20=E2=80=94=20non-sequential=20stepper,=20font=20caching,?= =?UTF-8?q?=20sizing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-15-nest-progress-redesign.md | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-nest-progress-redesign.md b/docs/superpowers/specs/2026-03-15-nest-progress-redesign.md index 04bb29e..a913514 100644 --- a/docs/superpowers/specs/2026-03-15-nest-progress-redesign.md +++ b/docs/superpowers/specs/2026-03-15-nest-progress-redesign.md @@ -7,7 +7,7 @@ The current `NestProgressForm` is a flat list of label/value pairs with no visua ## Solution Redesign the form with three changes: -1. A custom-drawn **phase stepper** control showing which nesting phase is active +1. A custom-drawn **phase stepper** control showing which nesting phases have been visited 2. **Grouped sections** separating results from status information 3. **Modern styling** — Segoe UI fonts, subtle background contrast, better spacing @@ -15,27 +15,36 @@ Redesign the form with three changes: **New file: `OpenNest/Controls/PhaseStepperControl.cs`** -A custom `UserControl` that draws 4 connected circles with labels beneath: +A custom `UserControl` that draws 4 circles with labels beneath, connected by lines: ``` ●━━━━━━━●━━━━━━━○━━━━━━━○ Linear BestFit Pairs Remainder ``` +### Non-sequential design + +The engine does **not** execute phases in a fixed order. `FindBestFill` runs Pairs → Linear → BestFit → Remainder, while the group fill path runs Linear → BestFit → Pairs → Remainder. Some phases may not execute at all (e.g., multi-part fills only run Linear). + +The stepper therefore tracks **which phases have been visited**, not a left-to-right progression. Each circle independently lights up when its phase reports progress, regardless of position. The connecting lines between circles are purely decorative (always light gray) — they do not indicate sequential flow. + ### Visual States -- **Completed:** Filled circle with accent color, bold label -- **Active:** Filled circle with accent color and slightly larger radius, bold label -- **Pending:** Hollow circle with border only, dimmed label text +- **Completed/visited:** Filled circle with accent color, bold label — the phase has reported at least one progress update +- **Active:** Filled circle with accent color and slightly larger radius, bold label — the phase currently executing +- **Pending:** Hollow circle with border only, dimmed label text — the phase has not yet reported progress +- **Skipped:** Same as Pending — phases that never execute simply remain hollow. No special "skipped" visual needed. - **All complete:** All 4 circles filled (used when `ShowCompleted()` is called) +- **Initial state (before first `UpdateProgress`):** All 4 circles in Pending (hollow) state ### Implementation -- Single `OnPaint` override. Circles evenly spaced across control width. Connecting lines drawn between circle centers — completed segments use accent color, pending segments use a light gray. -- Colors defined as `static readonly Color` fields at the top of the class for easy tweaking. -- Exposes a `CurrentPhase` property (type `NestPhase?`). Setting it calls `Invalidate()`. A `null` value means no phase is active yet. An additional `bool IsComplete` property marks all phases as done. +- Single `OnPaint` override. Circles evenly spaced across control width. Connecting lines drawn between circle centers in light gray. +- Colors and fonts defined as `static readonly` fields at the top of the class. Fonts are cached (not created per paint call) to avoid GDI handle leaks during frequent progress updates. +- Tracks state via a `HashSet VisitedPhases` and a `NestPhase? ActivePhase` property. When `ActivePhase` is set, it is added to `VisitedPhases` and `Invalidate()` is called. A `bool IsComplete` property marks all phases as done. - `DoubleBuffered = true` to prevent flicker on repaint. - Fixed height (~60px), docks to fill width. +- Namespace: `OpenNest.Controls` (follows existing convention, e.g., `QuadrantSelect`). ## Form Layout @@ -80,13 +89,17 @@ Group panels use `Color.White` (or very light gray) `BackColor` against the form ### Sizing - Width: ~450px (slightly wider than current 425px for breathing room) -- Height: auto-sizes to content via `AutoSize = true` on the table panels +- Height: fixed `ClientSize` calculated to fit stepper (~60px) + results group (~110px) + status group (~90px) + button bar (~45px) + padding. The form uses `FixedToolWindow` which does not auto-resize, so the height is set explicitly in the designer. - `FormBorderStyle.FixedToolWindow`, `StartPosition.CenterParent`, `ShowInTaskbar = false` ### Plate Row Visibility The Plate row in the Status group is hidden when `showPlateRow: false` is passed to the constructor (same as current behavior). +### Phase description text + +The current form's `FormatPhase()` method produces friendly text like "Trying rotations..." which was displayed in the Phase row. Since the phase stepper replaces the Phase row visually, this descriptive text moves to the **Detail** row. `UpdateProgress` writes `FormatPhase(progress.Phase)` to the Detail value when `progress.Description` is empty, and writes `progress.Description` when it's set (the engine's per-iteration descriptions like "Linear: 3/12 angles" take precedence). + ## Public API No signature changes. The form remains a drop-in replacement. @@ -97,12 +110,16 @@ No signature changes. The form remains a drop-in replacement. ### UpdateProgress(NestProgress progress) -Same as today, plus sets `phaseStepperControl.CurrentPhase = progress.Phase` to advance the stepper visual. +Same as today, plus: +- Sets `phaseStepperControl.ActivePhase = progress.Phase` to update the stepper +- Writes `FormatPhase(progress.Phase)` to the Detail row as a fallback when `progress.Description` is empty ### ShowCompleted() Same as today (stops timer, changes button to "Close"), plus sets `phaseStepperControl.IsComplete = true` to fill all circles. +Note: `MainForm.FillArea_Click` currently calls `progressForm.Close()` without calling `ShowCompleted()` first. This is existing behavior and is fine — the form closes immediately so the "all complete" visual is not needed in that path. + ## No External Changes - `NestProgress` and `NestPhase` are unchanged. From 9a17fe97d303f033f7b866cd56b84c6ce5d9c734 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 12:40:56 -0400 Subject: [PATCH 046/116] feat(engine): add BinarySearchFill helper for exact-quantity search Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 91 +++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 14 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 840b93c..884fc62 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -58,21 +58,21 @@ namespace OpenNest AngleResults.Clear(); var best = FindBestFill(item, workArea, progress, token); - if (token.IsCancellationRequested) - return best ?? new List(); - - // Try improving by filling the remainder strip separately. - var remainderSw = Stopwatch.StartNew(); - var improved = TryRemainderImprovement(item, workArea, best); - remainderSw.Stop(); - - if (IsBetterFill(improved, best, workArea)) + if (!token.IsCancellationRequested) { - Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); - best = improved; - WinnerPhase = NestPhase.Remainder; - PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, remainderSw.ElapsedMilliseconds)); - ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary()); + // Try improving by filling the remainder strip separately. + var remainderSw = Stopwatch.StartNew(); + var improved = TryRemainderImprovement(item, workArea, best); + remainderSw.Stop(); + + if (IsBetterFill(improved, best, workArea)) + { + Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); + best = improved; + WinnerPhase = NestPhase.Remainder; + PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, remainderSw.ElapsedMilliseconds)); + ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary()); + } } if (best == null || best.Count == 0) @@ -84,6 +84,69 @@ namespace OpenNest return best; } + /// + /// Binary-searches for the smallest sub-area (one dimension fixed) that fits + /// exactly item.Quantity parts. Returns the best parts list and the dimension + /// value that achieved it. + /// + private (List parts, double usedDim) BinarySearchFill( + NestItem item, Box workArea, bool shrinkWidth, + CancellationToken token) + { + var quantity = item.Quantity; + var partBox = item.Drawing.Program.BoundingBox(); + var partArea = item.Drawing.Area; + + // Fixed and variable dimensions. + var fixedDim = shrinkWidth ? workArea.Length : workArea.Width; + var highDim = shrinkWidth ? workArea.Width : workArea.Length; + + // Estimate starting point: target area at 50% utilization. + var targetArea = partArea * quantity / 0.5; + var minPartDim = shrinkWidth + ? partBox.Width + Plate.PartSpacing + : partBox.Length + Plate.PartSpacing; + var estimatedDim = System.Math.Max(minPartDim, targetArea / fixedDim); + + var low = estimatedDim; + var high = highDim; + + List bestParts = null; + var bestDim = high; + + for (var iter = 0; iter < 8; iter++) + { + if (token.IsCancellationRequested) + break; + + if (high - low < Plate.PartSpacing) + break; + + var mid = (low + high) / 2.0; + + var testBox = shrinkWidth + ? new Box(workArea.X, workArea.Y, mid, workArea.Length) + : new Box(workArea.X, workArea.Y, workArea.Width, mid); + + var result = Fill(item, testBox, null, token); + + if (result.Count >= quantity) + { + bestParts = result.Count > quantity + ? result.Take(quantity).ToList() + : result; + bestDim = mid; + high = mid; + } + else + { + low = mid; + } + } + + return (bestParts, bestDim); + } + public bool Fill(List groupParts) { return Fill(groupParts, Plate.WorkArea()); From 28fb1a1a671661df4a99e7025cf3b3e19e540ca6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 12:41:21 -0400 Subject: [PATCH 047/116] feat(engine): add FillExact method for exact-quantity nesting Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 884fc62..f1dc1bf 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -84,6 +84,64 @@ namespace OpenNest return best; } + /// + /// Finds the smallest sub-area of workArea that fits exactly item.Quantity parts. + /// Uses binary search on both orientations and picks the tightest fit. + /// Falls through to standard Fill for unlimited (0) or single (1) quantities. + /// + public List FillExact(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + // Early exits: unlimited or single quantity — no benefit from area search. + if (item.Quantity <= 1) + return Fill(item, workArea, progress, token); + + // Full fill to establish upper bound. + var fullResult = Fill(item, workArea, progress, token); + + if (fullResult.Count <= item.Quantity) + return fullResult; + + // Binary search: try shrinking each dimension. + var (lengthParts, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token); + var (widthParts, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token); + + // Pick winner by smallest test box area. Tie-break: prefer shrink-length. + List winner; + Box winnerBox; + + var lengthArea = lengthParts != null ? workArea.Width * lengthDim : double.MaxValue; + var widthArea = widthParts != null ? widthDim * workArea.Length : double.MaxValue; + + if (lengthParts != null && lengthArea <= widthArea) + { + winner = lengthParts; + winnerBox = new Box(workArea.X, workArea.Y, workArea.Width, lengthDim); + } + else if (widthParts != null) + { + winner = widthParts; + winnerBox = new Box(workArea.X, workArea.Y, widthDim, workArea.Length); + } + else + { + // Neither search found the exact quantity — return full fill truncated. + return fullResult.Take(item.Quantity).ToList(); + } + + // Re-run the winner with progress so PhaseResults/WinnerPhase are correct + // and the progress form shows the final result. + var finalResult = Fill(item, winnerBox, progress, token); + + if (finalResult.Count >= item.Quantity) + return finalResult.Count > item.Quantity + ? finalResult.Take(item.Quantity).ToList() + : finalResult; + + // Fallback: return the binary search result if the re-run produced fewer. + return winner; + } + /// /// Binary-searches for the smallest sub-area (one dimension fixed) that fits /// exactly item.Quantity parts. Returns the best parts list and the dimension From 00ee205b44d2bf57202ff8ce6efec4c00880f85b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 12:41:29 -0400 Subject: [PATCH 048/116] feat(engine): add Compactor for post-fill gravity compaction Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/Compactor.cs | 93 ++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 OpenNest.Engine/Compactor.cs diff --git a/OpenNest.Engine/Compactor.cs b/OpenNest.Engine/Compactor.cs new file mode 100644 index 0000000..b5ff5ab --- /dev/null +++ b/OpenNest.Engine/Compactor.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// Pushes a group of parts left and down to close gaps after placement. + /// Uses the same directional-distance logic as PlateView.PushSelected + /// but operates on Part objects directly. + /// + public static class Compactor + { + private const double ChordTolerance = 0.001; + + /// + /// Compacts movingParts toward the bottom-left of the plate work area. + /// Everything already on the plate (excluding movingParts) is treated + /// as stationary obstacles. + /// + public static void Compact(List movingParts, Plate plate) + { + if (movingParts == null || movingParts.Count == 0) + return; + + Push(movingParts, plate, PushDirection.Left); + Push(movingParts, plate, PushDirection.Down); + } + + private static void Push(List movingParts, Plate plate, PushDirection direction) + { + var stationaryParts = plate.Parts + .Where(p => !movingParts.Contains(p)) + .ToList(); + + var stationaryBoxes = new Box[stationaryParts.Count]; + + for (var i = 0; i < stationaryParts.Count; i++) + stationaryBoxes[i] = stationaryParts[i].BoundingBox; + + var stationaryLines = new List[stationaryParts.Count]; + var opposite = Helper.OppositeDirection(direction); + var halfSpacing = plate.PartSpacing / 2; + var isHorizontal = Helper.IsHorizontalDirection(direction); + var workArea = plate.WorkArea(); + + foreach (var moving in movingParts) + { + var distance = double.MaxValue; + var movingBox = moving.BoundingBox; + + // Plate edge distance. + var edgeDist = Helper.EdgeDistance(movingBox, workArea, direction); + if (edgeDist > 0 && edgeDist < distance) + distance = edgeDist; + + List movingLines = null; + + for (var i = 0; i < stationaryBoxes.Length; i++) + { + var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction); + if (gap < 0 || gap >= distance) + continue; + + var perpOverlap = isHorizontal + ? movingBox.IsHorizontalTo(stationaryBoxes[i], out _) + : movingBox.IsVerticalTo(stationaryBoxes[i], out _); + + if (!perpOverlap) + continue; + + movingLines ??= halfSpacing > 0 + ? Helper.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance) + : Helper.GetPartLines(moving, direction, ChordTolerance); + + stationaryLines[i] ??= halfSpacing > 0 + ? Helper.GetOffsetPartLines(stationaryParts[i], halfSpacing, opposite, ChordTolerance) + : Helper.GetPartLines(stationaryParts[i], opposite, ChordTolerance); + + var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction); + if (d < distance) + distance = d; + } + + if (distance < double.MaxValue && distance > 0) + { + var offset = Helper.DirectionToOffset(direction, distance); + moving.Offset(offset); + } + } + } + } +} From 2bde2545f42aaed61993e1264269e87ef669e07f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 12:43:32 -0400 Subject: [PATCH 049/116] feat(ui): use FillExact + Compactor in AutoNest Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Forms/MainForm.cs | 115 +++++++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 10 deletions(-) diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 4329cd1..e970615 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -738,6 +738,15 @@ namespace OpenNest.Forms nestingCts = new CancellationTokenSource(); var token = nestingCts.Token; + var progressForm = new NestProgressForm(nestingCts, showPlateRow: true); + + var progress = new Progress(p => + { + progressForm.UpdateProgress(p); + activeForm.PlateView.SetTemporaryParts(p.BestParts); + }); + + progressForm.Show(this); SetNestingLockout(true); try @@ -761,38 +770,124 @@ namespace OpenNest.Forms if (plate != activeForm.PlateView.Plate) activeForm.LoadLastPlate(); - var parts = await Task.Run(() => - AutoNester.Nest(remaining, plate, token)); + // Split items: Fill produces great results for qty > 1, + // Pack is fast for single-quantity items. + var fillItems = remaining + .Where(i => i.Quantity > 1) + .OrderBy(i => i.Priority) + .ThenByDescending(i => i.Drawing.Area) + .ToList(); - if (parts.Count == 0) - break; + var packItems = remaining + .Where(i => i.Quantity == 1) + .ToList(); - plate.Parts.AddRange(parts); - activeForm.PlateView.Invalidate(); + var workArea = plate.WorkArea(); + var anyPlaced = false; - // Deduct placed quantities using Drawing.Name to avoid reference issues. - foreach (var item in remaining) + // Phase 1: Fill each multi-quantity drawing with NestEngine. + foreach (var item in fillItems) { - var placed = parts.Count(p => p.BaseDrawing.Name == item.Drawing.Name); - item.Quantity = System.Math.Max(0, item.Quantity - placed); + if (item.Quantity <= 0 || token.IsCancellationRequested) + continue; + + if (workArea.Width <= 0 || workArea.Length <= 0) + break; + + var engine = new NestEngine(plate) { PlateNumber = plateCount }; + + var parts = await Task.Run(() => + engine.FillExact(item, workArea, progress, token)); + + activeForm.PlateView.ClearTemporaryParts(); + + if (token.IsCancellationRequested) + break; + + if (parts.Count > 0) + { + plate.Parts.AddRange(parts); + Compactor.Compact(parts, plate); + activeForm.PlateView.Invalidate(); + anyPlaced = true; + + item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); + + // Compute remainder strip for the next drawing. + workArea = ComputeRemainderStrip(plate); + } } + + // Phase 2: Pack single-quantity items into remaining space. + packItems = packItems.Where(i => i.Quantity > 0).ToList(); + + if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0 + && !token.IsCancellationRequested) + { + var engine = new NestEngine(plate); + var partsBefore = plate.Parts.Count; + engine.PackArea(workArea, packItems); + var packed = plate.Parts.Count - partsBefore; + + if (packed > 0) + { + activeForm.PlateView.Invalidate(); + anyPlaced = true; + + // Deduct packed quantities. + foreach (var item in packItems) + { + var placed = plate.Parts.Count(p => + p.BaseDrawing.Name == item.Drawing.Name); + item.Quantity = System.Math.Max(0, + item.Quantity - placed); + } + } + } + + if (!anyPlaced) + break; } activeForm.Nest.UpdateDrawingQuantities(); + progressForm.ShowCompleted(); } catch (Exception ex) { + activeForm.PlateView.ClearTemporaryParts(); MessageBox.Show($"Nesting error: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { + progressForm.Close(); SetNestingLockout(false); nestingCts.Dispose(); nestingCts = null; } } + private static Box ComputeRemainderStrip(Plate plate) + { + if (plate.Parts.Count == 0) + return plate.WorkArea(); + + var usedBox = plate.Parts.Cast().GetBoundingBox(); + var fullArea = plate.WorkArea(); + + var hWidth = fullArea.Right - usedBox.Right - plate.PartSpacing; + var hStrip = hWidth > 0 + ? new Box(usedBox.Right + plate.PartSpacing, fullArea.Y, hWidth, fullArea.Length) + : Box.Empty; + + var vHeight = fullArea.Top - usedBox.Top - plate.PartSpacing; + var vStrip = vHeight > 0 + ? new Box(fullArea.X, usedBox.Top + plate.PartSpacing, fullArea.Width, vHeight) + : Box.Empty; + + return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; + } + private void SequenceAllPlates_Click(object sender, EventArgs e) { if (activeForm == null) From 521ada17cce1b94423b75fdcee562fd1d90c8bf1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 12:43:38 -0400 Subject: [PATCH 050/116] feat(console): use FillExact + Compactor in --autonest Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/Program.cs | 69 +++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 49ac31d..259fb09 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; using OpenNest; using OpenNest.Converters; using OpenNest.Geometry; @@ -328,9 +329,50 @@ static class NestConsole Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); - var nestParts = AutoNester.Nest(nestItems, plate); - plate.Parts.AddRange(nestParts); - success = nestParts.Count > 0; + var fillItems = nestItems + .Where(i => i.Quantity > 1) + .OrderBy(i => i.Priority) + .ThenByDescending(i => i.Drawing.Area) + .ToList(); + + var packItems = nestItems + .Where(i => i.Quantity == 1) + .ToList(); + + var workArea = plate.WorkArea(); + success = false; + + // Phase 1: Fill multi-quantity drawings with NestEngine. + foreach (var item in fillItems) + { + if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0) + continue; + + var engine = new NestEngine(plate); + var parts = engine.FillExact(item, workArea, null, CancellationToken.None); + + if (parts.Count > 0) + { + plate.Parts.AddRange(parts); + Compactor.Compact(parts, plate); + item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); + success = true; + workArea = ComputeRemainderStrip(plate); + } + } + + // Phase 2: Pack single-quantity items into remaining space. + packItems = packItems.Where(i => i.Quantity > 0).ToList(); + + if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0) + { + var engine = new NestEngine(plate); + var before = plate.Parts.Count; + engine.PackArea(workArea, packItems); + + if (plate.Parts.Count > before) + success = true; + } } else { @@ -343,6 +385,27 @@ static class NestConsole return (success, sw.ElapsedMilliseconds); } + static Box ComputeRemainderStrip(Plate plate) + { + if (plate.Parts.Count == 0) + return plate.WorkArea(); + + var usedBox = plate.Parts.Cast().GetBoundingBox(); + var fullArea = plate.WorkArea(); + + var hWidth = fullArea.Right - usedBox.Right - plate.PartSpacing; + var hStrip = hWidth > 0 + ? new Box(usedBox.Right + plate.PartSpacing, fullArea.Y, hWidth, fullArea.Length) + : Box.Empty; + + var vHeight = fullArea.Top - usedBox.Top - plate.PartSpacing; + var vStrip = vHeight > 0 + ? new Box(fullArea.X, usedBox.Top + plate.PartSpacing, fullArea.Width, vHeight) + : Box.Empty; + + return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; + } + static int CheckOverlaps(Plate plate, Options options) { if (!options.CheckOverlaps || plate.Parts.Count == 0) From 56592f909a1a7bba20bda17b98edcb29693fcb09 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 12:43:41 -0400 Subject: [PATCH 051/116] feat(mcp): use FillExact + Compactor in autonest_plate Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Mcp/Tools/NestingTools.cs | 73 +++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 99d8aef..83e2761 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -193,7 +193,7 @@ namespace OpenNest.Mcp.Tools } [McpServerTool(Name = "autonest_plate")] - [Description("NFP-based mixed-part autonesting. Places multiple different drawings on a plate with geometry-aware collision avoidance and simulated annealing optimization. Produces tighter layouts than pack_plate by allowing parts to interlock.")] + [Description("Mixed-part autonesting. Fills the plate with multiple different drawings using iterative per-drawing fills with remainder-strip packing.")] public string AutoNestPlate( [Description("Index of the plate")] int plateIndex, [Description("Comma-separated drawing names")] string drawingNames, @@ -233,20 +233,81 @@ namespace OpenNest.Mcp.Tools items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); } - var parts = AutoNester.Nest(items, plate); - plate.Parts.AddRange(parts); + var fillItems = items + .Where(i => i.Quantity > 1) + .OrderBy(i => i.Priority) + .ThenByDescending(i => i.Drawing.Area) + .ToList(); + + var packItems = items + .Where(i => i.Quantity == 1) + .ToList(); + + var workArea = plate.WorkArea(); + var totalPlaced = 0; + + // Phase 1: Fill multi-quantity drawings with NestEngine. + foreach (var item in fillItems) + { + if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0) + continue; + + var engine = new NestEngine(plate); + var parts = engine.FillExact(item, workArea, null, CancellationToken.None); + + if (parts.Count > 0) + { + plate.Parts.AddRange(parts); + Compactor.Compact(parts, plate); + item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); + totalPlaced += parts.Count; + workArea = ComputeRemainderStrip(plate); + } + } + + // Phase 2: Pack single-quantity items into remaining space. + packItems = packItems.Where(i => i.Quantity > 0).ToList(); + + if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0) + { + var before = plate.Parts.Count; + var engine = new NestEngine(plate); + engine.PackArea(workArea, packItems); + totalPlaced += plate.Parts.Count - before; + } var sb = new StringBuilder(); - sb.AppendLine($"AutoNest plate {plateIndex}: {(parts.Count > 0 ? "success" : "no parts placed")}"); - sb.AppendLine($" Parts placed: {parts.Count}"); + sb.AppendLine($"AutoNest plate {plateIndex}: {(totalPlaced > 0 ? "success" : "no parts placed")}"); + sb.AppendLine($" Parts placed: {totalPlaced}"); sb.AppendLine($" Total parts: {plate.Parts.Count}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}"); - var groups = parts.GroupBy(p => p.BaseDrawing.Name); + var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name); foreach (var group in groups) sb.AppendLine($" {group.Key}: {group.Count()}"); return sb.ToString(); } + + private static Box ComputeRemainderStrip(Plate plate) + { + if (plate.Parts.Count == 0) + return plate.WorkArea(); + + var usedBox = plate.Parts.Cast().GetBoundingBox(); + var fullArea = plate.WorkArea(); + + var hWidth = fullArea.Right - usedBox.Right - plate.PartSpacing; + var hStrip = hWidth > 0 + ? new Box(usedBox.Right + plate.PartSpacing, fullArea.Y, hWidth, fullArea.Length) + : Box.Empty; + + var vHeight = fullArea.Top - usedBox.Top - plate.PartSpacing; + var vStrip = vHeight > 0 + ? new Box(fullArea.X, usedBox.Top + plate.PartSpacing, fullArea.Width, vHeight) + : Box.Empty; + + return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; + } } } From ef12cf2966585b64babd8ecf712d794ec1e8e476 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 12:54:25 -0400 Subject: [PATCH 052/116] fix(engine): Compactor treats pushed parts as obstacles for subsequent pushes Previously each moving part only checked against the original stationary set. Parts pushed earlier in the loop were invisible to later parts, causing overlaps (utilization > 100%). Now each pushed part is added to the obstacle set so subsequent parts collide correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/Compactor.cs | 41 ++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/OpenNest.Engine/Compactor.cs b/OpenNest.Engine/Compactor.cs index b5ff5ab..3ef73eb 100644 --- a/OpenNest.Engine/Compactor.cs +++ b/OpenNest.Engine/Compactor.cs @@ -29,16 +29,20 @@ namespace OpenNest private static void Push(List movingParts, Plate plate, PushDirection direction) { - var stationaryParts = plate.Parts + // Start with parts already on the plate (excluding the moving group). + var obstacleParts = plate.Parts .Where(p => !movingParts.Contains(p)) .ToList(); - var stationaryBoxes = new Box[stationaryParts.Count]; + var obstacleBoxes = new List(obstacleParts.Count + movingParts.Count); + var obstacleLines = new List>(obstacleParts.Count + movingParts.Count); - for (var i = 0; i < stationaryParts.Count; i++) - stationaryBoxes[i] = stationaryParts[i].BoundingBox; + for (var i = 0; i < obstacleParts.Count; i++) + { + obstacleBoxes.Add(obstacleParts[i].BoundingBox); + obstacleLines.Add(null); // lazy + } - var stationaryLines = new List[stationaryParts.Count]; var opposite = Helper.OppositeDirection(direction); var halfSpacing = plate.PartSpacing / 2; var isHorizontal = Helper.IsHorizontalDirection(direction); @@ -56,15 +60,15 @@ namespace OpenNest List movingLines = null; - for (var i = 0; i < stationaryBoxes.Length; i++) + for (var i = 0; i < obstacleBoxes.Count; i++) { - var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction); + var gap = Helper.DirectionalGap(movingBox, obstacleBoxes[i], direction); if (gap < 0 || gap >= distance) continue; var perpOverlap = isHorizontal - ? movingBox.IsHorizontalTo(stationaryBoxes[i], out _) - : movingBox.IsVerticalTo(stationaryBoxes[i], out _); + ? movingBox.IsHorizontalTo(obstacleBoxes[i], out _) + : movingBox.IsVerticalTo(obstacleBoxes[i], out _); if (!perpOverlap) continue; @@ -73,11 +77,17 @@ namespace OpenNest ? Helper.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance) : Helper.GetPartLines(moving, direction, ChordTolerance); - stationaryLines[i] ??= halfSpacing > 0 - ? Helper.GetOffsetPartLines(stationaryParts[i], halfSpacing, opposite, ChordTolerance) - : Helper.GetPartLines(stationaryParts[i], opposite, ChordTolerance); + var obstaclePart = i < obstacleParts.Count ? obstacleParts[i] : null; - var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction); + obstacleLines[i] ??= obstaclePart != null + ? (halfSpacing > 0 + ? Helper.GetOffsetPartLines(obstaclePart, halfSpacing, opposite, ChordTolerance) + : Helper.GetPartLines(obstaclePart, opposite, ChordTolerance)) + : (halfSpacing > 0 + ? Helper.GetOffsetPartLines(moving, halfSpacing, opposite, ChordTolerance) + : Helper.GetPartLines(moving, opposite, ChordTolerance)); + + var d = Helper.DirectionalDistance(movingLines, obstacleLines[i], direction); if (d < distance) distance = d; } @@ -87,6 +97,11 @@ namespace OpenNest var offset = Helper.DirectionToOffset(direction, distance); moving.Offset(offset); } + + // This part is now an obstacle for subsequent moving parts. + obstacleBoxes.Add(moving.BoundingBox); + obstacleParts.Add(moving); + obstacleLines.Add(null); // will be lazily computed if needed } } } From 744062152e2885a9c164a03db8f009617c8d6fab Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 13:21:28 -0400 Subject: [PATCH 053/116] feat(engine): optimize FillExact with angle pruning and tight search range - Track productive angles across Fill calls; subsequent fills skip angles that never produced results (knownGoodAngles) - Binary search uses utilization-based range estimates (70%-25%) instead of starting from the full work area dimension - Quick bounding-box capacity check skips binary search entirely when the plate can't fit more than the requested quantity - Use full Fill (not rect-only) for binary search iterations so the search benefits from pairs/linear strategies Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 116 +++++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 38 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index f1dc1bf..4bb8792 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -33,6 +33,10 @@ namespace OpenNest public List AngleResults { get; } = new(); + // Angles that have produced results across multiple Fill calls. + // Populated after each Fill; used to prune subsequent fills. + private readonly HashSet knownGoodAngles = new(); + // --- Public Fill API --- public bool Fill(NestItem item) @@ -96,50 +100,56 @@ namespace OpenNest if (item.Quantity <= 1) return Fill(item, workArea, progress, token); - // Full fill to establish upper bound. - var fullResult = Fill(item, workArea, progress, token); + // Quick capacity check: estimate how many parts fit via bounding box. + var partBox = item.Drawing.Program.BoundingBox(); + var cols = (int)(workArea.Width / (partBox.Width + Plate.PartSpacing)); + var rows = (int)(workArea.Length / (partBox.Length + Plate.PartSpacing)); + var capacity = System.Math.Max(cols * rows, 1); - if (fullResult.Count <= item.Quantity) - return fullResult; + // Also check rotated orientation. + var colsR = (int)(workArea.Width / (partBox.Length + Plate.PartSpacing)); + var rowsR = (int)(workArea.Length / (partBox.Width + Plate.PartSpacing)); + capacity = System.Math.Max(capacity, colsR * rowsR); + + Debug.WriteLine($"[FillExact] Capacity estimate: {capacity}, target: {item.Quantity}, workArea: {workArea.Width:F1}x{workArea.Length:F1}"); + + if (capacity <= item.Quantity) + { + // Plate can't fit more than requested — do a normal fill. + return Fill(item, workArea, progress, token); + } // Binary search: try shrinking each dimension. - var (lengthParts, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token); - var (widthParts, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token); + Debug.WriteLine($"[FillExact] Starting binary search (capacity={capacity} > target={item.Quantity})"); + var (lengthFound, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token); + Debug.WriteLine($"[FillExact] Shrink-length: found={lengthFound}, dim={lengthDim:F1}"); + var (widthFound, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token); + Debug.WriteLine($"[FillExact] Shrink-width: found={widthFound}, dim={widthDim:F1}"); // Pick winner by smallest test box area. Tie-break: prefer shrink-length. - List winner; Box winnerBox; - var lengthArea = lengthParts != null ? workArea.Width * lengthDim : double.MaxValue; - var widthArea = widthParts != null ? widthDim * workArea.Length : double.MaxValue; + var lengthArea = lengthFound ? workArea.Width * lengthDim : double.MaxValue; + var widthArea = widthFound ? widthDim * workArea.Length : double.MaxValue; - if (lengthParts != null && lengthArea <= widthArea) + if (lengthFound && lengthArea <= widthArea) { - winner = lengthParts; winnerBox = new Box(workArea.X, workArea.Y, workArea.Width, lengthDim); } - else if (widthParts != null) + else if (widthFound) { - winner = widthParts; winnerBox = new Box(workArea.X, workArea.Y, widthDim, workArea.Length); } else { - // Neither search found the exact quantity — return full fill truncated. - return fullResult.Take(item.Quantity).ToList(); + // Neither search found the exact quantity — do a normal fill. + return Fill(item, workArea, progress, token); } - // Re-run the winner with progress so PhaseResults/WinnerPhase are correct - // and the progress form shows the final result. - var finalResult = Fill(item, winnerBox, progress, token); + Debug.WriteLine($"[FillExact] Winner box: {winnerBox.Width:F1}x{winnerBox.Length:F1}"); - if (finalResult.Count >= item.Quantity) - return finalResult.Count > item.Quantity - ? finalResult.Take(item.Quantity).ToList() - : finalResult; - - // Fallback: return the binary search result if the re-run produced fewer. - return winner; + // Run the full Fill on the winning box with progress. + return Fill(item, winnerBox, progress, token); } /// @@ -147,7 +157,7 @@ namespace OpenNest /// exactly item.Quantity parts. Returns the best parts list and the dimension /// value that achieved it. /// - private (List parts, double usedDim) BinarySearchFill( + private (bool found, double usedDim) BinarySearchFill( NestItem item, Box workArea, bool shrinkWidth, CancellationToken token) { @@ -159,18 +169,23 @@ namespace OpenNest var fixedDim = shrinkWidth ? workArea.Length : workArea.Width; var highDim = shrinkWidth ? workArea.Width : workArea.Length; - // Estimate starting point: target area at 50% utilization. - var targetArea = partArea * quantity / 0.5; + // Estimate search range from part area and utilization assumptions. var minPartDim = shrinkWidth ? partBox.Width + Plate.PartSpacing : partBox.Length + Plate.PartSpacing; - var estimatedDim = System.Math.Max(minPartDim, targetArea / fixedDim); - var low = estimatedDim; - var high = highDim; + // Low: tight estimate at 70% utilization. + var lowEstimate = System.Math.Max(minPartDim, partArea * quantity / (0.7 * fixedDim)); + // High: generous estimate at 25% utilization, capped to work area. + var highEstimate = System.Math.Min(highDim, partArea * quantity / (0.25 * fixedDim)); + // Ensure high is at least low. + highEstimate = System.Math.Max(highEstimate, lowEstimate + Plate.PartSpacing); - List bestParts = null; - var bestDim = high; + var low = lowEstimate; + var high = highEstimate; + + var found = false; + var bestDim = highEstimate; for (var iter = 0; iter < 8; iter++) { @@ -186,14 +201,15 @@ namespace OpenNest ? new Box(workArea.X, workArea.Y, mid, workArea.Length) : new Box(workArea.X, workArea.Y, workArea.Width, mid); - var result = Fill(item, testBox, null, token); + // Fill with unlimited qty to get the true count for this box size. + // After the first iteration, angle pruning kicks in and makes this fast. + var searchItem = new NestItem { Drawing = item.Drawing, Quantity = 0 }; + var result = Fill(searchItem, testBox, null, token); if (result.Count >= quantity) { - bestParts = result.Count > quantity - ? result.Take(quantity).ToList() - : result; bestDim = mid; + found = true; high = mid; } else @@ -202,7 +218,7 @@ namespace OpenNest } } - return (bestParts, bestDim); + return (found, bestDim); } public bool Fill(List groupParts) @@ -381,6 +397,13 @@ namespace OpenNest linearSw.Stop(); PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds)); + // Record productive angles for future fills. + foreach (var ar in AngleResults) + { + if (ar.PartCount > 0) + knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg)); + } + Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary()); @@ -461,6 +484,23 @@ namespace OpenNest } } + // If we have known-good angles from previous fills, use only those + // plus the defaults (bestRotation + 90°). This prunes the expensive + // angle sweep after the first fill. + if (knownGoodAngles.Count > 0 && !ForceFullAngleSweep) + { + var pruned = new List { bestRotation, bestRotation + Angle.HalfPI }; + + foreach (var a in knownGoodAngles) + { + if (!pruned.Any(existing => existing.IsEqualTo(a))) + pruned.Add(a); + } + + Debug.WriteLine($"[BuildCandidateAngles] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)"); + return pruned; + } + return angles; } From 2af02096e0b864968cb6f5a839d0eb7ce723d36e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 13:30:39 -0400 Subject: [PATCH 054/116] fix(engine): pass progress through FillExact binary search iterations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binary search was passing null for progress, so the NestProgressForm showed all dashes during the entire search (potentially minutes). Now each iteration reports progress — the user sees phases, part counts, and density updating as the search runs. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 4bb8792..67a36a0 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -121,9 +121,9 @@ namespace OpenNest // Binary search: try shrinking each dimension. Debug.WriteLine($"[FillExact] Starting binary search (capacity={capacity} > target={item.Quantity})"); - var (lengthFound, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token); + var (lengthFound, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, progress, token); Debug.WriteLine($"[FillExact] Shrink-length: found={lengthFound}, dim={lengthDim:F1}"); - var (widthFound, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token); + var (widthFound, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, progress, token); Debug.WriteLine($"[FillExact] Shrink-width: found={widthFound}, dim={widthDim:F1}"); // Pick winner by smallest test box area. Tie-break: prefer shrink-length. @@ -159,7 +159,7 @@ namespace OpenNest /// private (bool found, double usedDim) BinarySearchFill( NestItem item, Box workArea, bool shrinkWidth, - CancellationToken token) + IProgress progress, CancellationToken token) { var quantity = item.Quantity; var partBox = item.Drawing.Program.BoundingBox(); @@ -204,7 +204,7 @@ namespace OpenNest // Fill with unlimited qty to get the true count for this box size. // After the first iteration, angle pruning kicks in and makes this fast. var searchItem = new NestItem { Drawing = item.Drawing, Quantity = 0 }; - var result = Fill(searchItem, testBox, null, token); + var result = Fill(searchItem, testBox, progress, token); if (result.Count >= quantity) { From 2042c7d3f2cfaba01ec2aacfad362c9572f3c811 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 13:35:30 -0400 Subject: [PATCH 055/116] perf(engine): cap strip-mode pair candidates at 100 (sorted by utilization) Strip mode was adding thousands of candidates (7600+) when the work area was narrow. Now caps at 100 total, sorted by utilization descending so the best candidates are tried first. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngine.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 67a36a0..79d8aab 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -594,12 +594,16 @@ namespace OpenNest { var stripCandidates = bestFits .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon - && r.Utilization >= 0.3); + && r.Utilization >= 0.3) + .OrderByDescending(r => r.Utilization); var existing = new HashSet(top); foreach (var r in stripCandidates) { + if (top.Count >= 100) + break; + if (existing.Add(r)) top.Add(r); } From 4525be302cb425e636ed3bb8fe11a8bb9e0f38e6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 13:43:33 -0400 Subject: [PATCH 056/116] fix(engine): compute remainder from just-placed parts within current work area ComputeRemainderStrip used the bounding box of ALL plate parts against the full plate, missing large interior gaps between drawing groups. Now computes remainder within the current work area based on only the parts that were just placed. This lets subsequent drawings fill the gap between previous drawing groups instead of being forced into a tiny strip at the plate edge. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/Program.cs | 21 ++++++++------------- OpenNest.Mcp/Tools/NestingTools.cs | 21 ++++++++------------- OpenNest/Forms/MainForm.cs | 24 ++++++++++-------------- 3 files changed, 26 insertions(+), 40 deletions(-) diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 259fb09..9709889 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -354,10 +354,11 @@ static class NestConsole if (parts.Count > 0) { plate.Parts.AddRange(parts); - Compactor.Compact(parts, plate); + // TODO: Compactor.Compact(parts, plate); item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); success = true; - workArea = ComputeRemainderStrip(plate); + var placedBox = parts.Cast().GetBoundingBox(); + workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); } } @@ -385,22 +386,16 @@ static class NestConsole return (success, sw.ElapsedMilliseconds); } - static Box ComputeRemainderStrip(Plate plate) + static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) { - if (plate.Parts.Count == 0) - return plate.WorkArea(); - - var usedBox = plate.Parts.Cast().GetBoundingBox(); - var fullArea = plate.WorkArea(); - - var hWidth = fullArea.Right - usedBox.Right - plate.PartSpacing; + var hWidth = workArea.Right - usedBox.Right - spacing; var hStrip = hWidth > 0 - ? new Box(usedBox.Right + plate.PartSpacing, fullArea.Y, hWidth, fullArea.Length) + ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) : Box.Empty; - var vHeight = fullArea.Top - usedBox.Top - plate.PartSpacing; + var vHeight = workArea.Top - usedBox.Top - spacing; var vStrip = vHeight > 0 - ? new Box(fullArea.X, usedBox.Top + plate.PartSpacing, fullArea.Width, vHeight) + ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) : Box.Empty; return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 83e2761..8bd5e00 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -258,10 +258,11 @@ namespace OpenNest.Mcp.Tools if (parts.Count > 0) { plate.Parts.AddRange(parts); - Compactor.Compact(parts, plate); + // TODO: Compactor.Compact(parts, plate); item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); totalPlaced += parts.Count; - workArea = ComputeRemainderStrip(plate); + var placedBox = parts.Cast().GetBoundingBox(); + workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); } } @@ -289,22 +290,16 @@ namespace OpenNest.Mcp.Tools return sb.ToString(); } - private static Box ComputeRemainderStrip(Plate plate) + private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) { - if (plate.Parts.Count == 0) - return plate.WorkArea(); - - var usedBox = plate.Parts.Cast().GetBoundingBox(); - var fullArea = plate.WorkArea(); - - var hWidth = fullArea.Right - usedBox.Right - plate.PartSpacing; + var hWidth = workArea.Right - usedBox.Right - spacing; var hStrip = hWidth > 0 - ? new Box(usedBox.Right + plate.PartSpacing, fullArea.Y, hWidth, fullArea.Length) + ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) : Box.Empty; - var vHeight = fullArea.Top - usedBox.Top - plate.PartSpacing; + var vHeight = workArea.Top - usedBox.Top - spacing; var vStrip = vHeight > 0 - ? new Box(fullArea.X, usedBox.Top + plate.PartSpacing, fullArea.Width, vHeight) + ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) : Box.Empty; return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index e970615..a90a5e7 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -807,14 +807,16 @@ namespace OpenNest.Forms if (parts.Count > 0) { plate.Parts.AddRange(parts); - Compactor.Compact(parts, plate); + // TODO: Compactor.Compact(parts, plate); activeForm.PlateView.Invalidate(); anyPlaced = true; item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); - // Compute remainder strip for the next drawing. - workArea = ComputeRemainderStrip(plate); + // Compute remainder within the current work area based on + // what was just placed — not the full plate bounding box. + var placedBox = parts.Cast().GetBoundingBox(); + workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); } } @@ -867,22 +869,16 @@ namespace OpenNest.Forms } } - private static Box ComputeRemainderStrip(Plate plate) + private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) { - if (plate.Parts.Count == 0) - return plate.WorkArea(); - - var usedBox = plate.Parts.Cast().GetBoundingBox(); - var fullArea = plate.WorkArea(); - - var hWidth = fullArea.Right - usedBox.Right - plate.PartSpacing; + var hWidth = workArea.Right - usedBox.Right - spacing; var hStrip = hWidth > 0 - ? new Box(usedBox.Right + plate.PartSpacing, fullArea.Y, hWidth, fullArea.Length) + ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) : Box.Empty; - var vHeight = fullArea.Top - usedBox.Top - plate.PartSpacing; + var vHeight = workArea.Top - usedBox.Top - spacing; var vStrip = vHeight > 0 - ? new Box(fullArea.X, usedBox.Top + plate.PartSpacing, fullArea.Width, vHeight) + ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) : Box.Empty; return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; From 7508fbf715e7dc2d65aa04c7a5c2bf292c098ccc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 14:16:35 -0400 Subject: [PATCH 057/116] refactor(engine): delegate PlateView.PushSelected to Compactor and add iterative compaction PushSelected now calls Compactor.Push instead of duplicating the push logic. Compactor.Push moves parts as a group (single min distance) to preserve grid layouts. Compact tries both left-first and down-first orderings, iterating up to 20 times until movement drops below threshold, and keeps whichever ordering traveled further. Also includes a cancellation check in FillWithProgress to avoid accepting parts after the user stops a nest. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/Compactor.cs | 107 ++++++++++++++++++++++----------- OpenNest/Controls/PlateView.cs | 64 ++------------------ 2 files changed, 75 insertions(+), 96 deletions(-) diff --git a/OpenNest.Engine/Compactor.cs b/OpenNest.Engine/Compactor.cs index 3ef73eb..4a4f781 100644 --- a/OpenNest.Engine/Compactor.cs +++ b/OpenNest.Engine/Compactor.cs @@ -18,49 +18,91 @@ namespace OpenNest /// Everything already on the plate (excluding movingParts) is treated /// as stationary obstacles. /// + private const double RepeatThreshold = 0.01; + private const int MaxIterations = 20; + public static void Compact(List movingParts, Plate plate) { if (movingParts == null || movingParts.Count == 0) return; - Push(movingParts, plate, PushDirection.Left); - Push(movingParts, plate, PushDirection.Down); + var savedPositions = SavePositions(movingParts); + + // Try left-first. + var leftFirst = CompactLoop(movingParts, plate, PushDirection.Left, PushDirection.Down); + + // Restore and try down-first. + RestorePositions(movingParts, savedPositions); + var downFirst = CompactLoop(movingParts, plate, PushDirection.Down, PushDirection.Left); + + // Keep left-first if it traveled further. + if (leftFirst > downFirst) + { + RestorePositions(movingParts, savedPositions); + CompactLoop(movingParts, plate, PushDirection.Left, PushDirection.Down); + } } - private static void Push(List movingParts, Plate plate, PushDirection direction) + private static double CompactLoop(List parts, Plate plate, + PushDirection first, PushDirection second) + { + var total = 0.0; + + for (var i = 0; i < MaxIterations; i++) + { + var a = Push(parts, plate, first); + var b = Push(parts, plate, second); + total += a + b; + + if (a <= RepeatThreshold && b <= RepeatThreshold) + break; + } + + return total; + } + + private static Vector[] SavePositions(List parts) + { + var positions = new Vector[parts.Count]; + for (var i = 0; i < parts.Count; i++) + positions[i] = parts[i].Location; + return positions; + } + + private static void RestorePositions(List parts, Vector[] positions) + { + for (var i = 0; i < parts.Count; i++) + parts[i].Location = positions[i]; + } + + public static double Push(List movingParts, Plate plate, PushDirection direction) { - // Start with parts already on the plate (excluding the moving group). var obstacleParts = plate.Parts .Where(p => !movingParts.Contains(p)) .ToList(); - var obstacleBoxes = new List(obstacleParts.Count + movingParts.Count); - var obstacleLines = new List>(obstacleParts.Count + movingParts.Count); + var obstacleBoxes = new Box[obstacleParts.Count]; + var obstacleLines = new List[obstacleParts.Count]; for (var i = 0; i < obstacleParts.Count; i++) - { - obstacleBoxes.Add(obstacleParts[i].BoundingBox); - obstacleLines.Add(null); // lazy - } + obstacleBoxes[i] = obstacleParts[i].BoundingBox; var opposite = Helper.OppositeDirection(direction); var halfSpacing = plate.PartSpacing / 2; var isHorizontal = Helper.IsHorizontalDirection(direction); var workArea = plate.WorkArea(); + var distance = double.MaxValue; foreach (var moving in movingParts) { - var distance = double.MaxValue; - var movingBox = moving.BoundingBox; - - // Plate edge distance. - var edgeDist = Helper.EdgeDistance(movingBox, workArea, direction); + var edgeDist = Helper.EdgeDistance(moving.BoundingBox, workArea, direction); if (edgeDist > 0 && edgeDist < distance) distance = edgeDist; + var movingBox = moving.BoundingBox; List movingLines = null; - for (var i = 0; i < obstacleBoxes.Count; i++) + for (var i = 0; i < obstacleBoxes.Length; i++) { var gap = Helper.DirectionalGap(movingBox, obstacleBoxes[i], direction); if (gap < 0 || gap >= distance) @@ -77,32 +119,25 @@ namespace OpenNest ? Helper.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance) : Helper.GetPartLines(moving, direction, ChordTolerance); - var obstaclePart = i < obstacleParts.Count ? obstacleParts[i] : null; - - obstacleLines[i] ??= obstaclePart != null - ? (halfSpacing > 0 - ? Helper.GetOffsetPartLines(obstaclePart, halfSpacing, opposite, ChordTolerance) - : Helper.GetPartLines(obstaclePart, opposite, ChordTolerance)) - : (halfSpacing > 0 - ? Helper.GetOffsetPartLines(moving, halfSpacing, opposite, ChordTolerance) - : Helper.GetPartLines(moving, opposite, ChordTolerance)); + obstacleLines[i] ??= halfSpacing > 0 + ? Helper.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance) + : Helper.GetPartLines(obstacleParts[i], opposite, ChordTolerance); var d = Helper.DirectionalDistance(movingLines, obstacleLines[i], direction); if (d < distance) distance = d; } - - if (distance < double.MaxValue && distance > 0) - { - var offset = Helper.DirectionToOffset(direction, distance); - moving.Offset(offset); - } - - // This part is now an obstacle for subsequent moving parts. - obstacleBoxes.Add(moving.BoundingBox); - obstacleParts.Add(moving); - obstacleLines.Add(null); // will be lazily computed if needed } + + if (distance < double.MaxValue && distance > 0) + { + var offset = Helper.DirectionToOffset(direction, distance); + foreach (var moving in movingParts) + moving.Offset(offset); + return distance; + } + + return 0; } } } diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index 051d82c..fce56be 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -837,7 +837,7 @@ namespace OpenNest.Controls var parts = await Task.Run(() => engine.Fill(groupParts, workArea, progress, cts.Token)); - if (parts.Count > 0) + if (parts.Count > 0 && !cts.IsCancellationRequested) { AcceptTemporaryParts(); sw.Stop(); @@ -937,65 +937,9 @@ namespace OpenNest.Controls public void PushSelected(PushDirection direction) { - var stationaryParts = parts.Where(p => !p.IsSelected && !SelectedParts.Contains(p)).ToList(); - var stationaryBoxes = new Box[stationaryParts.Count]; - var stationaryLines = new List[stationaryParts.Count]; - - var opposite = Helper.OppositeDirection(direction); - var halfSpacing = Plate.PartSpacing / 2; - var isHorizontal = Helper.IsHorizontalDirection(direction); - - for (var i = 0; i < stationaryParts.Count; i++) - stationaryBoxes[i] = stationaryParts[i].BoundingBox; - - var workArea = Plate.WorkArea(); - var distance = double.MaxValue; - - foreach (var selected in SelectedParts) - { - // Check plate edge first to tighten the upper bound. - var edgeDist = Helper.EdgeDistance(selected.BoundingBox, workArea, direction); - if (edgeDist > 0 && edgeDist < distance) - distance = edgeDist; - - var movingBox = selected.BoundingBox; - List movingLines = null; - - for (var i = 0; i < stationaryBoxes.Length; i++) - { - // Skip parts not ahead in the push direction or further than current best. - var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction); - if (gap < 0 || gap >= distance) - continue; - - var perpOverlap = isHorizontal - ? movingBox.IsHorizontalTo(stationaryBoxes[i], out _) - : movingBox.IsVerticalTo(stationaryBoxes[i], out _); - - if (!perpOverlap) - continue; - - // Compute lines lazily — only for parts that survive bounding box checks. - movingLines ??= halfSpacing > 0 - ? Helper.GetOffsetPartLines(selected.BasePart, halfSpacing, direction, OffsetTolerance) - : Helper.GetPartLines(selected.BasePart, direction, OffsetTolerance); - - stationaryLines[i] ??= halfSpacing > 0 - ? Helper.GetOffsetPartLines(stationaryParts[i].BasePart, halfSpacing, opposite, OffsetTolerance) - : Helper.GetPartLines(stationaryParts[i].BasePart, opposite, OffsetTolerance); - - var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction); - if (d < distance) - distance = d; - } - } - - if (distance < double.MaxValue && distance > 0) - { - var offset = Helper.DirectionToOffset(direction, distance); - SelectedParts.ForEach(p => p.Offset(offset)); - Invalidate(); - } + var movingParts = SelectedParts.Select(p => p.BasePart).ToList(); + Compactor.Push(movingParts, Plate, direction); + Invalidate(); } private string GetDisplayName(Type type) From 289a2044a66e5ce422c7be75c653cb96c421d92d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 14:20:18 -0400 Subject: [PATCH 058/116] fix(ui): mark LayoutParts dirty after PushSelected so paths rebuild Moving BasePart locations via Compactor.Push bypassed LayoutPart.Offset which sets IsDirty. Without it, graphics paths were stale until a zoom triggered a full rebuild. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Controls/PlateView.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index fce56be..5db1be3 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -939,6 +939,7 @@ namespace OpenNest.Controls { var movingParts = SelectedParts.Select(p => p.BasePart).ToList(); Compactor.Push(movingParts, Plate, direction); + SelectedParts.ForEach(p => p.IsDirty = true); Invalidate(); } From 823320e9828a285ee2d2ef551c6ef20e0b46de5a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 17:29:29 -0400 Subject: [PATCH 059/116] fix(engine): use reverse-gap check in Compactor to handle irregular shapes The forward bounding-box gap check (gap < 0) incorrectly skipped obstacles for irregular shapes like SULLYS-003 whose narrow handle extends past an adjacent part's BB edge while the wide body still needs contact detection. Replaced with a reverse-direction gap check that only skips obstacles the moving part has entirely cleared. Also fixed edge distance check to prevent overshooting the work area boundary when already at the limit. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/Compactor.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/OpenNest.Engine/Compactor.cs b/OpenNest.Engine/Compactor.cs index 4a4f781..89f4018 100644 --- a/OpenNest.Engine/Compactor.cs +++ b/OpenNest.Engine/Compactor.cs @@ -93,10 +93,15 @@ namespace OpenNest var workArea = plate.WorkArea(); var distance = double.MaxValue; + // BB gap at which offset geometries are expected to be touching. + var contactGap = (halfSpacing + ChordTolerance) * 2; + foreach (var moving in movingParts) { var edgeDist = Helper.EdgeDistance(moving.BoundingBox, workArea, direction); - if (edgeDist > 0 && edgeDist < distance) + if (edgeDist <= 0) + distance = 0; + else if (edgeDist < distance) distance = edgeDist; var movingBox = moving.BoundingBox; @@ -104,8 +109,16 @@ namespace OpenNest for (var i = 0; i < obstacleBoxes.Length; i++) { + // Use the reverse-direction gap to check if the obstacle is entirely + // behind the moving part. The forward gap (gap < 0) is unreliable for + // irregular shapes whose bounding boxes overlap even when the actual + // geometry still has a valid contact in the push direction. + var reverseGap = Helper.DirectionalGap(movingBox, obstacleBoxes[i], opposite); + if (reverseGap > 0) + continue; + var gap = Helper.DirectionalGap(movingBox, obstacleBoxes[i], direction); - if (gap < 0 || gap >= distance) + if (gap >= distance) continue; var perpOverlap = isHorizontal From 09cdb98dfceb97f5810a76e17c5bc12d1c7e7a87 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 17:38:23 -0400 Subject: [PATCH 060/116] refactor: extract Rounding from Helper to OpenNest.Math Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Helper.cs | 33 ---------------------------- OpenNest.Core/Math/Rounding.cs | 40 ++++++++++++++++++++++++++++++++++ OpenNest.Core/Plate.cs | 4 ++-- 3 files changed, 42 insertions(+), 35 deletions(-) create mode 100644 OpenNest.Core/Math/Rounding.cs diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index 44aa015..60c3ed6 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -11,39 +11,6 @@ namespace OpenNest { public static class Helper { - /// - /// Rounds a number down to the nearest factor. - /// - /// - /// - /// - public static double RoundDownToNearest(double num, double factor) - { - return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor; - } - - /// - /// Rounds a number up to the nearest factor. - /// - /// - /// - /// - public static double RoundUpToNearest(double num, double factor) - { - return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor; - } - - /// - /// Rounds a number to the nearest factor using midpoint rounding convention. - /// - /// - /// - /// - public static double RoundToNearest(double num, double factor) - { - return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor; - } - public static void Optimize(IList arcs) { for (int i = 0; i < arcs.Count; ++i) diff --git a/OpenNest.Core/Math/Rounding.cs b/OpenNest.Core/Math/Rounding.cs new file mode 100644 index 0000000..28892b2 --- /dev/null +++ b/OpenNest.Core/Math/Rounding.cs @@ -0,0 +1,40 @@ +using OpenNest.Math; + +namespace OpenNest.Math +{ + public static class Rounding + { + /// + /// Rounds a number down to the nearest factor. + /// + /// + /// + /// + public static double RoundDownToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor; + } + + /// + /// Rounds a number up to the nearest factor. + /// + /// + /// + /// + public static double RoundUpToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor; + } + + /// + /// Rounds a number to the nearest factor using midpoint rounding convention. + /// + /// + /// + /// + public static double RoundToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor; + } + } +} diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 4bc49b7..5005529 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -412,8 +412,8 @@ namespace OpenNest } Size = new Size( - Helper.RoundUpToNearest(width, roundingFactor), - Helper.RoundUpToNearest(length, roundingFactor)); + Rounding.RoundUpToNearest(width, roundingFactor), + Rounding.RoundUpToNearest(length, roundingFactor)); } /// From be318bc1c1f6c680d5bc20ccb51e6ff6f6dddd93 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 17:39:52 -0400 Subject: [PATCH 061/116] refactor: extract GeometryOptimizer from Helper Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/GeometryOptimizer.cs | 202 ++++++++++++++++++++ OpenNest.Core/Geometry/Shape.cs | 4 +- OpenNest.Core/Helper.cs | 192 ------------------- OpenNest.IO/DxfImporter.cs | 4 +- 4 files changed, 206 insertions(+), 196 deletions(-) create mode 100644 OpenNest.Core/Geometry/GeometryOptimizer.cs diff --git a/OpenNest.Core/Geometry/GeometryOptimizer.cs b/OpenNest.Core/Geometry/GeometryOptimizer.cs new file mode 100644 index 0000000..db9eedd --- /dev/null +++ b/OpenNest.Core/Geometry/GeometryOptimizer.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using OpenNest.Math; + +namespace OpenNest.Geometry +{ + public static class GeometryOptimizer + { + public static void Optimize(IList arcs) + { + for (int i = 0; i < arcs.Count; ++i) + { + var arc = arcs[i]; + + var coradialArcs = arcs.GetCoradialArs(arc, i); + int index = 0; + + while (index < coradialArcs.Count) + { + Arc arc2 = coradialArcs[index]; + Arc joinArc; + + if (!TryJoinArcs(arc, arc2, out joinArc)) + { + index++; + continue; + } + + coradialArcs.Remove(arc2); + arcs.Remove(arc2); + + arc = joinArc; + index = 0; + } + + arcs[i] = arc; + } + } + + public static void Optimize(IList lines) + { + for (int i = 0; i < lines.Count; ++i) + { + var line = lines[i]; + + var collinearLines = lines.GetCollinearLines(line, i); + var index = 0; + + while (index < collinearLines.Count) + { + Line line2 = collinearLines[index]; + Line joinLine; + + if (!TryJoinLines(line, line2, out joinLine)) + { + index++; + continue; + } + + collinearLines.Remove(line2); + lines.Remove(line2); + + line = joinLine; + index = 0; + } + + lines[i] = line; + } + } + + public static bool TryJoinLines(Line line1, Line line2, out Line lineOut) + { + lineOut = null; + + if (line1 == line2) + return false; + + if (!line1.IsCollinearTo(line2)) + return false; + + bool onPoint = false; + + if (line1.StartPoint == line2.StartPoint) + onPoint = true; + else if (line1.StartPoint == line2.EndPoint) + onPoint = true; + else if (line1.EndPoint == line2.StartPoint) + onPoint = true; + else if (line1.EndPoint == line2.EndPoint) + onPoint = true; + + var t1 = line1.StartPoint.Y > line1.EndPoint.Y ? line1.StartPoint.Y : line1.EndPoint.Y; + var t2 = line2.StartPoint.Y > line2.EndPoint.Y ? line2.StartPoint.Y : line2.EndPoint.Y; + var b1 = line1.StartPoint.Y < line1.EndPoint.Y ? line1.StartPoint.Y : line1.EndPoint.Y; + var b2 = line2.StartPoint.Y < line2.EndPoint.Y ? line2.StartPoint.Y : line2.EndPoint.Y; + var l1 = line1.StartPoint.X < line1.EndPoint.X ? line1.StartPoint.X : line1.EndPoint.X; + var l2 = line2.StartPoint.X < line2.EndPoint.X ? line2.StartPoint.X : line2.EndPoint.X; + var r1 = line1.StartPoint.X > line1.EndPoint.X ? line1.StartPoint.X : line1.EndPoint.X; + var r2 = line2.StartPoint.X > line2.EndPoint.X ? line2.StartPoint.X : line2.EndPoint.X; + + if (!onPoint) + { + if (t1 < b2 - Tolerance.Epsilon) return false; + if (b1 > t2 + Tolerance.Epsilon) return false; + if (l1 > r2 + Tolerance.Epsilon) return false; + if (r1 < l2 - Tolerance.Epsilon) return false; + } + + var l = l1 < l2 ? l1 : l2; + var r = r1 > r2 ? r1 : r2; + var t = t1 > t2 ? t1 : t2; + var b = b1 < b2 ? b1 : b2; + + if (!line1.IsVertical() && line1.Slope() < 0) + lineOut = new Line(new Vector(l, t), new Vector(r, b)); + else + lineOut = new Line(new Vector(l, b), new Vector(r, t)); + + return true; + } + + public static bool TryJoinArcs(Arc arc1, Arc arc2, out Arc arcOut) + { + arcOut = null; + + if (arc1 == arc2) + return false; + + if (arc1.Center != arc2.Center) + return false; + + if (!arc1.Radius.IsEqualTo(arc2.Radius)) + return false; + + if (arc1.StartAngle > arc1.EndAngle) + arc1.StartAngle -= Angle.TwoPI; + + if (arc2.StartAngle > arc2.EndAngle) + arc2.StartAngle -= Angle.TwoPI; + + if (arc1.EndAngle < arc2.StartAngle || arc1.StartAngle > arc2.EndAngle) + return false; + + var startAngle = arc1.StartAngle < arc2.StartAngle ? arc1.StartAngle : arc2.StartAngle; + var endAngle = arc1.EndAngle > arc2.EndAngle ? arc1.EndAngle : arc2.EndAngle; + + if (startAngle < 0) startAngle += Angle.TwoPI; + if (endAngle < 0) endAngle += Angle.TwoPI; + + arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle); + + return true; + } + + private static List GetCollinearLines(this IList lines, Line line, int startIndex) + { + var collinearLines = new List(); + + Parallel.For(startIndex, lines.Count, index => + { + var compareLine = lines[index]; + + if (Object.ReferenceEquals(line, compareLine)) + return; + + if (!line.IsCollinearTo(compareLine)) + return; + + lock (collinearLines) + { + collinearLines.Add(compareLine); + } + }); + + return collinearLines; + } + + private static List GetCoradialArs(this IList arcs, Arc arc, int startIndex) + { + var coradialArcs = new List(); + + Parallel.For(startIndex, arcs.Count, index => + { + var compareArc = arcs[index]; + + if (Object.ReferenceEquals(arc, compareArc)) + return; + + if (!arc.IsCoradialTo(compareArc)) + return; + + lock (coradialArcs) + { + coradialArcs.Add(compareArc); + } + }); + + return coradialArcs; + } + } +} diff --git a/OpenNest.Core/Geometry/Shape.cs b/OpenNest.Core/Geometry/Shape.cs index d65d393..9f11945 100644 --- a/OpenNest.Core/Geometry/Shape.cs +++ b/OpenNest.Core/Geometry/Shape.cs @@ -159,8 +159,8 @@ namespace OpenNest.Geometry } } - Helper.Optimize(lines); - Helper.Optimize(arcs); + GeometryOptimizer.Optimize(lines); + GeometryOptimizer.Optimize(arcs); } /// diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index 60c3ed6..101de75 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -11,198 +11,6 @@ namespace OpenNest { public static class Helper { - public static void Optimize(IList arcs) - { - for (int i = 0; i < arcs.Count; ++i) - { - var arc = arcs[i]; - - var coradialArcs = arcs.GetCoradialArs(arc, i); - int index = 0; - - while (index < coradialArcs.Count) - { - Arc arc2 = coradialArcs[index]; - Arc joinArc; - - if (!TryJoinArcs(arc, arc2, out joinArc)) - { - index++; - continue; - } - - coradialArcs.Remove(arc2); - arcs.Remove(arc2); - - arc = joinArc; - index = 0; - } - - arcs[i] = arc; - } - } - - public static void Optimize(IList lines) - { - for (int i = 0; i < lines.Count; ++i) - { - var line = lines[i]; - - var collinearLines = lines.GetCollinearLines(line, i); - var index = 0; - - while (index < collinearLines.Count) - { - Line line2 = collinearLines[index]; - Line joinLine; - - if (!TryJoinLines(line, line2, out joinLine)) - { - index++; - continue; - } - - collinearLines.Remove(line2); - lines.Remove(line2); - - line = joinLine; - index = 0; - } - - lines[i] = line; - } - } - - public static bool TryJoinLines(Line line1, Line line2, out Line lineOut) - { - lineOut = null; - - if (line1 == line2) - return false; - - if (!line1.IsCollinearTo(line2)) - return false; - - bool onPoint = false; - - if (line1.StartPoint == line2.StartPoint) - onPoint = true; - else if (line1.StartPoint == line2.EndPoint) - onPoint = true; - else if (line1.EndPoint == line2.StartPoint) - onPoint = true; - else if (line1.EndPoint == line2.EndPoint) - onPoint = true; - - var t1 = line1.StartPoint.Y > line1.EndPoint.Y ? line1.StartPoint.Y : line1.EndPoint.Y; - var t2 = line2.StartPoint.Y > line2.EndPoint.Y ? line2.StartPoint.Y : line2.EndPoint.Y; - var b1 = line1.StartPoint.Y < line1.EndPoint.Y ? line1.StartPoint.Y : line1.EndPoint.Y; - var b2 = line2.StartPoint.Y < line2.EndPoint.Y ? line2.StartPoint.Y : line2.EndPoint.Y; - var l1 = line1.StartPoint.X < line1.EndPoint.X ? line1.StartPoint.X : line1.EndPoint.X; - var l2 = line2.StartPoint.X < line2.EndPoint.X ? line2.StartPoint.X : line2.EndPoint.X; - var r1 = line1.StartPoint.X > line1.EndPoint.X ? line1.StartPoint.X : line1.EndPoint.X; - var r2 = line2.StartPoint.X > line2.EndPoint.X ? line2.StartPoint.X : line2.EndPoint.X; - - if (!onPoint) - { - if (t1 < b2 - Tolerance.Epsilon) return false; - if (b1 > t2 + Tolerance.Epsilon) return false; - if (l1 > r2 + Tolerance.Epsilon) return false; - if (r1 < l2 - Tolerance.Epsilon) return false; - } - - var l = l1 < l2 ? l1 : l2; - var r = r1 > r2 ? r1 : r2; - var t = t1 > t2 ? t1 : t2; - var b = b1 < b2 ? b1 : b2; - - if (!line1.IsVertical() && line1.Slope() < 0) - lineOut = new Line(new Vector(l, t), new Vector(r, b)); - else - lineOut = new Line(new Vector(l, b), new Vector(r, t)); - - return true; - } - - public static bool TryJoinArcs(Arc arc1, Arc arc2, out Arc arcOut) - { - arcOut = null; - - if (arc1 == arc2) - return false; - - if (arc1.Center != arc2.Center) - return false; - - if (!arc1.Radius.IsEqualTo(arc2.Radius)) - return false; - - if (arc1.StartAngle > arc1.EndAngle) - arc1.StartAngle -= Angle.TwoPI; - - if (arc2.StartAngle > arc2.EndAngle) - arc2.StartAngle -= Angle.TwoPI; - - if (arc1.EndAngle < arc2.StartAngle || arc1.StartAngle > arc2.EndAngle) - return false; - - var startAngle = arc1.StartAngle < arc2.StartAngle ? arc1.StartAngle : arc2.StartAngle; - var endAngle = arc1.EndAngle > arc2.EndAngle ? arc1.EndAngle : arc2.EndAngle; - - if (startAngle < 0) startAngle += Angle.TwoPI; - if (endAngle < 0) endAngle += Angle.TwoPI; - - arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle); - - return true; - } - - private static List GetCollinearLines(this IList lines, Line line, int startIndex) - { - var collinearLines = new List(); - - Parallel.For(startIndex, lines.Count, index => - { - var compareLine = lines[index]; - - if (Object.ReferenceEquals(line, compareLine)) - return; - - if (!line.IsCollinearTo(compareLine)) - return; - - lock (collinearLines) - { - collinearLines.Add(compareLine); - } - }); - - return collinearLines; - } - - private static List GetCoradialArs(this IList arcs, Arc arc, int startIndex) - { - var coradialArcs = new List(); - - Parallel.For(startIndex, arcs.Count, index => - { - var compareArc = arcs[index]; - - if (Object.ReferenceEquals(arc, compareArc)) - return; - - if (!arc.IsCoradialTo(compareArc)) - return; - - lock (coradialArcs) - { - coradialArcs.Add(compareArc); - } - }); - - return coradialArcs; - } - public static List GetShapes(IEnumerable entities) { var lines = new List(); diff --git a/OpenNest.IO/DxfImporter.cs b/OpenNest.IO/DxfImporter.cs index 50fcb85..c604a69 100644 --- a/OpenNest.IO/DxfImporter.cs +++ b/OpenNest.IO/DxfImporter.cs @@ -56,8 +56,8 @@ namespace OpenNest.IO } } - Helper.Optimize(lines); - Helper.Optimize(arcs); + GeometryOptimizer.Optimize(lines); + GeometryOptimizer.Optimize(arcs); entities.AddRange(lines); entities.AddRange(arcs); From 7c4eac546058234f6e1a07eef296dd94f7515bd7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 17:41:40 -0400 Subject: [PATCH 062/116] refactor: extract ShapeBuilder from Helper Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Converters/ConvertGeometry.cs | 2 +- OpenNest.Core/Drawing.cs | 2 +- OpenNest.Core/Geometry/ShapeBuilder.cs | 150 ++++++++++++++++++++ OpenNest.Core/Geometry/ShapeProfile.cs | 2 +- OpenNest.Core/Helper.cs | 149 +------------------ OpenNest.Core/Timing.cs | 2 +- OpenNest.Engine/BestFit/BestFitFinder.cs | 2 +- OpenNest.Engine/BestFit/PairEvaluator.cs | 4 +- OpenNest.Engine/RotationAnalysis.cs | 4 +- OpenNest.Gpu/GpuPairEvaluator.cs | 2 +- OpenNest.Gpu/PartBitmap.cs | 4 +- OpenNest/Actions/ActionSetSequence.cs | 2 +- OpenNest/LayoutPart.cs | 2 +- 13 files changed, 168 insertions(+), 159 deletions(-) create mode 100644 OpenNest.Core/Geometry/ShapeBuilder.cs diff --git a/OpenNest.Core/Converters/ConvertGeometry.cs b/OpenNest.Core/Converters/ConvertGeometry.cs index 13cc52a..6c95e56 100644 --- a/OpenNest.Core/Converters/ConvertGeometry.cs +++ b/OpenNest.Core/Converters/ConvertGeometry.cs @@ -9,7 +9,7 @@ namespace OpenNest.Converters { public static Program ToProgram(IList geometry) { - var shapes = Helper.GetShapes(geometry); + var shapes = ShapeBuilder.GetShapes(geometry); if (shapes.Count == 0) return null; diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs index 080e72f..2adc038 100644 --- a/OpenNest.Core/Drawing.cs +++ b/OpenNest.Core/Drawing.cs @@ -65,7 +65,7 @@ namespace OpenNest public void UpdateArea() { var geometry = ConvertProgram.ToGeometry(Program).Where(entity => entity.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(geometry); + var shapes = ShapeBuilder.GetShapes(geometry); if (shapes.Count == 0) return; diff --git a/OpenNest.Core/Geometry/ShapeBuilder.cs b/OpenNest.Core/Geometry/ShapeBuilder.cs new file mode 100644 index 0000000..8ae4cae --- /dev/null +++ b/OpenNest.Core/Geometry/ShapeBuilder.cs @@ -0,0 +1,150 @@ +using System.Collections.Generic; +using System.Diagnostics; +using OpenNest.Math; + +namespace OpenNest.Geometry +{ + public static class ShapeBuilder + { + public static List GetShapes(IEnumerable entities) + { + var lines = new List(); + var arcs = new List(); + var circles = new List(); + var shapes = new List(); + + var entities2 = new Queue(entities); + + while (entities2.Count > 0) + { + var entity = entities2.Dequeue(); + + switch (entity.Type) + { + case EntityType.Arc: + arcs.Add((Arc)entity); + break; + + case EntityType.Circle: + circles.Add((Circle)entity); + break; + + case EntityType.Line: + lines.Add((Line)entity); + break; + + case EntityType.Shape: + var shape = (Shape)entity; + shape.Entities.ForEach(e => entities2.Enqueue(e)); + break; + + default: + Debug.Fail("Unhandled geometry type"); + break; + } + } + + foreach (var circle in circles) + { + var shape = new Shape(); + shape.Entities.Add(circle); + shape.UpdateBounds(); + shapes.Add(shape); + } + + var entityList = new List(); + + entityList.AddRange(lines); + entityList.AddRange(arcs); + + while (entityList.Count > 0) + { + var next = entityList[0]; + var shape = new Shape(); + shape.Entities.Add(next); + + entityList.RemoveAt(0); + + Vector startPoint = new Vector(); + Entity connected; + + switch (next.Type) + { + case EntityType.Arc: + var arc = (Arc)next; + startPoint = arc.EndPoint(); + break; + + case EntityType.Line: + var line = (Line)next; + startPoint = line.EndPoint; + break; + } + + while ((connected = GetConnected(startPoint, entityList)) != null) + { + shape.Entities.Add(connected); + entityList.Remove(connected); + + switch (connected.Type) + { + case EntityType.Arc: + var arc = (Arc)connected; + startPoint = arc.EndPoint(); + break; + + case EntityType.Line: + var line = (Line)connected; + startPoint = line.EndPoint; + break; + } + } + + shape.UpdateBounds(); + shapes.Add(shape); + } + + return shapes; + } + + internal static Entity GetConnected(Vector pt, IEnumerable geometry) + { + var tol = Tolerance.ChainTolerance; + + foreach (var geo in geometry) + { + switch (geo.Type) + { + case EntityType.Arc: + var arc = (Arc)geo; + + if (arc.StartPoint().DistanceTo(pt) <= tol) + return arc; + + if (arc.EndPoint().DistanceTo(pt) <= tol) + { + arc.Reverse(); + return arc; + } + + break; + + case EntityType.Line: + var line = (Line)geo; + + if (line.StartPoint.DistanceTo(pt) <= tol) + return line; + + if (line.EndPoint.DistanceTo(pt) <= tol) + { + line.Reverse(); + return line; + } + break; + } + } + + return null; + } + } +} diff --git a/OpenNest.Core/Geometry/ShapeProfile.cs b/OpenNest.Core/Geometry/ShapeProfile.cs index 4889df3..69f3067 100644 --- a/OpenNest.Core/Geometry/ShapeProfile.cs +++ b/OpenNest.Core/Geometry/ShapeProfile.cs @@ -16,7 +16,7 @@ namespace OpenNest.Geometry private void Update(List entities) { - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); Perimeter = shapes[0]; Cutouts = new List(); diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index 101de75..1c0fd75 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -11,147 +11,6 @@ namespace OpenNest { public static class Helper { - public static List GetShapes(IEnumerable entities) - { - var lines = new List(); - var arcs = new List(); - var circles = new List(); - var shapes = new List(); - - var entities2 = new Queue(entities); - - while (entities2.Count > 0) - { - var entity = entities2.Dequeue(); - - switch (entity.Type) - { - case EntityType.Arc: - arcs.Add((Arc)entity); - break; - - case EntityType.Circle: - circles.Add((Circle)entity); - break; - - case EntityType.Line: - lines.Add((Line)entity); - break; - - case EntityType.Shape: - var shape = (Shape)entity; - shape.Entities.ForEach(e => entities2.Enqueue(e)); - break; - - default: - Debug.Fail("Unhandled geometry type"); - break; - } - } - - foreach (var circle in circles) - { - var shape = new Shape(); - shape.Entities.Add(circle); - shape.UpdateBounds(); - shapes.Add(shape); - } - - var entityList = new List(); - - entityList.AddRange(lines); - entityList.AddRange(arcs); - - while (entityList.Count > 0) - { - var next = entityList[0]; - var shape = new Shape(); - shape.Entities.Add(next); - - entityList.RemoveAt(0); - - Vector startPoint = new Vector(); - Entity connected; - - switch (next.Type) - { - case EntityType.Arc: - var arc = (Arc)next; - startPoint = arc.EndPoint(); - break; - - case EntityType.Line: - var line = (Line)next; - startPoint = line.EndPoint; - break; - } - - while ((connected = GetConnected(startPoint, entityList)) != null) - { - shape.Entities.Add(connected); - entityList.Remove(connected); - - switch (connected.Type) - { - case EntityType.Arc: - var arc = (Arc)connected; - startPoint = arc.EndPoint(); - break; - - case EntityType.Line: - var line = (Line)connected; - startPoint = line.EndPoint; - break; - } - } - - shape.UpdateBounds(); - shapes.Add(shape); - } - - return shapes; - } - - internal static Entity GetConnected(Vector pt, IEnumerable geometry) - { - var tol = Math.Tolerance.ChainTolerance; - - foreach (var geo in geometry) - { - switch (geo.Type) - { - case EntityType.Arc: - var arc = (Arc)geo; - - if (arc.StartPoint().DistanceTo(pt) <= tol) - return arc; - - if (arc.EndPoint().DistanceTo(pt) <= tol) - { - arc.Reverse(); - return arc; - } - - break; - - case EntityType.Line: - var line = (Line)geo; - - if (line.StartPoint.DistanceTo(pt) <= tol) - return line; - - if (line.EndPoint.DistanceTo(pt) <= tol) - { - line.Reverse(); - return line; - } - break; - } - } - - return null; - } - internal static bool Intersects(Arc arc1, Arc arc2, out List pts) { var c1 = new Circle(arc1.Center, arc1.Radius); @@ -519,7 +378,7 @@ namespace OpenNest public static List GetPartLines(Part part, double chordTolerance = 0.001) { var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); var lines = new List(); foreach (var shape in shapes) @@ -535,7 +394,7 @@ namespace OpenNest public static List GetPartLines(Part part, PushDirection facingDirection, double chordTolerance = 0.001) { var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); var lines = new List(); foreach (var shape in shapes) @@ -551,7 +410,7 @@ namespace OpenNest public static List GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001) { var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); var lines = new List(); foreach (var shape in shapes) @@ -575,7 +434,7 @@ namespace OpenNest public static List GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001) { var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); var lines = new List(); foreach (var shape in shapes) diff --git a/OpenNest.Core/Timing.cs b/OpenNest.Core/Timing.cs index 11b85c8..7e1b401 100644 --- a/OpenNest.Core/Timing.cs +++ b/OpenNest.Core/Timing.cs @@ -11,7 +11,7 @@ namespace OpenNest public static TimingInfo GetTimingInfo(Program pgm) { var entities = ConvertProgram.ToGeometry(pgm); - var shapes = Helper.GetShapes(entities.Where(entity => entity.Layer != SpecialLayers.Rapid)); + var shapes = ShapeBuilder.GetShapes(entities.Where(entity => entity.Layer != SpecialLayers.Rapid)); var info = new TimingInfo { PierceCount = shapes.Count }; var last = entities[0]; diff --git a/OpenNest.Engine/BestFit/BestFitFinder.cs b/OpenNest.Engine/BestFit/BestFitFinder.cs index b86bc6a..49a4121 100644 --- a/OpenNest.Engine/BestFit/BestFitFinder.cs +++ b/OpenNest.Engine/BestFit/BestFitFinder.cs @@ -116,7 +116,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram.ToGeometry(drawing.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var points = new List(); diff --git a/OpenNest.Engine/BestFit/PairEvaluator.cs b/OpenNest.Engine/BestFit/PairEvaluator.cs index bffc7c6..c48f1f0 100644 --- a/OpenNest.Engine/BestFit/PairEvaluator.cs +++ b/OpenNest.Engine/BestFit/PairEvaluator.cs @@ -103,7 +103,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram.ToGeometry(part.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); shapes.ForEach(s => s.Offset(part.Location)); return shapes; } @@ -112,7 +112,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram.ToGeometry(part.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var points = new List(); foreach (var shape in shapes) diff --git a/OpenNest.Engine/RotationAnalysis.cs b/OpenNest.Engine/RotationAnalysis.cs index 8aa7aa5..0d7e20f 100644 --- a/OpenNest.Engine/RotationAnalysis.cs +++ b/OpenNest.Engine/RotationAnalysis.cs @@ -17,7 +17,7 @@ namespace OpenNest var entities = ConvertProgram.ToGeometry(item.Drawing.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); if (shapes.Count == 0) return 0; @@ -65,7 +65,7 @@ namespace OpenNest var entities = ConvertProgram.ToGeometry(part.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); foreach (var shape in shapes) { diff --git a/OpenNest.Gpu/GpuPairEvaluator.cs b/OpenNest.Gpu/GpuPairEvaluator.cs index 17c2871..4a6e502 100644 --- a/OpenNest.Gpu/GpuPairEvaluator.cs +++ b/OpenNest.Gpu/GpuPairEvaluator.cs @@ -258,7 +258,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram.ToGeometry(part.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var points = new List(); foreach (var shape in shapes) diff --git a/OpenNest.Gpu/PartBitmap.cs b/OpenNest.Gpu/PartBitmap.cs index 97b3af3..9b729ef 100644 --- a/OpenNest.Gpu/PartBitmap.cs +++ b/OpenNest.Gpu/PartBitmap.cs @@ -47,7 +47,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram.ToGeometry(part.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var polygons = new List(); @@ -137,7 +137,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram.ToGeometry(drawing.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var polygons = new List(); diff --git a/OpenNest/Actions/ActionSetSequence.cs b/OpenNest/Actions/ActionSetSequence.cs index e76e4e2..fb2ed33 100644 --- a/OpenNest/Actions/ActionSetSequence.cs +++ b/OpenNest/Actions/ActionSetSequence.cs @@ -52,7 +52,7 @@ namespace OpenNest.Actions { var entities = ConvertProgram.ToGeometry(part.Program).Where(e => e.Layer == SpecialLayers.Cut).ToList(); entities.ForEach(entity => entity.Offset(part.Location)); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var shape = new Shape(); shape.Entities.AddRange(shapes); ShapePartPairs.Add(new Pair() { Part = part, Shape = shape }); diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index 94685d6..b66a64f 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -134,7 +134,7 @@ namespace OpenNest { var result = new List(); var entities = ConvertProgram.ToGeometry(BasePart.Program); - var shapes = Helper.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); foreach (var shape in shapes) { From 84d3f90549ae4243ed598e41ee9ec92a4c445202 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 17:43:12 -0400 Subject: [PATCH 063/116] refactor: extract Intersect from Helper Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/Arc.cs | 20 +- OpenNest.Core/Geometry/Circle.cs | 18 +- OpenNest.Core/Geometry/Intersect.cs | 373 ++++++++++++++++++++++++++++ OpenNest.Core/Geometry/Line.cs | 18 +- OpenNest.Core/Geometry/Polygon.cs | 20 +- OpenNest.Core/Geometry/Shape.cs | 22 +- OpenNest.Core/Helper.cs | 364 --------------------------- 7 files changed, 422 insertions(+), 413 deletions(-) create mode 100644 OpenNest.Core/Geometry/Intersect.cs diff --git a/OpenNest.Core/Geometry/Arc.cs b/OpenNest.Core/Geometry/Arc.cs index d48792f..bbd2d3f 100644 --- a/OpenNest.Core/Geometry/Arc.cs +++ b/OpenNest.Core/Geometry/Arc.cs @@ -465,7 +465,7 @@ namespace OpenNest.Geometry public override bool Intersects(Arc arc) { List pts; - return Helper.Intersects(this, arc, out pts); + return Intersect.Intersects(this, arc, out pts); } /// @@ -476,7 +476,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Arc arc, out List pts) { - return Helper.Intersects(this, arc, out pts); ; + return Intersect.Intersects(this, arc, out pts); ; } /// @@ -487,7 +487,7 @@ namespace OpenNest.Geometry public override bool Intersects(Circle circle) { List pts; - return Helper.Intersects(this, circle, out pts); + return Intersect.Intersects(this, circle, out pts); } /// @@ -498,7 +498,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Circle circle, out List pts) { - return Helper.Intersects(this, circle, out pts); + return Intersect.Intersects(this, circle, out pts); } /// @@ -509,7 +509,7 @@ namespace OpenNest.Geometry public override bool Intersects(Line line) { List pts; - return Helper.Intersects(this, line, out pts); + return Intersect.Intersects(this, line, out pts); } /// @@ -520,7 +520,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Line line, out List pts) { - return Helper.Intersects(this, line, out pts); + return Intersect.Intersects(this, line, out pts); } /// @@ -531,7 +531,7 @@ namespace OpenNest.Geometry public override bool Intersects(Polygon polygon) { List pts; - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -542,7 +542,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Polygon polygon, out List pts) { - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -553,7 +553,7 @@ namespace OpenNest.Geometry public override bool Intersects(Shape shape) { List pts; - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// @@ -564,7 +564,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Shape shape, out List pts) { - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// diff --git a/OpenNest.Core/Geometry/Circle.cs b/OpenNest.Core/Geometry/Circle.cs index 859d51a..82a0b3e 100644 --- a/OpenNest.Core/Geometry/Circle.cs +++ b/OpenNest.Core/Geometry/Circle.cs @@ -320,7 +320,7 @@ namespace OpenNest.Geometry public override bool Intersects(Arc arc) { List pts; - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -331,7 +331,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Arc arc, out List pts) { - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -353,7 +353,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Circle circle, out List pts) { - return Helper.Intersects(this, circle, out pts); + return Intersect.Intersects(this, circle, out pts); } /// @@ -364,7 +364,7 @@ namespace OpenNest.Geometry public override bool Intersects(Line line) { List pts; - return Helper.Intersects(this, line, out pts); + return Intersect.Intersects(this, line, out pts); } /// @@ -375,7 +375,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Line line, out List pts) { - return Helper.Intersects(this, line, out pts); + return Intersect.Intersects(this, line, out pts); } /// @@ -386,7 +386,7 @@ namespace OpenNest.Geometry public override bool Intersects(Polygon polygon) { List pts; - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -397,7 +397,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Polygon polygon, out List pts) { - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -408,7 +408,7 @@ namespace OpenNest.Geometry public override bool Intersects(Shape shape) { List pts; - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// @@ -419,7 +419,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Shape shape, out List pts) { - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// diff --git a/OpenNest.Core/Geometry/Intersect.cs b/OpenNest.Core/Geometry/Intersect.cs new file mode 100644 index 0000000..8af27e1 --- /dev/null +++ b/OpenNest.Core/Geometry/Intersect.cs @@ -0,0 +1,373 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Math; + +namespace OpenNest.Geometry +{ + public static class Intersect + { + internal static bool Intersects(Arc arc1, Arc arc2, out List pts) + { + var c1 = new Circle(arc1.Center, arc1.Radius); + var c2 = new Circle(arc2.Center, arc2.Radius); + + if (!Intersects(c1, c2, out pts)) + { + pts = new List(); + return false; + } + + pts = pts.Where(pt => + Angle.IsBetweenRad(arc1.Center.AngleTo(pt), arc1.StartAngle, arc1.EndAngle, arc1.IsReversed) && + Angle.IsBetweenRad(arc2.Center.AngleTo(pt), arc2.StartAngle, arc2.EndAngle, arc2.IsReversed)) + .ToList(); + + return pts.Count > 0; + } + + internal static bool Intersects(Arc arc, Circle circle, out List pts) + { + var c1 = new Circle(arc.Center, arc.Radius); + + if (!Intersects(c1, circle, out pts)) + { + pts = new List(); + return false; + } + + pts = pts.Where(pt => Angle.IsBetweenRad( + arc.Center.AngleTo(pt), + arc.StartAngle, + arc.EndAngle, + arc.IsReversed)).ToList(); + + return pts.Count > 0; + } + + internal static bool Intersects(Arc arc, Line line, out List pts) + { + var c1 = new Circle(arc.Center, arc.Radius); + + if (!Intersects(c1, line, out pts)) + { + pts = new List(); + return false; + } + + pts = pts.Where(pt => Angle.IsBetweenRad( + arc.Center.AngleTo(pt), + arc.StartAngle, + arc.EndAngle, + arc.IsReversed)).ToList(); + + return pts.Count > 0; + } + + internal static bool Intersects(Arc arc, Shape shape, out List pts) + { + var pts2 = new List(); + + foreach (var geo in shape.Entities) + { + List pts3; + geo.Intersects(arc, out pts3); + pts2.AddRange(pts3); + } + + pts = pts2.Where(pt => Angle.IsBetweenRad( + arc.Center.AngleTo(pt), + arc.StartAngle, + arc.EndAngle, + arc.IsReversed)).ToList(); + + return pts.Count > 0; + } + + internal static bool Intersects(Arc arc, Polygon polygon, out List pts) + { + var pts2 = new List(); + var lines = polygon.ToLines(); + + foreach (var line in lines) + { + List pts3; + Intersects(arc, line, out pts3); + pts2.AddRange(pts3); + } + + pts = pts2.Where(pt => Angle.IsBetweenRad( + arc.Center.AngleTo(pt), + arc.StartAngle, + arc.EndAngle, + arc.IsReversed)).ToList(); + + return pts.Count > 0; + } + + internal static bool Intersects(Circle circle1, Circle circle2, out List pts) + { + var distance = circle1.Center.DistanceTo(circle2.Center); + + // check if circles are too far apart + if (distance > circle1.Radius + circle2.Radius) + { + pts = new List(); + return false; + } + + // check if one circle contains the other + if (distance < System.Math.Abs(circle1.Radius - circle2.Radius)) + { + pts = new List(); + return false; + } + + var d = circle2.Center - circle1.Center; + var a = (circle1.Radius * circle1.Radius - circle2.Radius * circle2.Radius + distance * distance) / (2.0 * distance); + var h = System.Math.Sqrt(circle1.Radius * circle1.Radius - a * a); + + var pt = new Vector( + circle1.Center.X + (a * d.X) / distance, + circle1.Center.Y + (a * d.Y) / distance); + + var i1 = new Vector( + pt.X + (h * d.Y) / distance, + pt.Y - (h * d.X) / distance); + + var i2 = new Vector( + pt.X - (h * d.Y) / distance, + pt.Y + (h * d.X) / distance); + + pts = i1 != i2 ? new List { i1, i2 } : new List { i1 }; + + return true; + } + + internal static bool Intersects(Circle circle, Line line, out List pts) + { + var d1 = line.EndPoint - line.StartPoint; + var d2 = line.StartPoint - circle.Center; + + var a = d1.X * d1.X + d1.Y * d1.Y; + var b = (d1.X * d2.X + d1.Y * d2.Y) * 2; + var c = (d2.X * d2.X + d2.Y * d2.Y) - circle.Radius * circle.Radius; + + var det = b * b - 4 * a * c; + + if ((a <= Tolerance.Epsilon) || (det < 0)) + { + pts = new List(); + return false; + } + + double t; + pts = new List(); + + if (det.IsEqualTo(0)) + { + t = -b / (2 * a); + var pt1 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y); + + if (line.BoundingBox.Contains(pt1)) + pts.Add(pt1); + + return true; + } + + t = (-b + System.Math.Sqrt(det)) / (2 * a); + var pt2 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y); + + if (line.BoundingBox.Contains(pt2)) + pts.Add(pt2); + + t = (-b - System.Math.Sqrt(det)) / (2 * a); + var pt3 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y); + + if (line.BoundingBox.Contains(pt3)) + pts.Add(pt3); + + return true; + } + + internal static bool Intersects(Circle circle, Shape shape, out List pts) + { + pts = new List(); + + foreach (var geo in shape.Entities) + { + List pts3; + geo.Intersects(circle, out pts3); + pts.AddRange(pts3); + } + + return pts.Count > 0; + } + + internal static bool Intersects(Circle circle, Polygon polygon, out List pts) + { + pts = new List(); + var lines = polygon.ToLines(); + + foreach (var line in lines) + { + List pts3; + Intersects(circle, line, out pts3); + pts.AddRange(pts3); + } + + return pts.Count > 0; + } + + internal static bool Intersects(Line line1, Line line2, out Vector pt) + { + var a1 = line1.EndPoint.Y - line1.StartPoint.Y; + var b1 = line1.StartPoint.X - line1.EndPoint.X; + var c1 = a1 * line1.StartPoint.X + b1 * line1.StartPoint.Y; + + var a2 = line2.EndPoint.Y - line2.StartPoint.Y; + var b2 = line2.StartPoint.X - line2.EndPoint.X; + var c2 = a2 * line2.StartPoint.X + b2 * line2.StartPoint.Y; + + var d = a1 * b2 - a2 * b1; + + if (d.IsEqualTo(0.0)) + { + pt = Vector.Zero; + return false; + } + + var x = (b2 * c1 - b1 * c2) / d; + var y = (a1 * c2 - a2 * c1) / d; + + pt = new Vector(x, y); + return line1.BoundingBox.Contains(pt) && line2.BoundingBox.Contains(pt); + } + + internal static bool Intersects(Line line, Shape shape, out List pts) + { + pts = new List(); + + foreach (var geo in shape.Entities) + { + List pts3; + geo.Intersects(line, out pts3); + pts.AddRange(pts3); + } + + return pts.Count > 0; + } + + internal static bool Intersects(Line line, Polygon polygon, out List pts) + { + pts = new List(); + var lines = polygon.ToLines(); + + foreach (var line2 in lines) + { + Vector pt; + + if (Intersects(line, line2, out pt)) + pts.Add(pt); + } + + return pts.Count > 0; + } + + internal static bool Intersects(Shape shape1, Shape shape2, out List pts) + { + pts = new List(); + + for (int i = 0; i < shape1.Entities.Count; i++) + { + var geo1 = shape1.Entities[i]; + + for (int j = 0; j < shape2.Entities.Count; j++) + { + List pts2; + bool success = false; + + var geo2 = shape2.Entities[j]; + + switch (geo2.Type) + { + case EntityType.Arc: + success = geo1.Intersects((Arc)geo2, out pts2); + break; + + case EntityType.Circle: + success = geo1.Intersects((Circle)geo2, out pts2); + break; + + case EntityType.Line: + success = geo1.Intersects((Line)geo2, out pts2); + break; + + case EntityType.Shape: + success = geo1.Intersects((Shape)geo2, out pts2); + break; + + case EntityType.Polygon: + success = geo1.Intersects((Polygon)geo2, out pts2); + break; + + default: + continue; + } + + if (success) + pts.AddRange(pts2); + } + } + + return pts.Count > 0; + } + + internal static bool Intersects(Shape shape, Polygon polygon, out List pts) + { + pts = new List(); + + var lines = polygon.ToLines(); + + for (int i = 0; i < shape.Entities.Count; i++) + { + var geo = shape.Entities[i]; + + for (int j = 0; j < lines.Count; j++) + { + var line = lines[j]; + + List pts2; + + if (geo.Intersects(line, out pts2)) + pts.AddRange(pts2); + } + } + + return pts.Count > 0; + } + + internal static bool Intersects(Polygon polygon1, Polygon polygon2, out List pts) + { + pts = new List(); + + var lines1 = polygon1.ToLines(); + var lines2 = polygon2.ToLines(); + + for (int i = 0; i < lines1.Count; i++) + { + var line1 = lines1[i]; + + for (int j = 0; j < lines2.Count; j++) + { + var line2 = lines2[j]; + Vector pt; + + if (Intersects(line1, line2, out pt)) + pts.Add(pt); + } + } + + return pts.Count > 0; + } + } +} diff --git a/OpenNest.Core/Geometry/Line.cs b/OpenNest.Core/Geometry/Line.cs index 0145f9e..d4c5cc2 100644 --- a/OpenNest.Core/Geometry/Line.cs +++ b/OpenNest.Core/Geometry/Line.cs @@ -456,7 +456,7 @@ namespace OpenNest.Geometry public override bool Intersects(Arc arc) { List pts; - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -467,7 +467,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Arc arc, out List pts) { - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -478,7 +478,7 @@ namespace OpenNest.Geometry public override bool Intersects(Circle circle) { List pts; - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -489,7 +489,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Circle circle, out List pts) { - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -512,7 +512,7 @@ namespace OpenNest.Geometry public override bool Intersects(Line line, out List pts) { Vector pt; - var success = Helper.Intersects(this, line, out pt); + var success = Intersect.Intersects(this, line, out pt); pts = new List(new[] { pt }); return success; } @@ -525,7 +525,7 @@ namespace OpenNest.Geometry public override bool Intersects(Polygon polygon) { List pts; - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -536,7 +536,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Polygon polygon, out List pts) { - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -547,7 +547,7 @@ namespace OpenNest.Geometry public override bool Intersects(Shape shape) { List pts; - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// @@ -558,7 +558,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Shape shape, out List pts) { - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// diff --git a/OpenNest.Core/Geometry/Polygon.cs b/OpenNest.Core/Geometry/Polygon.cs index e48bf24..2f09e65 100644 --- a/OpenNest.Core/Geometry/Polygon.cs +++ b/OpenNest.Core/Geometry/Polygon.cs @@ -364,7 +364,7 @@ namespace OpenNest.Geometry public override bool Intersects(Arc arc) { List pts; - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -375,7 +375,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Arc arc, out List pts) { - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -386,7 +386,7 @@ namespace OpenNest.Geometry public override bool Intersects(Circle circle) { List pts; - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -397,7 +397,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Circle circle, out List pts) { - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -408,7 +408,7 @@ namespace OpenNest.Geometry public override bool Intersects(Line line) { List pts; - return Helper.Intersects(line, this, out pts); + return Intersect.Intersects(line, this, out pts); } /// @@ -419,7 +419,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Line line, out List pts) { - return Helper.Intersects(line, this, out pts); + return Intersect.Intersects(line, this, out pts); } /// @@ -430,7 +430,7 @@ namespace OpenNest.Geometry public override bool Intersects(Polygon polygon) { List pts; - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -441,7 +441,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Polygon polygon, out List pts) { - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -452,7 +452,7 @@ namespace OpenNest.Geometry public override bool Intersects(Shape shape) { List pts; - return Helper.Intersects(shape, this, out pts); + return Intersect.Intersects(shape, this, out pts); } /// @@ -463,7 +463,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Shape shape, out List pts) { - return Helper.Intersects(shape, this, out pts); + return Intersect.Intersects(shape, this, out pts); } /// diff --git a/OpenNest.Core/Geometry/Shape.cs b/OpenNest.Core/Geometry/Shape.cs index 9f11945..03076d2 100644 --- a/OpenNest.Core/Geometry/Shape.cs +++ b/OpenNest.Core/Geometry/Shape.cs @@ -534,7 +534,7 @@ namespace OpenNest.Geometry { Vector intersection; - if (Helper.Intersects(offsetLine, lastOffsetLine, out intersection)) + if (Intersect.Intersects(offsetLine, lastOffsetLine, out intersection)) { offsetLine.StartPoint = intersection; lastOffsetLine.EndPoint = intersection; @@ -577,7 +577,7 @@ namespace OpenNest.Geometry public override bool Intersects(Arc arc) { List pts; - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -588,7 +588,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Arc arc, out List pts) { - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -599,7 +599,7 @@ namespace OpenNest.Geometry public override bool Intersects(Circle circle) { List pts; - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -610,7 +610,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Circle circle, out List pts) { - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -621,7 +621,7 @@ namespace OpenNest.Geometry public override bool Intersects(Line line) { List pts; - return Helper.Intersects(line, this, out pts); + return Intersect.Intersects(line, this, out pts); } /// @@ -632,7 +632,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Line line, out List pts) { - return Helper.Intersects(line, this, out pts); + return Intersect.Intersects(line, this, out pts); } /// @@ -643,7 +643,7 @@ namespace OpenNest.Geometry public override bool Intersects(Polygon polygon) { List pts; - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -654,7 +654,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Polygon polygon, out List pts) { - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -665,7 +665,7 @@ namespace OpenNest.Geometry public override bool Intersects(Shape shape) { List pts; - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// @@ -676,7 +676,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Shape shape, out List pts) { - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index 1c0fd75..e9ba40e 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -11,370 +11,6 @@ namespace OpenNest { public static class Helper { - internal static bool Intersects(Arc arc1, Arc arc2, out List pts) - { - var c1 = new Circle(arc1.Center, arc1.Radius); - var c2 = new Circle(arc2.Center, arc2.Radius); - - if (!Intersects(c1, c2, out pts)) - { - pts = new List(); - return false; - } - - pts = pts.Where(pt => - Angle.IsBetweenRad(arc1.Center.AngleTo(pt), arc1.StartAngle, arc1.EndAngle, arc1.IsReversed) && - Angle.IsBetweenRad(arc2.Center.AngleTo(pt), arc2.StartAngle, arc2.EndAngle, arc2.IsReversed)) - .ToList(); - - return pts.Count > 0; - } - - internal static bool Intersects(Arc arc, Circle circle, out List pts) - { - var c1 = new Circle(arc.Center, arc.Radius); - - if (!Intersects(c1, circle, out pts)) - { - pts = new List(); - return false; - } - - pts = pts.Where(pt => Angle.IsBetweenRad( - arc.Center.AngleTo(pt), - arc.StartAngle, - arc.EndAngle, - arc.IsReversed)).ToList(); - - return pts.Count > 0; - } - - internal static bool Intersects(Arc arc, Line line, out List pts) - { - var c1 = new Circle(arc.Center, arc.Radius); - - if (!Intersects(c1, line, out pts)) - { - pts = new List(); - return false; - } - - pts = pts.Where(pt => Angle.IsBetweenRad( - arc.Center.AngleTo(pt), - arc.StartAngle, - arc.EndAngle, - arc.IsReversed)).ToList(); - - return pts.Count > 0; - } - - internal static bool Intersects(Arc arc, Shape shape, out List pts) - { - var pts2 = new List(); - - foreach (var geo in shape.Entities) - { - List pts3; - geo.Intersects(arc, out pts3); - pts2.AddRange(pts3); - } - - pts = pts2.Where(pt => Angle.IsBetweenRad( - arc.Center.AngleTo(pt), - arc.StartAngle, - arc.EndAngle, - arc.IsReversed)).ToList(); - - return pts.Count > 0; - } - - internal static bool Intersects(Arc arc, Polygon polygon, out List pts) - { - var pts2 = new List(); - var lines = polygon.ToLines(); - - foreach (var line in lines) - { - List pts3; - Intersects(arc, line, out pts3); - pts2.AddRange(pts3); - } - - pts = pts2.Where(pt => Angle.IsBetweenRad( - arc.Center.AngleTo(pt), - arc.StartAngle, - arc.EndAngle, - arc.IsReversed)).ToList(); - - return pts.Count > 0; - } - - internal static bool Intersects(Circle circle1, Circle circle2, out List pts) - { - var distance = circle1.Center.DistanceTo(circle2.Center); - - // check if circles are too far apart - if (distance > circle1.Radius + circle2.Radius) - { - pts = new List(); - return false; - } - - // check if one circle contains the other - if (distance < System.Math.Abs(circle1.Radius - circle2.Radius)) - { - pts = new List(); - return false; - } - - var d = circle2.Center - circle1.Center; - var a = (circle1.Radius * circle1.Radius - circle2.Radius * circle2.Radius + distance * distance) / (2.0 * distance); - var h = System.Math.Sqrt(circle1.Radius * circle1.Radius - a * a); - - var pt = new Vector( - circle1.Center.X + (a * d.X) / distance, - circle1.Center.Y + (a * d.Y) / distance); - - var i1 = new Vector( - pt.X + (h * d.Y) / distance, - pt.Y - (h * d.X) / distance); - - var i2 = new Vector( - pt.X - (h * d.Y) / distance, - pt.Y + (h * d.X) / distance); - - pts = i1 != i2 ? new List { i1, i2 } : new List { i1 }; - - return true; - } - - internal static bool Intersects(Circle circle, Line line, out List pts) - { - var d1 = line.EndPoint - line.StartPoint; - var d2 = line.StartPoint - circle.Center; - - var a = d1.X * d1.X + d1.Y * d1.Y; - var b = (d1.X * d2.X + d1.Y * d2.Y) * 2; - var c = (d2.X * d2.X + d2.Y * d2.Y) - circle.Radius * circle.Radius; - - var det = b * b - 4 * a * c; - - if ((a <= Tolerance.Epsilon) || (det < 0)) - { - pts = new List(); - return false; - } - - double t; - pts = new List(); - - if (det.IsEqualTo(0)) - { - t = -b / (2 * a); - var pt1 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y); - - if (line.BoundingBox.Contains(pt1)) - pts.Add(pt1); - - return true; - } - - t = (-b + System.Math.Sqrt(det)) / (2 * a); - var pt2 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y); - - if (line.BoundingBox.Contains(pt2)) - pts.Add(pt2); - - t = (-b - System.Math.Sqrt(det)) / (2 * a); - var pt3 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y); - - if (line.BoundingBox.Contains(pt3)) - pts.Add(pt3); - - return true; - } - - internal static bool Intersects(Circle circle, Shape shape, out List pts) - { - pts = new List(); - - foreach (var geo in shape.Entities) - { - List pts3; - geo.Intersects(circle, out pts3); - pts.AddRange(pts3); - } - - return pts.Count > 0; - } - - internal static bool Intersects(Circle circle, Polygon polygon, out List pts) - { - pts = new List(); - var lines = polygon.ToLines(); - - foreach (var line in lines) - { - List pts3; - Intersects(circle, line, out pts3); - pts.AddRange(pts3); - } - - return pts.Count > 0; - } - - internal static bool Intersects(Line line1, Line line2, out Vector pt) - { - var a1 = line1.EndPoint.Y - line1.StartPoint.Y; - var b1 = line1.StartPoint.X - line1.EndPoint.X; - var c1 = a1 * line1.StartPoint.X + b1 * line1.StartPoint.Y; - - var a2 = line2.EndPoint.Y - line2.StartPoint.Y; - var b2 = line2.StartPoint.X - line2.EndPoint.X; - var c2 = a2 * line2.StartPoint.X + b2 * line2.StartPoint.Y; - - var d = a1 * b2 - a2 * b1; - - if (d.IsEqualTo(0.0)) - { - pt = Vector.Zero; - return false; - } - - var x = (b2 * c1 - b1 * c2) / d; - var y = (a1 * c2 - a2 * c1) / d; - - pt = new Vector(x, y); - return line1.BoundingBox.Contains(pt) && line2.BoundingBox.Contains(pt); - } - - internal static bool Intersects(Line line, Shape shape, out List pts) - { - pts = new List(); - - foreach (var geo in shape.Entities) - { - List pts3; - geo.Intersects(line, out pts3); - pts.AddRange(pts3); - } - - return pts.Count > 0; - } - - internal static bool Intersects(Line line, Polygon polygon, out List pts) - { - pts = new List(); - var lines = polygon.ToLines(); - - foreach (var line2 in lines) - { - Vector pt; - - if (Intersects(line, line2, out pt)) - pts.Add(pt); - } - - return pts.Count > 0; - } - - internal static bool Intersects(Shape shape1, Shape shape2, out List pts) - { - pts = new List(); - - for (int i = 0; i < shape1.Entities.Count; i++) - { - var geo1 = shape1.Entities[i]; - - for (int j = 0; j < shape2.Entities.Count; j++) - { - List pts2; - bool success = false; - - var geo2 = shape2.Entities[j]; - - switch (geo2.Type) - { - case EntityType.Arc: - success = geo1.Intersects((Arc)geo2, out pts2); - break; - - case EntityType.Circle: - success = geo1.Intersects((Circle)geo2, out pts2); - break; - - case EntityType.Line: - success = geo1.Intersects((Line)geo2, out pts2); - break; - - case EntityType.Shape: - success = geo1.Intersects((Shape)geo2, out pts2); - break; - - case EntityType.Polygon: - success = geo1.Intersects((Polygon)geo2, out pts2); - break; - - default: - continue; - } - - if (success) - pts.AddRange(pts2); - } - } - - return pts.Count > 0; - } - - internal static bool Intersects(Shape shape, Polygon polygon, out List pts) - { - pts = new List(); - - var lines = polygon.ToLines(); - - for (int i = 0; i < shape.Entities.Count; i++) - { - var geo = shape.Entities[i]; - - for (int j = 0; j < lines.Count; j++) - { - var line = lines[j]; - - List pts2; - - if (geo.Intersects(line, out pts2)) - pts.AddRange(pts2); - } - } - - return pts.Count > 0; - } - - internal static bool Intersects(Polygon polygon1, Polygon polygon2, out List pts) - { - pts = new List(); - - var lines1 = polygon1.ToLines(); - var lines2 = polygon2.ToLines(); - - for (int i = 0; i < lines1.Count; i++) - { - var line1 = lines1[i]; - - for (int j = 0; j < lines2.Count; j++) - { - var line2 = lines2[j]; - Vector pt; - - if (Intersects(line1, line2, out pt)) - pts.Add(pt); - } - } - - return pts.Count > 0; - } - public static List GetPartLines(Part part, double chordTolerance = 0.001) { var entities = ConvertProgram.ToGeometry(part.Program); From 2881815c7aea3ff7e159367bfa49a491ece0665a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 17:44:17 -0400 Subject: [PATCH 064/116] refactor: extract PartGeometry from Helper Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Helper.cs | 116 ---------------- OpenNest.Core/PartGeometry.cs | 126 ++++++++++++++++++ .../BestFit/RotationSlideStrategy.cs | 4 +- OpenNest.Engine/Compactor.cs | 8 +- 4 files changed, 132 insertions(+), 122 deletions(-) create mode 100644 OpenNest.Core/PartGeometry.cs diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index e9ba40e..a41b50f 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -11,122 +11,6 @@ namespace OpenNest { public static class Helper { - public static List GetPartLines(Part part, double chordTolerance = 0.001) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); - var lines = new List(); - - foreach (var shape in shapes) - { - var polygon = shape.ToPolygonWithTolerance(chordTolerance); - polygon.Offset(part.Location); - lines.AddRange(polygon.ToLines()); - } - - return lines; - } - - public static List GetPartLines(Part part, PushDirection facingDirection, double chordTolerance = 0.001) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); - var lines = new List(); - - foreach (var shape in shapes) - { - var polygon = shape.ToPolygonWithTolerance(chordTolerance); - polygon.Offset(part.Location); - lines.AddRange(GetDirectionalLines(polygon, facingDirection)); - } - - return lines; - } - - public static List GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); - var lines = new List(); - - foreach (var shape in shapes) - { - // Add chord tolerance to compensate for inscribed polygon chords - // being inside the actual offset arcs. - var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape; - - if (offsetEntity == null) - continue; - - var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance); - polygon.RemoveSelfIntersections(); - polygon.Offset(part.Location); - lines.AddRange(polygon.ToLines()); - } - - return lines; - } - - public static List GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); - var lines = new List(); - - foreach (var shape in shapes) - { - var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape; - - if (offsetEntity == null) - continue; - - var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance); - polygon.RemoveSelfIntersections(); - polygon.Offset(part.Location); - lines.AddRange(GetDirectionalLines(polygon, facingDirection)); - } - - return lines; - } - - /// - /// Returns only polygon edges whose outward normal faces the specified direction. - /// - private static List GetDirectionalLines(Polygon polygon, PushDirection facingDirection) - { - if (polygon.Vertices.Count < 3) - return polygon.ToLines(); - - var sign = polygon.RotationDirection() == RotationType.CCW ? 1.0 : -1.0; - var lines = new List(); - var last = polygon.Vertices[0]; - - for (int i = 1; i < polygon.Vertices.Count; i++) - { - var current = polygon.Vertices[i]; - var dx = current.X - last.X; - var dy = current.Y - last.Y; - - bool keep; - - switch (facingDirection) - { - case PushDirection.Left: keep = -sign * dy > 0; break; - case PushDirection.Right: keep = sign * dy > 0; break; - case PushDirection.Up: keep = -sign * dx > 0; break; - case PushDirection.Down: keep = sign * dx > 0; break; - default: keep = true; break; - } - - if (keep) - lines.Add(new Line(last, current)); - - last = current; - } - - return lines; - } - /// /// Finds the distance from a vertex to a line segment along a push axis. /// Returns double.MaxValue if the ray does not hit the segment. diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs new file mode 100644 index 0000000..be5c3d7 --- /dev/null +++ b/OpenNest.Core/PartGeometry.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest +{ + public static class PartGeometry + { + public static List GetPartLines(Part part, double chordTolerance = 0.001) + { + var entities = ConvertProgram.ToGeometry(part.Program); + var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var lines = new List(); + + foreach (var shape in shapes) + { + var polygon = shape.ToPolygonWithTolerance(chordTolerance); + polygon.Offset(part.Location); + lines.AddRange(polygon.ToLines()); + } + + return lines; + } + + public static List GetPartLines(Part part, PushDirection facingDirection, double chordTolerance = 0.001) + { + var entities = ConvertProgram.ToGeometry(part.Program); + var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var lines = new List(); + + foreach (var shape in shapes) + { + var polygon = shape.ToPolygonWithTolerance(chordTolerance); + polygon.Offset(part.Location); + lines.AddRange(GetDirectionalLines(polygon, facingDirection)); + } + + return lines; + } + + public static List GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001) + { + var entities = ConvertProgram.ToGeometry(part.Program); + var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var lines = new List(); + + foreach (var shape in shapes) + { + // Add chord tolerance to compensate for inscribed polygon chords + // being inside the actual offset arcs. + var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape; + + if (offsetEntity == null) + continue; + + var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance); + polygon.RemoveSelfIntersections(); + polygon.Offset(part.Location); + lines.AddRange(polygon.ToLines()); + } + + return lines; + } + + public static List GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001) + { + var entities = ConvertProgram.ToGeometry(part.Program); + var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var lines = new List(); + + foreach (var shape in shapes) + { + var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape; + + if (offsetEntity == null) + continue; + + var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance); + polygon.RemoveSelfIntersections(); + polygon.Offset(part.Location); + lines.AddRange(GetDirectionalLines(polygon, facingDirection)); + } + + return lines; + } + + /// + /// Returns only polygon edges whose outward normal faces the specified direction. + /// + private static List GetDirectionalLines(Polygon polygon, PushDirection facingDirection) + { + if (polygon.Vertices.Count < 3) + return polygon.ToLines(); + + var sign = polygon.RotationDirection() == RotationType.CCW ? 1.0 : -1.0; + var lines = new List(); + var last = polygon.Vertices[0]; + + for (int i = 1; i < polygon.Vertices.Count; i++) + { + var current = polygon.Vertices[i]; + var dx = current.X - last.X; + var dy = current.Y - last.Y; + + bool keep; + + switch (facingDirection) + { + case PushDirection.Left: keep = -sign * dy > 0; break; + case PushDirection.Right: keep = sign * dy > 0; break; + case PushDirection.Up: keep = -sign * dx > 0; break; + case PushDirection.Down: keep = sign * dx > 0; break; + default: keep = true; break; + } + + if (keep) + lines.Add(new Line(last, current)); + + last = current; + } + + return lines; + } + } +} diff --git a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs index 395025f..26a6db4 100644 --- a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs +++ b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs @@ -34,8 +34,8 @@ namespace OpenNest.Engine.BestFit var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation); var halfSpacing = spacing / 2; - var part1Lines = Helper.GetOffsetPartLines(part1, halfSpacing); - var part2TemplateLines = Helper.GetOffsetPartLines(part2Template, halfSpacing); + var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing); + var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing); var bbox1 = part1.BoundingBox; var bbox2 = part2Template.BoundingBox; diff --git a/OpenNest.Engine/Compactor.cs b/OpenNest.Engine/Compactor.cs index 89f4018..364fb26 100644 --- a/OpenNest.Engine/Compactor.cs +++ b/OpenNest.Engine/Compactor.cs @@ -129,12 +129,12 @@ namespace OpenNest continue; movingLines ??= halfSpacing > 0 - ? Helper.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance) - : Helper.GetPartLines(moving, direction, ChordTolerance); + ? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance) + : PartGeometry.GetPartLines(moving, direction, ChordTolerance); obstacleLines[i] ??= halfSpacing > 0 - ? Helper.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance) - : Helper.GetPartLines(obstacleParts[i], opposite, ChordTolerance); + ? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance) + : PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance); var d = Helper.DirectionalDistance(movingLines, obstacleLines[i], direction); if (d < distance) From 13b01240b19410d9e257a9b3f05f9103ce046608 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 17:46:14 -0400 Subject: [PATCH 065/116] refactor: extract SpatialQuery from Helper Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/SpatialQuery.cs | 614 ++++++++++++++++++ OpenNest.Core/Helper.cs | 612 ----------------- .../BestFit/RotationSlideStrategy.cs | 12 +- OpenNest.Engine/Compactor.cs | 14 +- OpenNest.Engine/FillLinear.cs | 8 +- OpenNest/Actions/ActionClone.cs | 4 +- OpenNest/Actions/ActionSelectArea.cs | 4 +- 7 files changed, 635 insertions(+), 633 deletions(-) create mode 100644 OpenNest.Core/Geometry/SpatialQuery.cs diff --git a/OpenNest.Core/Geometry/SpatialQuery.cs b/OpenNest.Core/Geometry/SpatialQuery.cs new file mode 100644 index 0000000..dca54cf --- /dev/null +++ b/OpenNest.Core/Geometry/SpatialQuery.cs @@ -0,0 +1,614 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Math; + +namespace OpenNest.Geometry +{ + public static class SpatialQuery + { + /// + /// Finds the distance from a vertex to a line segment along a push axis. + /// Returns double.MaxValue if the ray does not hit the segment. + /// + private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction) + { + return RayEdgeDistance( + vertex.X, vertex.Y, + edge.pt1.X, edge.pt1.Y, edge.pt2.X, edge.pt2.Y, + direction); + } + + [System.Runtime.CompilerServices.MethodImpl( + System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static double RayEdgeDistance( + double vx, double vy, + double p1x, double p1y, double p2x, double p2y, + PushDirection direction) + { + switch (direction) + { + case PushDirection.Left: + case PushDirection.Right: + { + var dy = p2y - p1y; + if (System.Math.Abs(dy) < Tolerance.Epsilon) + return double.MaxValue; + + var t = (vy - p1y) / dy; + if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) + return double.MaxValue; + + var ix = p1x + t * (p2x - p1x); + var dist = direction == PushDirection.Left ? vx - ix : ix - vx; + + if (dist > Tolerance.Epsilon) return dist; + if (dist >= -Tolerance.Epsilon) return 0; + return double.MaxValue; + } + + case PushDirection.Down: + case PushDirection.Up: + { + var dx = p2x - p1x; + if (System.Math.Abs(dx) < Tolerance.Epsilon) + return double.MaxValue; + + var t = (vx - p1x) / dx; + if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) + return double.MaxValue; + + var iy = p1y + t * (p2y - p1y); + var dist = direction == PushDirection.Down ? vy - iy : iy - vy; + + if (dist > Tolerance.Epsilon) return dist; + if (dist >= -Tolerance.Epsilon) return 0; + return double.MaxValue; + } + + default: + return double.MaxValue; + } + } + + /// + /// Computes the minimum translation distance along a push direction before + /// any edge of movingLines contacts any edge of stationaryLines. + /// Returns double.MaxValue if no collision path exists. + /// + public static double DirectionalDistance(List movingLines, List stationaryLines, PushDirection direction) + { + var minDist = double.MaxValue; + + // Case 1: Each moving vertex -> each stationary edge + var movingVertices = new HashSet(); + for (int i = 0; i < movingLines.Count; i++) + { + movingVertices.Add(movingLines[i].pt1); + movingVertices.Add(movingLines[i].pt2); + } + + var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count]; + for (int i = 0; i < stationaryLines.Count; i++) + stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2); + + // Sort edges for pruning if not already sorted (usually they aren't here) + if (direction == PushDirection.Left || direction == PushDirection.Right) + stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + + foreach (var mv in movingVertices) + { + var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction); + if (d < minDist) minDist = d; + } + + // Case 2: Each stationary vertex -> each moving edge (opposite direction) + var opposite = OppositeDirection(direction); + var stationaryVertices = new HashSet(); + for (int i = 0; i < stationaryLines.Count; i++) + { + stationaryVertices.Add(stationaryLines[i].pt1); + stationaryVertices.Add(stationaryLines[i].pt2); + } + + var movingEdges = new (Vector start, Vector end)[movingLines.Count]; + for (int i = 0; i < movingLines.Count; i++) + movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2); + + if (opposite == PushDirection.Left || opposite == PushDirection.Right) + movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + + foreach (var sv in stationaryVertices) + { + var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite); + if (d < minDist) minDist = d; + } + + return minDist; + } + + /// + /// Computes the minimum directional distance with the moving lines translated + /// by (movingDx, movingDy) without creating new Line objects. + /// + public static double DirectionalDistance( + List movingLines, double movingDx, double movingDy, + List stationaryLines, PushDirection direction) + { + var minDist = double.MaxValue; + var movingOffset = new Vector(movingDx, movingDy); + + // Case 1: Each moving vertex -> each stationary edge + var movingVertices = new HashSet(); + for (int i = 0; i < movingLines.Count; i++) + { + movingVertices.Add(movingLines[i].pt1 + movingOffset); + movingVertices.Add(movingLines[i].pt2 + movingOffset); + } + + var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count]; + for (int i = 0; i < stationaryLines.Count; i++) + stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2); + + if (direction == PushDirection.Left || direction == PushDirection.Right) + stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + + foreach (var mv in movingVertices) + { + var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction); + if (d < minDist) minDist = d; + } + + // Case 2: Each stationary vertex -> each moving edge (opposite direction) + var opposite = OppositeDirection(direction); + var stationaryVertices = new HashSet(); + for (int i = 0; i < stationaryLines.Count; i++) + { + stationaryVertices.Add(stationaryLines[i].pt1); + stationaryVertices.Add(stationaryLines[i].pt2); + } + + var movingEdges = new (Vector start, Vector end)[movingLines.Count]; + for (int i = 0; i < movingLines.Count; i++) + movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2); + + if (opposite == PushDirection.Left || opposite == PushDirection.Right) + movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); + else + movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); + + foreach (var sv in stationaryVertices) + { + var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); + if (d < minDist) minDist = d; + } + + return minDist; + } + + /// + /// Packs line segments into a flat double array [x1,y1,x2,y2, ...] for GPU transfer. + /// + public static double[] FlattenLines(List lines) + { + var result = new double[lines.Count * 4]; + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i]; + result[i * 4] = line.pt1.X; + result[i * 4 + 1] = line.pt1.Y; + result[i * 4 + 2] = line.pt2.X; + result[i * 4 + 3] = line.pt2.Y; + } + return result; + } + + /// + /// Computes the minimum directional distance using raw edge arrays and location offsets + /// to avoid all intermediate object allocations. + /// + public static double DirectionalDistance( + (Vector start, Vector end)[] movingEdges, Vector movingOffset, + (Vector start, Vector end)[] stationaryEdges, Vector stationaryOffset, + PushDirection direction) + { + var minDist = double.MaxValue; + + // Extract unique vertices from moving edges. + var movingVertices = new HashSet(); + for (var i = 0; i < movingEdges.Length; i++) + { + movingVertices.Add(movingEdges[i].start + movingOffset); + movingVertices.Add(movingEdges[i].end + movingOffset); + } + + // Case 1: Each moving vertex -> each stationary edge + foreach (var mv in movingVertices) + { + var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction); + if (d < minDist) minDist = d; + } + + // Case 2: Each stationary vertex -> each moving edge (opposite direction) + var opposite = OppositeDirection(direction); + var stationaryVertices = new HashSet(); + for (var i = 0; i < stationaryEdges.Length; i++) + { + stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset); + stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset); + } + + foreach (var sv in stationaryVertices) + { + var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); + if (d < minDist) minDist = d; + } + + return minDist; + } + + public static double OneWayDistance( + Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset, + PushDirection direction) + { + var minDist = double.MaxValue; + var vx = vertex.X; + var vy = vertex.Y; + + // Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary. + if (direction == PushDirection.Left || direction == PushDirection.Right) + { + for (var i = 0; i < edges.Length; i++) + { + var e1 = edges[i].start + edgeOffset; + var e2 = edges[i].end + edgeOffset; + + var minY = e1.Y < e2.Y ? e1.Y : e2.Y; + var maxY = e1.Y > e2.Y ? e1.Y : e2.Y; + + // Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY. + if (vy < minY - Tolerance.Epsilon) + break; + + if (vy > maxY + Tolerance.Epsilon) + continue; + + var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); + if (d < minDist) minDist = d; + } + } + else // Up/Down + { + for (var i = 0; i < edges.Length; i++) + { + var e1 = edges[i].start + edgeOffset; + var e2 = edges[i].end + edgeOffset; + + var minX = e1.X < e2.X ? e1.X : e2.X; + var maxX = e1.X > e2.X ? e1.X : e2.X; + + // Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX. + if (vx < minX - Tolerance.Epsilon) + break; + + if (vx > maxX + Tolerance.Epsilon) + continue; + + var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); + if (d < minDist) minDist = d; + } + } + + return minDist; + } + + public static PushDirection OppositeDirection(PushDirection direction) + { + switch (direction) + { + case PushDirection.Left: return PushDirection.Right; + case PushDirection.Right: return PushDirection.Left; + case PushDirection.Up: return PushDirection.Down; + case PushDirection.Down: return PushDirection.Up; + default: return direction; + } + } + + public static bool IsHorizontalDirection(PushDirection direction) + { + return direction is PushDirection.Left or PushDirection.Right; + } + + public static double EdgeDistance(Box box, Box boundary, PushDirection direction) + { + switch (direction) + { + case PushDirection.Left: return box.Left - boundary.Left; + case PushDirection.Right: return boundary.Right - box.Right; + case PushDirection.Up: return boundary.Top - box.Top; + case PushDirection.Down: return box.Bottom - boundary.Bottom; + default: return double.MaxValue; + } + } + + public static Vector DirectionToOffset(PushDirection direction, double distance) + { + switch (direction) + { + case PushDirection.Left: return new Vector(-distance, 0); + case PushDirection.Right: return new Vector(distance, 0); + case PushDirection.Up: return new Vector(0, distance); + case PushDirection.Down: return new Vector(0, -distance); + default: return new Vector(); + } + } + + public static double DirectionalGap(Box from, Box to, PushDirection direction) + { + switch (direction) + { + case PushDirection.Left: return from.Left - to.Right; + case PushDirection.Right: return to.Left - from.Right; + case PushDirection.Up: return to.Bottom - from.Top; + case PushDirection.Down: return from.Bottom - to.Top; + default: return double.MaxValue; + } + } + + public static double ClosestDistanceLeft(Box box, List boxes) + { + var closestDistance = double.MaxValue; + + for (int i = 0; i < boxes.Count; i++) + { + var compareBox = boxes[i]; + + RelativePosition pos; + + if (!box.IsHorizontalTo(compareBox, out pos)) + continue; + + if (pos != RelativePosition.Right) + continue; + + var distance = box.Left - compareBox.Right; + + if (distance < closestDistance) + closestDistance = distance; + } + + return closestDistance == double.MaxValue ? double.NaN : closestDistance; + } + + public static double ClosestDistanceRight(Box box, List boxes) + { + var closestDistance = double.MaxValue; + + for (int i = 0; i < boxes.Count; i++) + { + var compareBox = boxes[i]; + + RelativePosition pos; + + if (!box.IsHorizontalTo(compareBox, out pos)) + continue; + + if (pos != RelativePosition.Left) + continue; + + var distance = compareBox.Left - box.Right; + + if (distance < closestDistance) + closestDistance = distance; + } + + return closestDistance == double.MaxValue ? double.NaN : closestDistance; + } + + public static double ClosestDistanceUp(Box box, List boxes) + { + var closestDistance = double.MaxValue; + + for (int i = 0; i < boxes.Count; i++) + { + var compareBox = boxes[i]; + + RelativePosition pos; + + if (!box.IsVerticalTo(compareBox, out pos)) + continue; + + if (pos != RelativePosition.Bottom) + continue; + + var distance = compareBox.Bottom - box.Top; + + if (distance < closestDistance) + closestDistance = distance; + } + + return closestDistance == double.MaxValue ? double.NaN : closestDistance; + } + + public static double ClosestDistanceDown(Box box, List boxes) + { + var closestDistance = double.MaxValue; + + for (int i = 0; i < boxes.Count; i++) + { + var compareBox = boxes[i]; + + RelativePosition pos; + + if (!box.IsVerticalTo(compareBox, out pos)) + continue; + + if (pos != RelativePosition.Top) + continue; + + var distance = box.Bottom - compareBox.Top; + + if (distance < closestDistance) + closestDistance = distance; + } + + return closestDistance == double.MaxValue ? double.NaN : closestDistance; + } + + public static Box GetLargestBoxVertically(Vector pt, Box bounds, IEnumerable boxes) + { + var verticalBoxes = boxes.Where(b => !(b.Left > pt.X || b.Right < pt.X)).ToList(); + + #region Find Top/Bottom Limits + + var top = double.MaxValue; + var btm = double.MinValue; + + foreach (var box in verticalBoxes) + { + var boxBtm = box.Bottom; + var boxTop = box.Top; + + if (boxBtm > pt.Y && boxBtm < top) + top = boxBtm; + + else if (box.Top < pt.Y && boxTop > btm) + btm = boxTop; + } + + if (top == double.MaxValue) + { + if (bounds.Top > pt.Y) + top = bounds.Top; + else return Box.Empty; + } + + if (btm == double.MinValue) + { + if (bounds.Bottom < pt.Y) + btm = bounds.Bottom; + else return Box.Empty; + } + + #endregion + + var horizontalBoxes = boxes.Where(b => !(b.Bottom >= top || b.Top <= btm)).ToList(); + + #region Find Left/Right Limits + + var lft = double.MinValue; + var rgt = double.MaxValue; + + foreach (var box in horizontalBoxes) + { + var boxLft = box.Left; + var boxRgt = box.Right; + + if (boxLft > pt.X && boxLft < rgt) + rgt = boxLft; + + else if (boxRgt < pt.X && boxRgt > lft) + lft = boxRgt; + } + + if (rgt == double.MaxValue) + { + if (bounds.Right > pt.X) + rgt = bounds.Right; + else return Box.Empty; + } + + if (lft == double.MinValue) + { + if (bounds.Left < pt.X) + lft = bounds.Left; + else return Box.Empty; + } + + #endregion + + return new Box(lft, btm, rgt - lft, top - btm); + } + + public static Box GetLargestBoxHorizontally(Vector pt, Box bounds, IEnumerable boxes) + { + var horizontalBoxes = boxes.Where(b => !(b.Bottom > pt.Y || b.Top < pt.Y)).ToList(); + + #region Find Left/Right Limits + + var lft = double.MinValue; + var rgt = double.MaxValue; + + foreach (var box in horizontalBoxes) + { + var boxLft = box.Left; + var boxRgt = box.Right; + + if (boxLft > pt.X && boxLft < rgt) + rgt = boxLft; + + else if (boxRgt < pt.X && boxRgt > lft) + lft = boxRgt; + } + + if (rgt == double.MaxValue) + { + if (bounds.Right > pt.X) + rgt = bounds.Right; + else return Box.Empty; + } + + if (lft == double.MinValue) + { + if (bounds.Left < pt.X) + lft = bounds.Left; + else return Box.Empty; + } + + #endregion + + var verticalBoxes = boxes.Where(b => !(b.Left >= rgt || b.Right <= lft)).ToList(); + + #region Find Top/Bottom Limits + + var top = double.MaxValue; + var btm = double.MinValue; + + foreach (var box in verticalBoxes) + { + var boxBtm = box.Bottom; + var boxTop = box.Top; + + if (boxBtm > pt.Y && boxBtm < top) + top = boxBtm; + + else if (box.Top < pt.Y && boxTop > btm) + btm = boxTop; + } + + if (top == double.MaxValue) + { + if (bounds.Top > pt.Y) + top = bounds.Top; + else return Box.Empty; + } + + if (btm == double.MinValue) + { + if (bounds.Bottom < pt.Y) + btm = bounds.Bottom; + else return Box.Empty; + } + + #endregion + + return new Box(lft, btm, rgt - lft, top - btm); + } + } +} diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs index a41b50f..fd09a71 100644 --- a/OpenNest.Core/Helper.cs +++ b/OpenNest.Core/Helper.cs @@ -1,618 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using OpenNest.Converters; -using OpenNest.Geometry; -using OpenNest.Math; - namespace OpenNest { public static class Helper { - /// - /// Finds the distance from a vertex to a line segment along a push axis. - /// Returns double.MaxValue if the ray does not hit the segment. - /// - private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction) - { - return RayEdgeDistance( - vertex.X, vertex.Y, - edge.pt1.X, edge.pt1.Y, edge.pt2.X, edge.pt2.Y, - direction); - } - - [System.Runtime.CompilerServices.MethodImpl( - System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] - private static double RayEdgeDistance( - double vx, double vy, - double p1x, double p1y, double p2x, double p2y, - PushDirection direction) - { - switch (direction) - { - case PushDirection.Left: - case PushDirection.Right: - { - var dy = p2y - p1y; - if (System.Math.Abs(dy) < Tolerance.Epsilon) - return double.MaxValue; - - var t = (vy - p1y) / dy; - if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) - return double.MaxValue; - - var ix = p1x + t * (p2x - p1x); - var dist = direction == PushDirection.Left ? vx - ix : ix - vx; - - if (dist > Tolerance.Epsilon) return dist; - if (dist >= -Tolerance.Epsilon) return 0; - return double.MaxValue; - } - - case PushDirection.Down: - case PushDirection.Up: - { - var dx = p2x - p1x; - if (System.Math.Abs(dx) < Tolerance.Epsilon) - return double.MaxValue; - - var t = (vx - p1x) / dx; - if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) - return double.MaxValue; - - var iy = p1y + t * (p2y - p1y); - var dist = direction == PushDirection.Down ? vy - iy : iy - vy; - - if (dist > Tolerance.Epsilon) return dist; - if (dist >= -Tolerance.Epsilon) return 0; - return double.MaxValue; - } - - default: - return double.MaxValue; - } - } - - /// - /// Computes the minimum translation distance along a push direction before - /// any edge of movingLines contacts any edge of stationaryLines. - /// Returns double.MaxValue if no collision path exists. - /// - public static double DirectionalDistance(List movingLines, List stationaryLines, PushDirection direction) - { - var minDist = double.MaxValue; - - // Case 1: Each moving vertex -> each stationary edge - var movingVertices = new HashSet(); - for (int i = 0; i < movingLines.Count; i++) - { - movingVertices.Add(movingLines[i].pt1); - movingVertices.Add(movingLines[i].pt2); - } - - var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count]; - for (int i = 0; i < stationaryLines.Count; i++) - stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2); - - // Sort edges for pruning if not already sorted (usually they aren't here) - if (direction == PushDirection.Left || direction == PushDirection.Right) - stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); - else - stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); - - foreach (var mv in movingVertices) - { - var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction); - if (d < minDist) minDist = d; - } - - // Case 2: Each stationary vertex -> each moving edge (opposite direction) - var opposite = OppositeDirection(direction); - var stationaryVertices = new HashSet(); - for (int i = 0; i < stationaryLines.Count; i++) - { - stationaryVertices.Add(stationaryLines[i].pt1); - stationaryVertices.Add(stationaryLines[i].pt2); - } - - var movingEdges = new (Vector start, Vector end)[movingLines.Count]; - for (int i = 0; i < movingLines.Count; i++) - movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2); - - if (opposite == PushDirection.Left || opposite == PushDirection.Right) - movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); - else - movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); - - foreach (var sv in stationaryVertices) - { - var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite); - if (d < minDist) minDist = d; - } - - return minDist; - } - - /// - /// Computes the minimum directional distance with the moving lines translated - /// by (movingDx, movingDy) without creating new Line objects. - /// - public static double DirectionalDistance( - List movingLines, double movingDx, double movingDy, - List stationaryLines, PushDirection direction) - { - var minDist = double.MaxValue; - var movingOffset = new Vector(movingDx, movingDy); - - // Case 1: Each moving vertex -> each stationary edge - var movingVertices = new HashSet(); - for (int i = 0; i < movingLines.Count; i++) - { - movingVertices.Add(movingLines[i].pt1 + movingOffset); - movingVertices.Add(movingLines[i].pt2 + movingOffset); - } - - var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count]; - for (int i = 0; i < stationaryLines.Count; i++) - stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2); - - if (direction == PushDirection.Left || direction == PushDirection.Right) - stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); - else - stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); - - foreach (var mv in movingVertices) - { - var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction); - if (d < minDist) minDist = d; - } - - // Case 2: Each stationary vertex -> each moving edge (opposite direction) - var opposite = OppositeDirection(direction); - var stationaryVertices = new HashSet(); - for (int i = 0; i < stationaryLines.Count; i++) - { - stationaryVertices.Add(stationaryLines[i].pt1); - stationaryVertices.Add(stationaryLines[i].pt2); - } - - var movingEdges = new (Vector start, Vector end)[movingLines.Count]; - for (int i = 0; i < movingLines.Count; i++) - movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2); - - if (opposite == PushDirection.Left || opposite == PushDirection.Right) - movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray(); - else - movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); - - foreach (var sv in stationaryVertices) - { - var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); - if (d < minDist) minDist = d; - } - - return minDist; - } - - /// - /// Packs line segments into a flat double array [x1,y1,x2,y2, ...] for GPU transfer. - /// - public static double[] FlattenLines(List lines) - { - var result = new double[lines.Count * 4]; - for (int i = 0; i < lines.Count; i++) - { - var line = lines[i]; - result[i * 4] = line.pt1.X; - result[i * 4 + 1] = line.pt1.Y; - result[i * 4 + 2] = line.pt2.X; - result[i * 4 + 3] = line.pt2.Y; - } - return result; - } - - /// - /// Computes the minimum directional distance using raw edge arrays and location offsets - /// to avoid all intermediate object allocations. - /// - public static double DirectionalDistance( - (Vector start, Vector end)[] movingEdges, Vector movingOffset, - (Vector start, Vector end)[] stationaryEdges, Vector stationaryOffset, - PushDirection direction) - { - var minDist = double.MaxValue; - - // Extract unique vertices from moving edges. - var movingVertices = new HashSet(); - for (var i = 0; i < movingEdges.Length; i++) - { - movingVertices.Add(movingEdges[i].start + movingOffset); - movingVertices.Add(movingEdges[i].end + movingOffset); - } - - // Case 1: Each moving vertex -> each stationary edge - foreach (var mv in movingVertices) - { - var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction); - if (d < minDist) minDist = d; - } - - // Case 2: Each stationary vertex -> each moving edge (opposite direction) - var opposite = OppositeDirection(direction); - var stationaryVertices = new HashSet(); - for (var i = 0; i < stationaryEdges.Length; i++) - { - stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset); - stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset); - } - - foreach (var sv in stationaryVertices) - { - var d = OneWayDistance(sv, movingEdges, movingOffset, opposite); - if (d < minDist) minDist = d; - } - - return minDist; - } - - public static double OneWayDistance( - Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset, - PushDirection direction) - { - var minDist = double.MaxValue; - var vx = vertex.X; - var vy = vertex.Y; - - // Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary. - if (direction == PushDirection.Left || direction == PushDirection.Right) - { - for (var i = 0; i < edges.Length; i++) - { - var e1 = edges[i].start + edgeOffset; - var e2 = edges[i].end + edgeOffset; - - var minY = e1.Y < e2.Y ? e1.Y : e2.Y; - var maxY = e1.Y > e2.Y ? e1.Y : e2.Y; - - // Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY. - if (vy < minY - Tolerance.Epsilon) - break; - - if (vy > maxY + Tolerance.Epsilon) - continue; - - var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); - if (d < minDist) minDist = d; - } - } - else // Up/Down - { - for (var i = 0; i < edges.Length; i++) - { - var e1 = edges[i].start + edgeOffset; - var e2 = edges[i].end + edgeOffset; - - var minX = e1.X < e2.X ? e1.X : e2.X; - var maxX = e1.X > e2.X ? e1.X : e2.X; - - // Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX. - if (vx < minX - Tolerance.Epsilon) - break; - - if (vx > maxX + Tolerance.Epsilon) - continue; - - var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); - if (d < minDist) minDist = d; - } - } - - return minDist; - } - - public static PushDirection OppositeDirection(PushDirection direction) - { - switch (direction) - { - case PushDirection.Left: return PushDirection.Right; - case PushDirection.Right: return PushDirection.Left; - case PushDirection.Up: return PushDirection.Down; - case PushDirection.Down: return PushDirection.Up; - default: return direction; - } - } - - public static bool IsHorizontalDirection(PushDirection direction) - { - return direction is PushDirection.Left or PushDirection.Right; - } - - public static double EdgeDistance(Box box, Box boundary, PushDirection direction) - { - switch (direction) - { - case PushDirection.Left: return box.Left - boundary.Left; - case PushDirection.Right: return boundary.Right - box.Right; - case PushDirection.Up: return boundary.Top - box.Top; - case PushDirection.Down: return box.Bottom - boundary.Bottom; - default: return double.MaxValue; - } - } - - public static Vector DirectionToOffset(PushDirection direction, double distance) - { - switch (direction) - { - case PushDirection.Left: return new Vector(-distance, 0); - case PushDirection.Right: return new Vector(distance, 0); - case PushDirection.Up: return new Vector(0, distance); - case PushDirection.Down: return new Vector(0, -distance); - default: return new Vector(); - } - } - - public static double DirectionalGap(Box from, Box to, PushDirection direction) - { - switch (direction) - { - case PushDirection.Left: return from.Left - to.Right; - case PushDirection.Right: return to.Left - from.Right; - case PushDirection.Up: return to.Bottom - from.Top; - case PushDirection.Down: return from.Bottom - to.Top; - default: return double.MaxValue; - } - } - - public static double ClosestDistanceLeft(Box box, List boxes) - { - var closestDistance = double.MaxValue; - - for (int i = 0; i < boxes.Count; i++) - { - var compareBox = boxes[i]; - - RelativePosition pos; - - if (!box.IsHorizontalTo(compareBox, out pos)) - continue; - - if (pos != RelativePosition.Right) - continue; - - var distance = box.Left - compareBox.Right; - - if (distance < closestDistance) - closestDistance = distance; - } - - return closestDistance == double.MaxValue ? double.NaN : closestDistance; - } - - public static double ClosestDistanceRight(Box box, List boxes) - { - var closestDistance = double.MaxValue; - - for (int i = 0; i < boxes.Count; i++) - { - var compareBox = boxes[i]; - - RelativePosition pos; - - if (!box.IsHorizontalTo(compareBox, out pos)) - continue; - - if (pos != RelativePosition.Left) - continue; - - var distance = compareBox.Left - box.Right; - - if (distance < closestDistance) - closestDistance = distance; - } - - return closestDistance == double.MaxValue ? double.NaN : closestDistance; - } - - public static double ClosestDistanceUp(Box box, List boxes) - { - var closestDistance = double.MaxValue; - - for (int i = 0; i < boxes.Count; i++) - { - var compareBox = boxes[i]; - - RelativePosition pos; - - if (!box.IsVerticalTo(compareBox, out pos)) - continue; - - if (pos != RelativePosition.Bottom) - continue; - - var distance = compareBox.Bottom - box.Top; - - if (distance < closestDistance) - closestDistance = distance; - } - - return closestDistance == double.MaxValue ? double.NaN : closestDistance; - } - - public static double ClosestDistanceDown(Box box, List boxes) - { - var closestDistance = double.MaxValue; - - for (int i = 0; i < boxes.Count; i++) - { - var compareBox = boxes[i]; - - RelativePosition pos; - - if (!box.IsVerticalTo(compareBox, out pos)) - continue; - - if (pos != RelativePosition.Top) - continue; - - var distance = box.Bottom - compareBox.Top; - - if (distance < closestDistance) - closestDistance = distance; - } - - return closestDistance == double.MaxValue ? double.NaN : closestDistance; - } - - public static Box GetLargestBoxVertically(Vector pt, Box bounds, IEnumerable boxes) - { - var verticalBoxes = boxes.Where(b => !(b.Left > pt.X || b.Right < pt.X)).ToList(); - - #region Find Top/Bottom Limits - - var top = double.MaxValue; - var btm = double.MinValue; - - foreach (var box in verticalBoxes) - { - var boxBtm = box.Bottom; - var boxTop = box.Top; - - if (boxBtm > pt.Y && boxBtm < top) - top = boxBtm; - - else if (box.Top < pt.Y && boxTop > btm) - btm = boxTop; - } - - if (top == double.MaxValue) - { - if (bounds.Top > pt.Y) - top = bounds.Top; - else return Box.Empty; - } - - if (btm == double.MinValue) - { - if (bounds.Bottom < pt.Y) - btm = bounds.Bottom; - else return Box.Empty; - } - - #endregion - - var horizontalBoxes = boxes.Where(b => !(b.Bottom >= top || b.Top <= btm)).ToList(); - - #region Find Left/Right Limits - - var lft = double.MinValue; - var rgt = double.MaxValue; - - foreach (var box in horizontalBoxes) - { - var boxLft = box.Left; - var boxRgt = box.Right; - - if (boxLft > pt.X && boxLft < rgt) - rgt = boxLft; - - else if (boxRgt < pt.X && boxRgt > lft) - lft = boxRgt; - } - - if (rgt == double.MaxValue) - { - if (bounds.Right > pt.X) - rgt = bounds.Right; - else return Box.Empty; - } - - if (lft == double.MinValue) - { - if (bounds.Left < pt.X) - lft = bounds.Left; - else return Box.Empty; - } - - #endregion - - return new Box(lft, btm, rgt - lft, top - btm); - } - - public static Box GetLargestBoxHorizontally(Vector pt, Box bounds, IEnumerable boxes) - { - var horizontalBoxes = boxes.Where(b => !(b.Bottom > pt.Y || b.Top < pt.Y)).ToList(); - - #region Find Left/Right Limits - - var lft = double.MinValue; - var rgt = double.MaxValue; - - foreach (var box in horizontalBoxes) - { - var boxLft = box.Left; - var boxRgt = box.Right; - - if (boxLft > pt.X && boxLft < rgt) - rgt = boxLft; - - else if (boxRgt < pt.X && boxRgt > lft) - lft = boxRgt; - } - - if (rgt == double.MaxValue) - { - if (bounds.Right > pt.X) - rgt = bounds.Right; - else return Box.Empty; - } - - if (lft == double.MinValue) - { - if (bounds.Left < pt.X) - lft = bounds.Left; - else return Box.Empty; - } - - #endregion - - var verticalBoxes = boxes.Where(b => !(b.Left >= rgt || b.Right <= lft)).ToList(); - - #region Find Top/Bottom Limits - - var top = double.MaxValue; - var btm = double.MinValue; - - foreach (var box in verticalBoxes) - { - var boxBtm = box.Bottom; - var boxTop = box.Top; - - if (boxBtm > pt.Y && boxBtm < top) - top = boxBtm; - - else if (box.Top < pt.Y && boxTop > btm) - btm = boxTop; - } - - if (top == double.MaxValue) - { - if (bounds.Top > pt.Y) - top = bounds.Top; - else return Box.Empty; - } - - if (btm == double.MinValue) - { - if (bounds.Bottom < pt.Y) - btm = bounds.Bottom; - else return Box.Empty; - } - - #endregion - - return new Box(lft, btm, rgt - lft, top - btm); - } } } diff --git a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs index 26a6db4..9308ec3 100644 --- a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs +++ b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs @@ -128,8 +128,8 @@ namespace OpenNest.Engine.BestFit if (_slideComputer != null) { - var stationarySegments = Helper.FlattenLines(part1Lines); - var movingSegments = Helper.FlattenLines(part2TemplateLines); + var stationarySegments = SpatialQuery.FlattenLines(part1Lines); + var movingSegments = SpatialQuery.FlattenLines(part2TemplateLines); var offsets = new double[count * 2]; var directions = new int[count]; @@ -182,7 +182,7 @@ namespace OpenNest.Engine.BestFit sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray(); stationaryEdgesByDir[dir] = sEdges; - var opposite = Helper.OppositeDirection(dir); + var opposite = SpatialQuery.OppositeDirection(dir); var mEdges = new (Vector start, Vector end)[part2TemplateLines.Count]; for (var i = 0; i < part2TemplateLines.Count; i++) mEdges[i] = (part2TemplateLines[i].StartPoint, part2TemplateLines[i].EndPoint); @@ -204,21 +204,21 @@ namespace OpenNest.Engine.BestFit var sEdges = stationaryEdgesByDir[dir]; var mEdges = movingEdgesByDir[dir]; - var opposite = Helper.OppositeDirection(dir); + var opposite = SpatialQuery.OppositeDirection(dir); var minDist = double.MaxValue; // Case 1: Moving vertices -> Stationary edges foreach (var mv in movingVerticesArray) { - var d = Helper.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir); + var d = SpatialQuery.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir); if (d < minDist) minDist = d; } // Case 2: Stationary vertices -> Moving edges (translated) foreach (var sv in stationaryVerticesArray) { - var d = Helper.OneWayDistance(sv, mEdges, movingOffset, opposite); + var d = SpatialQuery.OneWayDistance(sv, mEdges, movingOffset, opposite); if (d < minDist) minDist = d; } diff --git a/OpenNest.Engine/Compactor.cs b/OpenNest.Engine/Compactor.cs index 364fb26..f7bc551 100644 --- a/OpenNest.Engine/Compactor.cs +++ b/OpenNest.Engine/Compactor.cs @@ -87,9 +87,9 @@ namespace OpenNest for (var i = 0; i < obstacleParts.Count; i++) obstacleBoxes[i] = obstacleParts[i].BoundingBox; - var opposite = Helper.OppositeDirection(direction); + var opposite = SpatialQuery.OppositeDirection(direction); var halfSpacing = plate.PartSpacing / 2; - var isHorizontal = Helper.IsHorizontalDirection(direction); + var isHorizontal = SpatialQuery.IsHorizontalDirection(direction); var workArea = plate.WorkArea(); var distance = double.MaxValue; @@ -98,7 +98,7 @@ namespace OpenNest foreach (var moving in movingParts) { - var edgeDist = Helper.EdgeDistance(moving.BoundingBox, workArea, direction); + var edgeDist = SpatialQuery.EdgeDistance(moving.BoundingBox, workArea, direction); if (edgeDist <= 0) distance = 0; else if (edgeDist < distance) @@ -113,11 +113,11 @@ namespace OpenNest // behind the moving part. The forward gap (gap < 0) is unreliable for // irregular shapes whose bounding boxes overlap even when the actual // geometry still has a valid contact in the push direction. - var reverseGap = Helper.DirectionalGap(movingBox, obstacleBoxes[i], opposite); + var reverseGap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], opposite); if (reverseGap > 0) continue; - var gap = Helper.DirectionalGap(movingBox, obstacleBoxes[i], direction); + var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction); if (gap >= distance) continue; @@ -136,7 +136,7 @@ namespace OpenNest ? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance) : PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance); - var d = Helper.DirectionalDistance(movingLines, obstacleLines[i], direction); + var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction); if (d < distance) distance = d; } @@ -144,7 +144,7 @@ namespace OpenNest if (distance < double.MaxValue && distance > 0) { - var offset = Helper.DirectionToOffset(direction, distance); + var offset = SpatialQuery.DirectionToOffset(direction, distance); foreach (var moving in movingParts) moving.Offset(offset); return distance; diff --git a/OpenNest.Engine/FillLinear.cs b/OpenNest.Engine/FillLinear.cs index 09c0ead..92e6415 100644 --- a/OpenNest.Engine/FillLinear.cs +++ b/OpenNest.Engine/FillLinear.cs @@ -82,9 +82,9 @@ namespace OpenNest var locationBOffset = MakeOffset(direction, bboxDim); // Use the most efficient array-based overload to avoid all allocations. - var slideDistance = Helper.DirectionalDistance( + var slideDistance = SpatialQuery.DirectionalDistance( boundary.GetEdges(pushDir), partA.Location + locationBOffset, - boundary.GetEdges(Helper.OppositeDirection(pushDir)), partA.Location, + boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location, pushDir); return ComputeCopyDistance(bboxDim, slideDistance); @@ -103,7 +103,7 @@ namespace OpenNest var bboxDim = GetDimension(patternA.BoundingBox, direction); var pushDir = GetPushDirection(direction); - var opposite = Helper.OppositeDirection(pushDir); + var opposite = SpatialQuery.OppositeDirection(pushDir); // Compute a starting offset large enough that every part-pair in // patternB has its offset geometry beyond patternA's offset geometry. @@ -143,7 +143,7 @@ namespace OpenNest for (var i = 0; i < patternA.Parts.Count; i++) { - var slideDistance = Helper.DirectionalDistance( + var slideDistance = SpatialQuery.DirectionalDistance( movingEdges[j], locationB, stationaryEdges[i], patternA.Parts[i].Location, pushDir); diff --git a/OpenNest/Actions/ActionClone.cs b/OpenNest/Actions/ActionClone.cs index 9abb583..793c6eb 100644 --- a/OpenNest/Actions/ActionClone.cs +++ b/OpenNest/Actions/ActionClone.cs @@ -186,8 +186,8 @@ namespace OpenNest.Actions boxes.Add(part.BoundingBox.Offset(plate.PartSpacing)); var pt = plateView.CurrentPoint; - var vertical = Helper.GetLargestBoxVertically(pt, bounds, boxes); - var horizontal = Helper.GetLargestBoxHorizontally(pt, bounds, boxes); + var vertical = SpatialQuery.GetLargestBoxVertically(pt, bounds, boxes); + var horizontal = SpatialQuery.GetLargestBoxHorizontally(pt, bounds, boxes); var bestArea = vertical; if (horizontal.Area() > vertical.Area()) diff --git a/OpenNest/Actions/ActionSelectArea.cs b/OpenNest/Actions/ActionSelectArea.cs index d65c46b..77b3ff3 100644 --- a/OpenNest/Actions/ActionSelectArea.cs +++ b/OpenNest/Actions/ActionSelectArea.cs @@ -150,8 +150,8 @@ namespace OpenNest.Actions private void UpdateSelectedArea() { SelectedArea = altSelect - ? Helper.GetLargestBoxHorizontally(plateView.CurrentPoint, Bounds, boxes) - : Helper.GetLargestBoxVertically(plateView.CurrentPoint, Bounds, boxes); + ? SpatialQuery.GetLargestBoxHorizontally(plateView.CurrentPoint, Bounds, boxes) + : SpatialQuery.GetLargestBoxVertically(plateView.CurrentPoint, Bounds, boxes); plateView.Invalidate(); } From e017723318d7a952feda8a4a3f1b201d5ecd1133 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 17:46:36 -0400 Subject: [PATCH 066/116] refactor: remove empty Helper class Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Helper.cs | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 OpenNest.Core/Helper.cs diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs deleted file mode 100644 index fd09a71..0000000 --- a/OpenNest.Core/Helper.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace OpenNest -{ - public static class Helper - { - } -} From 93bf15c27fe21592579b0e66d8a75d42b3f31174 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 17:49:53 -0400 Subject: [PATCH 067/116] chore: remove redundant using in Rounding.cs Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Math/Rounding.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/OpenNest.Core/Math/Rounding.cs b/OpenNest.Core/Math/Rounding.cs index 28892b2..8f346ce 100644 --- a/OpenNest.Core/Math/Rounding.cs +++ b/OpenNest.Core/Math/Rounding.cs @@ -1,5 +1,3 @@ -using OpenNest.Math; - namespace OpenNest.Math { public static class Rounding From 56a298d95c8d450a3457eab4a10c407a27730cde Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 17:55:55 -0400 Subject: [PATCH 068/116] docs: update CLAUDE.md with current architecture Reflects ~120 commits of changes: new projects (Console, Gpu, Training), NFP best-fit pipeline, ML angle prediction, Compactor, CuttingStrategy, JSON nest format, async progress/cancellation, and Helper decomposition into focused classes (Intersect, ShapeBuilder, GeometryOptimizer, SpatialQuery, PartGeometry, Rounding). Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 51 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 86fe8c2..04692f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines. +OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using NFP-based (No Fit Polygon) and rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines. ## Build @@ -14,41 +14,55 @@ This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-wind dotnet build OpenNest.sln ``` -NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp). - -No test projects exist in this solution. +NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training). ## Architecture -Five projects form a layered architecture: +Eight projects form a layered architecture: ### OpenNest.Core (class library) Domain model, geometry, and CNC primitives organized into namespaces: -- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `Helper`, `Align`, `Sequence`, `Timing`. +- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`. - **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`). Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. -- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. +- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, and `RotatingCalipers`. - **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental). -- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`. Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed. +- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed. +- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration. - **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList`, `DrawingCollection`. - **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning. ### OpenNest.Engine (class library, depends on Core) -Nesting algorithms. `NestEngine` orchestrates filling plates with parts. +Nesting algorithms. `NestEngine` orchestrates filling plates with parts. `AutoNester` handles multi-plate auto-nesting. +- **BestFit/**: NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups. - **RectanglePacking/**: `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions. - **CirclePacking/**: Alternative packing for circular parts. +- **ML/**: `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data). +- `FillLinear`: Grid-based fill with directional sliding. +- `Compactor`: Post-fill gravity compaction — pushes parts toward a plate edge to close gaps. +- `FillScore`: Lexicographic comparison struct for fill results (count > utilization > compactness). - `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints. -- `BestCombination`: Finds optimal mix of normal/rotated columns for grid fills. +- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback. +- `RotationAnalysis`: Analyzes part geometry to determine valid rotation angles. ### OpenNest.IO (class library, depends on Core) File I/O and format conversion. Uses ACadSharp for DXF/DWG support. - `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp. -- `NestReader`/`NestWriter` — custom ZIP-based nest format (XML metadata + G-code programs). +- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format). - `ProgramReader` — G-code text parser. - `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types. +### OpenNest.Console (console app, depends on Core + Engine + IO) +Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`). + +### OpenNest.Gpu (class library, depends on Core + Engine) +GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods. + +### OpenNest.Training (console app, depends on Core + Engine) +Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training. + ### OpenNest.Mcp (console app, depends on Core + Engine + IO) MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`. @@ -62,16 +76,16 @@ MCP server for Claude Code integration. Exposes nesting operations as MCP tools The UI application with MDI interface. - **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc. -- **Controls/**: `PlateView` (2D plate renderer with zoom/pan), `DrawingListBox`, `DrawControl`, `QuadrantSelect`. -- **Actions/**: User interaction modes — `ActionSelect`, `ActionAddPart`, `ActionClone`, `ActionFillArea`, `ActionZoomWindow`, `ActionSetSequence`. +- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`. +- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`. - **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime. ## File Format -Nest files (`.zip`) contain: -- `info` — XML with nest metadata and plate defaults -- `drawing-info` — XML with drawing metadata (name, material, quantities, colors) -- `plate-info` — XML with plate metadata (size, material, spacing) +Nest files (`.nest`, ZIP-based) use v2 JSON format: +- `info.json` — nest metadata and plate defaults +- `drawing-info.json` — drawing metadata (name, material, quantities, colors) +- `plate-info.json` — plate metadata (size, material, spacing) - `program-NNN` — G-code text for each drawing's cut program - `plate-NNN` — G-code text encoding part placements (G00 for position, G65 for sub-program call with rotation) @@ -89,3 +103,6 @@ Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary metho - `ObservableList` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings. - Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion). - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. +- Nesting uses async progress/cancellation: `IProgress` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`. +- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. +- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. From 69da8c4632c1466d35c2ec14a2f42ad1d1161c75 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 19:40:09 -0400 Subject: [PATCH 069/116] docs: add strip nester design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-15-strip-nester-design.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-15-strip-nester-design.md diff --git a/docs/superpowers/specs/2026-03-15-strip-nester-design.md b/docs/superpowers/specs/2026-03-15-strip-nester-design.md new file mode 100644 index 0000000..70523ae --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-strip-nester-design.md @@ -0,0 +1,133 @@ +# Strip Nester Design Spec + +## Problem + +The current multi-drawing nesting strategies (AutoNester with NFP/simulated annealing, sequential FillExact) produce scattered, unstructured layouts. For jobs with multiple part types, a structured strip-based approach can pack more densely by dedicating a tight strip to the highest-area drawing and filling the remnant with the rest. + +## Strategy Overview + +1. Pick the drawing that consumes the most plate area (bounding box area x quantity) as the "strip item." All others are "remainder items." +2. Try two orientations — bottom strip and left strip. +3. For each orientation, find the tightest strip that fits the strip item's full quantity. +4. Fill the remnant area with remainder items using existing fill strategies. +5. Compare both orientations. The denser overall result wins. + +## Algorithm Detail + +### Step 1: Select Strip Item + +Sort `NestItem`s by `Drawing.Program.BoundingBox().Area() * quantity` descending — bounding box area, not `Drawing.Area`, because the bounding box represents the actual plate space consumed by each part. The first item becomes the strip item. If quantity is 0 (unlimited), estimate max capacity from `workArea.Area() / bboxArea` as a stand-in for sorting. + +### Step 2: Estimate Initial Strip Height + +For the strip item, calculate at both 0 deg and 90 deg rotation. These two angles are sufficient since this is only an estimate for the shrink loop starting point — the actual fill in Step 3 uses `NestEngine.Fill` which tries many rotation angles internally. + +- Parts per row: `floor(stripLength / bboxWidth)` +- Rows needed: `ceil(quantity / partsPerRow)` +- Strip height: `rows * bboxHeight` + +Pick the rotation with the shorter strip height. The strip length is the work area dimension along the strip's long axis (work area width for bottom strip, work area length for left strip). + +### Step 3: Initial Fill + +Create a `Box` for the strip area: + +- **Bottom strip**: `(workArea.X, workArea.Y, workArea.Width, estimatedStripHeight)` +- **Left strip**: `(workArea.X, workArea.Y, estimatedStripWidth, workArea.Length)` + +Fill using `NestEngine.Fill(stripItem, stripBox)`. Measure the actual strip dimension from placed parts: for a bottom strip, `actualStripHeight = placedParts.GetBoundingBox().Top - workArea.Y`; for a left strip, `actualStripWidth = placedParts.GetBoundingBox().Right - workArea.X`. This may be shorter than the estimate since FillLinear packs more efficiently than pure bounding-box grid. + +### Step 4: Shrink Loop + +Starting from the actual placed dimension (not the estimate), capped at 20 iterations: + +1. Reduce strip height by `plate.PartSpacing` (typically 0.25"). +2. Create new strip box with reduced dimension. +3. Fill with `NestEngine.Fill(stripItem, newStripBox)`. +4. If part count equals the initial fill count, record this as the new best and repeat. +5. If part count drops, stop. Use the previous iteration's result (tightest strip that still fits). + +For unlimited quantity (qty = 0), the initial fill count becomes the target. + +### Step 5: Remnant Fill + +Calculate the remnant box from the tightest strip's actual placed dimension, adding `plate.PartSpacing` between the strip and remnant to prevent spacing violations: + +- **Bottom strip remnant**: `(workArea.X, workArea.Y + actualStripHeight + partSpacing, workArea.Width, workArea.Length - actualStripHeight - partSpacing)` +- **Left strip remnant**: `(workArea.X + actualStripWidth + partSpacing, workArea.Y, workArea.Width - actualStripWidth - partSpacing, workArea.Length)` + +Fill remainder items in descending order by `bboxArea * quantity` (largest first, same as strip selection). If the strip item was only partially placed (fewer than target quantity), add the leftover quantity as a remainder item so it participates in the remnant fill. + +For each remainder item, fill using `NestEngine.Fill(remainderItem, remnantBox)`. + +### Step 6: Compare Orientations + +Score each orientation using `FillScore.Compute` over all placed parts (strip + remnant) against `plate.WorkArea()`. The orientation with the better `FillScore` wins. Apply the winning parts to the plate. + +## Classes + +### `StripNester` (new, `OpenNest.Engine`) + +```csharp +public class StripNester +{ + public StripNester(Plate plate) { } + + public List Nest(List items, + IProgress progress, + CancellationToken token); +} +``` + +**Constructor**: Takes the target plate (for work area, part spacing, quadrant). + +**`Nest` method**: Runs the full strategy. Returns the combined list of placed parts. The caller adds them to `plate.Parts`. Same instance-based pattern as `NestEngine`. + +### `StripNestResult` (new, internal, `OpenNest.Engine`) + +```csharp +internal class StripNestResult +{ + public List Parts { get; set; } = new(); + public Box StripBox { get; set; } + public Box RemnantBox { get; set; } + public FillScore Score { get; set; } + public StripDirection Direction { get; set; } +} +``` + +Holds intermediate results for comparing bottom vs left orientations. + +### `StripDirection` (new enum, `OpenNest.Engine`) + +```csharp +public enum StripDirection { Bottom, Left } +``` + +## Integration + +### MCP (`NestingTools`) + +`StripNester` becomes an additional strategy in the autonest flow. When multiple items are provided, both `StripNester` and the current approach run, and the better result wins. + +### UI (`AutoNestForm`) + +Can be offered as a strategy option alongside existing NFP-based auto-nesting. + +### No changes to `NestEngine` + +`StripNester` is a consumer of `NestEngine.Fill`, not a modification of it. + +## Edge Cases + +- **Single item**: Strategy reduces to strip optimization only (shrink loop with no remnant fill). Still valuable for finding the tightest area. +- **Strip item can't fill target quantity**: Use the partial result. Leftover quantity is added to remainder items for the remnant fill. +- **Remnant too small**: `NestEngine.Fill` returns empty naturally. No special handling needed. +- **Quantity = 0 (unlimited)**: Initial fill count becomes the shrink loop target. +- **Strip already one part tall**: Skip the shrink loop. + +## Dependencies + +- `NestEngine.Fill(NestItem, Box)` — existing API, no changes needed. +- `FillScore.Compute` — existing scoring, no changes needed. +- `Part.GetBoundingBox()` / list extensions — existing geometry utilities. From 7b01524934501c994f9167fa49dec4ab93f81b2a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 19:53:11 -0400 Subject: [PATCH 070/116] docs: add strip nester implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-15-strip-nester.md | 537 ++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-15-strip-nester.md diff --git a/docs/superpowers/plans/2026-03-15-strip-nester.md b/docs/superpowers/plans/2026-03-15-strip-nester.md new file mode 100644 index 0000000..f4f5b95 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-strip-nester.md @@ -0,0 +1,537 @@ +# Strip Nester Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a strip-based multi-drawing nesting strategy that dedicates a tight strip to the largest-area drawing and fills the remnant with remaining drawings. + +**Architecture:** New `StripNester` class in `OpenNest.Engine` that orchestrates strip optimization using `NestEngine.Fill` as a building block. Tries bottom and left strip orientations, finds the tightest strip via a shrink loop, fills remnants with remaining items, and picks the denser result. Integrated into `NestingTools` MCP as an additional strategy in `autonest_plate`. + +**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest.Mcp + +**Spec:** `docs/superpowers/specs/2026-03-15-strip-nester-design.md` + +--- + +## Chunk 1: Core StripNester + +### Task 1: Create StripDirection enum + +**Files:** +- Create: `OpenNest.Engine/StripDirection.cs` + +- [ ] **Step 1: Create the enum file** + +```csharp +namespace OpenNest +{ + public enum StripDirection + { + Bottom, + Left + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/StripDirection.cs +git commit -m "feat: add StripDirection enum" +``` + +--- + +### Task 2: Create StripNestResult internal class + +**Files:** +- Create: `OpenNest.Engine/StripNestResult.cs` + +- [ ] **Step 1: Create the result class** + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest +{ + internal class StripNestResult + { + public List Parts { get; set; } = new(); + public Box StripBox { get; set; } + public Box RemnantBox { get; set; } + public FillScore Score { get; set; } + public StripDirection Direction { get; set; } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/StripNestResult.cs +git commit -m "feat: add StripNestResult internal class" +``` + +--- + +### Task 3: Create StripNester class — strip item selection and initial strip height estimation + +**Files:** +- Create: `OpenNest.Engine/StripNester.cs` + +This task creates the class with the constructor and the helper methods for selecting the strip item and estimating the initial strip dimensions. The main `Nest` method is added in the next task. + +- [ ] **Step 1: Create StripNester with selection and estimation logic** + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + public class StripNester + { + private const int MaxShrinkIterations = 20; + + public StripNester(Plate plate) + { + Plate = plate; + } + + public Plate Plate { get; } + + /// + /// Selects the item that consumes the most plate area (bounding box area x quantity). + /// Returns the index into the items list. + /// + private static int SelectStripItemIndex(List items, Box workArea) + { + var bestIndex = 0; + var bestArea = 0.0; + + for (var i = 0; i < items.Count; i++) + { + var bbox = items[i].Drawing.Program.BoundingBox(); + var qty = items[i].Quantity > 0 + ? items[i].Quantity + : (int)(workArea.Area() / bbox.Area()); + var totalArea = bbox.Area() * qty; + + if (totalArea > bestArea) + { + bestArea = totalArea; + bestIndex = i; + } + } + + return bestIndex; + } + + /// + /// Estimates the strip dimension (height for bottom, width for left) needed + /// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter. + /// This is only an estimate for the shrink loop starting point — the actual fill + /// uses NestEngine.Fill which tries many rotation angles internally. + /// + private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension) + { + var bbox = item.Drawing.Program.BoundingBox(); + var qty = item.Quantity > 0 + ? item.Quantity + : System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area())); + + // At 0 deg: parts per row along strip length, strip dimension is bbox.Length + var perRow0 = (int)(stripLength / bbox.Width); + var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue; + var dim0 = rows0 * bbox.Length; + + // At 90 deg: rotated bounding box (Width and Length swap) + var perRow90 = (int)(stripLength / bbox.Length); + var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue; + var dim90 = rows90 * bbox.Width; + + var estimate = System.Math.Min(dim0, dim90); + + // Clamp to available dimension + return System.Math.Min(estimate, maxDimension); + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/StripNester.cs +git commit -m "feat: add StripNester with strip selection and estimation" +``` + +--- + +### Task 4: Add the core Nest method and TryOrientation + +**Files:** +- Modify: `OpenNest.Engine/StripNester.cs` + +This is the main algorithm: tries both orientations, fills strip + remnant, compares results. + +Key detail: The remnant fill must shrink the remnant box after each item fill using `ComputeRemainderWithin` (same pattern as `AutoNestPlate` in `NestingTools.cs:293-306`) to prevent overlapping placements. + +- [ ] **Step 1: Add Nest, TryOrientation, and ComputeRemainderWithin methods** + +Add these methods to the `StripNester` class, after the `EstimateStripDimension` method: + +```csharp + public List Nest(List items, + IProgress progress, CancellationToken token) + { + if (items == null || items.Count == 0) + return new List(); + + var workArea = Plate.WorkArea(); + + // Select which item gets the strip treatment. + var stripIndex = SelectStripItemIndex(items, workArea); + var stripItem = items[stripIndex]; + var remainderItems = items.Where((_, i) => i != stripIndex).ToList(); + + // Try both orientations. + var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, token); + var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, token); + + // Pick the better result. + if (bottomResult.Score >= leftResult.Score) + return bottomResult.Parts; + + return leftResult.Parts; + } + + private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem, + List remainderItems, Box workArea, CancellationToken token) + { + var result = new StripNestResult { Direction = direction }; + + if (token.IsCancellationRequested) + return result; + + // Estimate initial strip dimension. + var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length; + var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width; + var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension); + + // Create the initial strip box. + var stripBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim) + : new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length); + + // Initial fill (does NOT add to plate — uses the 4-arg overload). + var engine = new NestEngine(Plate); + var stripParts = engine.Fill( + new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, + stripBox, null, token); + + if (stripParts == null || stripParts.Count == 0) + return result; + + // Measure actual strip dimension from placed parts. + var placedBox = stripParts.Cast().GetBoundingBox(); + var actualDim = direction == StripDirection.Bottom + ? placedBox.Top - workArea.Y + : placedBox.Right - workArea.X; + + var bestParts = stripParts; + var bestDim = actualDim; + var targetCount = stripParts.Count; + + // Shrink loop: reduce strip dimension by PartSpacing until count drops. + for (var i = 0; i < MaxShrinkIterations; i++) + { + if (token.IsCancellationRequested) + break; + + var trialDim = bestDim - Plate.PartSpacing; + if (trialDim <= 0) + break; + + var trialBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y, workArea.Width, trialDim) + : new Box(workArea.X, workArea.Y, trialDim, workArea.Length); + + var trialEngine = new NestEngine(Plate); + var trialParts = trialEngine.Fill( + new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, + trialBox, null, token); + + if (trialParts == null || trialParts.Count < targetCount) + break; + + // Same count in a tighter strip — keep going. + bestParts = trialParts; + var trialPlacedBox = trialParts.Cast().GetBoundingBox(); + bestDim = direction == StripDirection.Bottom + ? trialPlacedBox.Top - workArea.Y + : trialPlacedBox.Right - workArea.X; + } + + // Build remnant box with spacing gap. + var spacing = Plate.PartSpacing; + var remnantBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y + bestDim + spacing, + workArea.Width, workArea.Length - bestDim - spacing) + : new Box(workArea.X + bestDim + spacing, workArea.Y, + workArea.Width - bestDim - spacing, workArea.Length); + + // Collect all parts. + var allParts = new List(bestParts); + + // If strip item was only partially placed, add leftovers to remainder. + var placed = bestParts.Count; + var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0; + var effectiveRemainder = new List(remainderItems); + + if (leftover > 0) + { + effectiveRemainder.Add(new NestItem + { + Drawing = stripItem.Drawing, + Quantity = leftover + }); + } + + // Sort remainder by descending bounding box area x quantity. + effectiveRemainder = effectiveRemainder + .OrderByDescending(i => + { + var bb = i.Drawing.Program.BoundingBox(); + return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1); + }) + .ToList(); + + // Fill remnant with remainder items, shrinking the available area after each. + if (remnantBox.Width > 0 && remnantBox.Length > 0) + { + var currentRemnant = remnantBox; + + foreach (var item in effectiveRemainder) + { + if (token.IsCancellationRequested) + break; + + if (currentRemnant.Width <= 0 || currentRemnant.Length <= 0) + break; + + var remnantEngine = new NestEngine(Plate); + var remnantParts = remnantEngine.Fill( + new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, + currentRemnant, null, token); + + if (remnantParts != null && remnantParts.Count > 0) + { + allParts.AddRange(remnantParts); + + // Shrink remnant to avoid overlap with next item. + var usedBox = remnantParts.Cast().GetBoundingBox(); + currentRemnant = ComputeRemainderWithin(currentRemnant, usedBox, spacing); + } + } + } + + result.Parts = allParts; + result.StripBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y, workArea.Width, bestDim) + : new Box(workArea.X, workArea.Y, bestDim, workArea.Length); + result.RemnantBox = remnantBox; + result.Score = FillScore.Compute(allParts, workArea); + + return result; + } + + /// + /// Computes the largest usable remainder within a work area after a portion has been used. + /// Picks whichever is larger: the horizontal strip to the right, or the vertical strip above. + /// + private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) + { + var hWidth = workArea.Right - usedBox.Right - spacing; + var hStrip = hWidth > 0 + ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) + : Box.Empty; + + var vHeight = workArea.Top - usedBox.Top - spacing; + var vStrip = vHeight > 0 + ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) + : Box.Empty; + + return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; + } +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/StripNester.cs +git commit -m "feat: add StripNester.Nest with strip fill, shrink loop, and remnant fill" +``` + +--- + +## Chunk 2: MCP Integration + +### Task 5: Integrate StripNester into autonest_plate MCP tool + +**Files:** +- Modify: `OpenNest.Mcp/Tools/NestingTools.cs` + +Run the strip nester alongside the existing sequential approach. Both use the 4-arg `Fill` overload (no side effects), then the winner's parts are added to the plate. + +- [ ] **Step 1: Refactor AutoNestPlate to run both strategies** + +In `NestingTools.cs`, replace the fill/pack logic in `AutoNestPlate` (lines 236-278) with a strategy competition. The existing sequential logic is extracted to a `SequentialFill` helper. + +Replace lines 236-278 with: + +```csharp + // Strategy 1: Strip nesting + var stripNester = new StripNester(plate); + var stripResult = stripNester.Nest(items, null, CancellationToken.None); + var stripScore = FillScore.Compute(stripResult, plate.WorkArea()); + + // Strategy 2: Current sequential fill + var seqResult = SequentialFill(plate, items); + var seqScore = FillScore.Compute(seqResult, plate.WorkArea()); + + // Pick winner and apply to plate. + var winner = stripScore >= seqScore ? stripResult : seqResult; + var winnerName = stripScore >= seqScore ? "strip" : "sequential"; + plate.Parts.AddRange(winner); + var totalPlaced = winner.Count; +``` + +Update the output section (around line 280): + +```csharp + var sb = new StringBuilder(); + sb.AppendLine($"AutoNest plate {plateIndex} ({winnerName} strategy): {(totalPlaced > 0 ? "success" : "no parts placed")}"); + sb.AppendLine($" Parts placed: {totalPlaced}"); + sb.AppendLine($" Total parts: {plate.Parts.Count}"); + sb.AppendLine($" Utilization: {plate.Utilization():P1}"); + sb.AppendLine($" Strip score: {stripScore.Count} parts, density {stripScore.Density:P1}"); + sb.AppendLine($" Sequential score: {seqScore.Count} parts, density {seqScore.Density:P1}"); + + var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name); + foreach (var group in groups) + sb.AppendLine($" {group.Key}: {group.Count()}"); + + return sb.ToString(); +``` + +- [ ] **Step 2: Add the SequentialFill helper method** + +Add this private method to `NestingTools`. It mirrors the existing `AutoNestPlate` fill phase using the 4-arg `Fill` overload for side-effect-free comparison. + +```csharp + private static List SequentialFill(Plate plate, List items) + { + var fillItems = items + .Where(i => i.Quantity != 1) + .OrderBy(i => i.Priority) + .ThenByDescending(i => i.Drawing.Area) + .ToList(); + + var workArea = plate.WorkArea(); + var allParts = new List(); + + foreach (var item in fillItems) + { + if (item.Quantity == 0 || workArea.Width <= 0 || workArea.Length <= 0) + continue; + + var engine = new NestEngine(plate); + var parts = engine.Fill( + new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, + workArea, null, CancellationToken.None); + + if (parts.Count > 0) + { + allParts.AddRange(parts); + var placedBox = parts.Cast().GetBoundingBox(); + workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); + } + } + + return allParts; + } +``` + +- [ ] **Step 3: Add required using statement** + +Add `using System.Threading;` to the top of `NestingTools.cs` if not already present. + +- [ ] **Step 4: Build the full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Mcp/Tools/NestingTools.cs +git commit -m "feat: integrate StripNester into autonest_plate MCP tool" +``` + +--- + +## Chunk 3: Publish and Test + +### Task 6: Publish MCP server and test with real parts + +**Files:** +- No code changes — publish and manual testing + +- [ ] **Step 1: Publish OpenNest.Mcp** + +Run: `dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"` +Expected: Build and publish succeeded + +- [ ] **Step 2: Test with SULLYS parts** + +Using the MCP tools, test the strip nester with the SULLYS-001 and SULLYS-002 parts: + +1. Load the test nest file or import the DXF files +2. Create a 60x120 plate +3. Run `autonest_plate` with both drawings at qty 10 +4. Verify the output reports which strategy won (strip vs sequential) +5. Verify the output shows scores for both strategies +6. Check plate info for part placement and utilization + +- [ ] **Step 3: Compare with current results** + +Verify the strip nester produces a result matching or improving on the target layout from screenshot 190519 (all 20 parts on one 60x120 plate with organized strip arrangement). + +- [ ] **Step 4: Commit any fixes** + +If issues are found during testing, fix and commit with descriptive messages. From 6c2810ef8041530bf2c6b726a08ee660b04fb667 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 20:29:29 -0400 Subject: [PATCH 071/116] docs: add abstract nest engine design spec Pluggable engine architecture with NestEngineBase, DefaultNestEngine, registry with plugin loading, and global engine switching. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-15-abstract-nest-engine-design.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md diff --git a/docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md b/docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md new file mode 100644 index 0000000..5dae15b --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md @@ -0,0 +1,184 @@ +# Abstract Nest Engine Design Spec + +**Date:** 2026-03-15 +**Goal:** Create a pluggable nest engine architecture so users can create custom nesting algorithms, switch between engines globally, and load third-party engines as plugins. + +--- + +## Motivation + +The current `NestEngine` is a concrete class with a sophisticated multi-phase fill strategy (Linear, Pairs, RectBestFit, Remainder). Different part geometries benefit from different algorithms — circles need circle-packing, strip-based layouts work better for mixed-drawing nests, and users may want to experiment with their own approaches. The engine needs to be swappable without changing the UI or other consumers. + +## Architecture Overview + +``` +NestEngineBase (abstract, OpenNest.Engine) +├── DefaultNestEngine (current multi-phase logic) +├── StripNestEngine (strip-based multi-drawing nesting) +├── CircleNestEngine (future, circle-packing) +└── [Plugin engines loaded from DLLs] + +NestEngineRegistry (static, OpenNest.Engine) +├── Tracks available engines (built-in + plugins) +├── Manages active engine selection (global) +└── Factory method: Create(Plate) → NestEngineBase +``` + +## NestEngineBase + +Abstract base class in `OpenNest.Engine`. Provides the contract, shared state, and utility methods. + +### Properties + +| Property | Type | Notes | +|----------|------|-------| +| `Plate` | `Plate` | The plate being nested | +| `PlateNumber` | `int` | For progress reporting | +| `NestDirection` | `NestDirection` | Fill direction preference | +| `WinnerPhase` | `NestPhase` | Which phase produced the best result (private set) | +| `PhaseResults` | `List` | Per-phase results for diagnostics | +| `AngleResults` | `List` | Per-angle results for diagnostics | + +### Abstract Members + +| Member | Type | Purpose | +|--------|------|---------| +| `Name` | `string` (get) | Display name for UI/registry | +| `Description` | `string` (get) | Human-readable description | + +### Virtual Methods (return parts, no side effects) + +These are the core methods subclasses override: + +```csharp +virtual List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + +virtual List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + +virtual List FillExact(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + +virtual List PackArea(Box box, List items, + IProgress progress, CancellationToken token) +``` + +Default implementations in the base class delegate to `DefaultNestEngine`'s logic (or return empty lists — see DefaultNestEngine below). + +### Convenience Overloads (non-virtual, add parts to plate) + +These call the virtual methods and handle plate mutation: + +```csharp +bool Fill(NestItem item) +bool Fill(NestItem item, Box workArea) +bool Fill(List groupParts) +bool Fill(List groupParts, Box workArea) +bool Pack(List items) +``` + +Pattern: call the virtual method → if parts returned → add to `Plate.Parts` → return `true`. + +### Protected Utilities + +Available to all subclasses: + +- `ReportProgress(IProgress, NestPhase, int plateNumber, List, Box, string)` — clone parts and report +- `BuildProgressSummary()` — format PhaseResults into a status string +- `IsBetterFill(List candidate, List current, Box workArea)` — FillScore comparison +- `IsBetterValidFill(List candidate, List current, Box workArea)` — with overlap check + +## DefaultNestEngine + +Rename of the current `NestEngine`. Inherits `NestEngineBase` and overrides all virtual methods with the existing multi-phase logic. + +- `Name` → `"Default"` +- `Description` → `"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)"` +- All current private methods (`FindBestFill`, `FillWithPairs`, `FillRectangleBestFit`, `FillPattern`, `TryRemainderImprovement`, `BuildCandidateAngles`, etc.) remain as private methods in this class +- No behavioral change — purely structural refactor + +## StripNestEngine + +The planned `StripNester` (from the strip nester spec) becomes a `NestEngineBase` subclass instead of a standalone class. + +- `Name` → `"Strip"` +- `Description` → `"Strip-based nesting for mixed-drawing layouts"` +- Overrides `Fill` for multi-item scenarios with its strip+remnant strategy +- Uses `DefaultNestEngine` internally as a building block for individual strip/remnant fills (composition, not inheritance from Default) + +## NestEngineRegistry + +Static class in `OpenNest.Engine` managing engine discovery and selection. + +### NestEngineInfo + +```csharp +class NestEngineInfo +{ + string Name { get; } + string Description { get; } + Func Factory { get; } +} +``` + +### API + +| Member | Purpose | +|--------|---------| +| `List AvailableEngines` | All registered engines | +| `string ActiveEngineName` | Currently selected engine (defaults to `"Default"`) | +| `NestEngineBase Create(Plate plate)` | Creates instance of active engine | +| `void Register(string name, string description, Func factory)` | Register a built-in engine | +| `void LoadPlugins(string directory)` | Scan DLLs for NestEngineBase subclasses | + +### Built-in Registration + +```csharp +Register("Default", "Multi-phase nesting...", plate => new DefaultNestEngine(plate)); +Register("Strip", "Strip-based nesting...", plate => new StripNestEngine(plate)); +``` + +### Plugin Discovery + +Follows the existing `IPostProcessor` pattern from `Posts/`: +- Scan `Engines/` directory next to the executable for DLLs +- Reflect over types, find concrete subclasses of `NestEngineBase` +- Require a constructor taking `Plate` +- Register each with its `Name` and `Description` properties +- Called at application startup alongside post-processor loading + +## Callsite Migration + +All `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`: + +| Location | Count | Notes | +|----------|-------|-------| +| `MainForm.cs` | 3 | Auto-nest, fill plate, fill area | +| `ActionFillArea.cs` | 2 | | +| `PlateView.cs` | 1 | | +| `NestingTools.cs` (MCP) | 6 | | +| `Program.cs` (Console) | 3 | | +| `BruteForceRunner.cs` | 1 | **Keep as `new DefaultNestEngine(plate)`** — training data must come from the known algorithm | + +## UI Integration + +- Global engine selector: combobox or menu item bound to `NestEngineRegistry.AvailableEngines` +- Changing selection sets `NestEngineRegistry.ActiveEngineName` +- No per-plate engine state — global setting applies to all subsequent operations +- Plugin directory: `Engines/` next to executable, loaded at startup + +## File Summary + +| Action | File | Project | +|--------|------|---------| +| Create | `NestEngineBase.cs` | OpenNest.Engine | +| Rename/Modify | `NestEngine.cs` → `DefaultNestEngine.cs` | OpenNest.Engine | +| Create | `NestEngineRegistry.cs` | OpenNest.Engine | +| Create | `NestEngineInfo.cs` | OpenNest.Engine | +| Modify | `StripNester.cs` → `StripNestEngine.cs` | OpenNest.Engine | +| Modify | `MainForm.cs` | OpenNest | +| Modify | `ActionFillArea.cs` | OpenNest | +| Modify | `PlateView.cs` | OpenNest | +| Modify | `NestingTools.cs` | OpenNest.Mcp | +| Modify | `Program.cs` | OpenNest.Console | From 068de63e83c6f20fffdd507cb9c464a48bbfac4a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 20:32:35 -0400 Subject: [PATCH 072/116] docs: address spec review feedback for abstract nest engine Fix MainForm callsite descriptions, clarify default implementations return empty lists, make FillExact non-virtual, document PackArea signature refactor, add AutoNester scope note, specify error handling for plugin loading, document thread safety and instance lifetime. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-15-abstract-nest-engine-design.md | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md b/docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md index 5dae15b..4d8904f 100644 --- a/docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md +++ b/docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md @@ -24,18 +24,22 @@ NestEngineRegistry (static, OpenNest.Engine) └── Factory method: Create(Plate) → NestEngineBase ``` +**Note on AutoNester:** The existing `AutoNester` static class (NFP + simulated annealing for mixed parts) is a natural future candidate for the registry but is currently unused by any caller. It is out of scope for this refactor — it can be wrapped as an engine later when it's ready for use. + ## NestEngineBase Abstract base class in `OpenNest.Engine`. Provides the contract, shared state, and utility methods. +**Instance lifetime:** Engine instances are short-lived and plate-specific — created per operation via the registry factory. Some engines (like `DefaultNestEngine`) maintain internal state across multiple `Fill` calls on the same instance (e.g., `knownGoodAngles` for angle pruning). Plugin authors should be aware that a single engine instance may receive multiple `Fill` calls within one nesting session. + ### Properties | Property | Type | Notes | |----------|------|-------| | `Plate` | `Plate` | The plate being nested | | `PlateNumber` | `int` | For progress reporting | -| `NestDirection` | `NestDirection` | Fill direction preference | -| `WinnerPhase` | `NestPhase` | Which phase produced the best result (private set) | +| `NestDirection` | `NestDirection` | Fill direction preference, set by callers after creation | +| `WinnerPhase` | `NestPhase` | Which phase produced the best result (protected set) | | `PhaseResults` | `List` | Per-phase results for diagnostics | | `AngleResults` | `List` | Per-angle results for diagnostics | @@ -48,7 +52,7 @@ Abstract base class in `OpenNest.Engine`. Provides the contract, shared state, a ### Virtual Methods (return parts, no side effects) -These are the core methods subclasses override: +These are the core methods subclasses override. Base class default implementations return empty lists — subclasses override the ones they support. ```csharp virtual List Fill(NestItem item, Box workArea, @@ -57,14 +61,13 @@ virtual List Fill(NestItem item, Box workArea, virtual List Fill(List groupParts, Box workArea, IProgress progress, CancellationToken token) -virtual List FillExact(NestItem item, Box workArea, - IProgress progress, CancellationToken token) - virtual List PackArea(Box box, List items, IProgress progress, CancellationToken token) ``` -Default implementations in the base class delegate to `DefaultNestEngine`'s logic (or return empty lists — see DefaultNestEngine below). +**`FillExact` is non-virtual.** It is orchestration logic (binary search wrapper around `Fill`) that works regardless of the underlying fill algorithm. It lives in the base class and calls the virtual `Fill` method. Any engine that implements `Fill` gets `FillExact` for free. + +**`PackArea` signature change:** The current `PackArea(Box, List)` mutates the plate directly and returns `bool`. The new virtual method adds `IProgress` and `CancellationToken` parameters and returns `List` (side-effect-free). This is a deliberate refactor — the old mutating behavior moves to the convenience overload `Pack(List)`. ### Convenience Overloads (non-virtual, add parts to plate) @@ -95,7 +98,9 @@ Rename of the current `NestEngine`. Inherits `NestEngineBase` and overrides all - `Name` → `"Default"` - `Description` → `"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)"` -- All current private methods (`FindBestFill`, `FillWithPairs`, `FillRectangleBestFit`, `FillPattern`, `TryRemainderImprovement`, `BuildCandidateAngles`, etc.) remain as private methods in this class +- All current private methods (`FindBestFill`, `FillWithPairs`, `FillRectangleBestFit`, `FillPattern`, `TryRemainderImprovement`, `BuildCandidateAngles`, `QuickFillCount`, etc.) remain as private methods in this class +- `ForceFullAngleSweep` property stays on `DefaultNestEngine` (not the base class) — only used by `BruteForceRunner` which references `DefaultNestEngine` directly +- `knownGoodAngles` HashSet stays as a private field — accumulates across multiple `Fill` calls for angle pruning - No behavioral change — purely structural refactor ## StripNestEngine @@ -109,7 +114,7 @@ The planned `StripNester` (from the strip nester spec) becomes a `NestEngineBase ## NestEngineRegistry -Static class in `OpenNest.Engine` managing engine discovery and selection. +Static class in `OpenNest.Engine` managing engine discovery and selection. Accessed only from the UI thread — not thread-safe. Engines are created per-operation and used on background threads, but the registry itself is only mutated/queried from the UI thread at startup and when the user changes the active engine. ### NestEngineInfo @@ -146,7 +151,13 @@ Follows the existing `IPostProcessor` pattern from `Posts/`: - Reflect over types, find concrete subclasses of `NestEngineBase` - Require a constructor taking `Plate` - Register each with its `Name` and `Description` properties -- Called at application startup alongside post-processor loading +- Called at application startup alongside post-processor loading (WinForms app only — Console and MCP use built-in engines only) + +**Error handling:** +- DLLs that fail to load (bad assembly, missing dependencies) are logged and skipped +- Types without a `Plate` constructor are skipped +- Duplicate engine names: first registration wins, duplicates are logged and skipped +- Exceptions from plugin constructors during `Create()` are caught and surfaced to the caller ## Callsite Migration @@ -154,7 +165,7 @@ All `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`: | Location | Count | Notes | |----------|-------|-------| -| `MainForm.cs` | 3 | Auto-nest, fill plate, fill area | +| `MainForm.cs` | 3 | Auto-nest fill, auto-nest pack, single-drawing fill plate | | `ActionFillArea.cs` | 2 | | | `PlateView.cs` | 1 | | | `NestingTools.cs` (MCP) | 6 | | From 9bd262dec0c9b6bfd9662163a5af7fc5cc4ba8c3 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 20:59:51 -0400 Subject: [PATCH 073/116] docs: add abstract nest engine implementation plan 9 tasks across 4 chunks: NestEngineBase + DefaultNestEngine, NestEngineRegistry + NestEngineInfo, callsite migration (16 sites), verification and docs update. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-15-abstract-nest-engine.md | 867 ++++++++++++++++++ 1 file changed, 867 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-15-abstract-nest-engine.md diff --git a/docs/superpowers/plans/2026-03-15-abstract-nest-engine.md b/docs/superpowers/plans/2026-03-15-abstract-nest-engine.md new file mode 100644 index 0000000..e7bc230 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-abstract-nest-engine.md @@ -0,0 +1,867 @@ +# Abstract Nest Engine Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor the concrete `NestEngine` into an abstract `NestEngineBase` with pluggable implementations, a registry for engine discovery/selection, and plugin loading from DLLs. + +**Architecture:** Extract shared state and utilities into `NestEngineBase` (abstract). Current logic becomes `DefaultNestEngine`. `NestEngineRegistry` provides factory creation, built-in registration, and DLL plugin discovery. All callsites migrate from `new NestEngine(plate)` to `NestEngineRegistry.Create(plate)`. + +**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest (WinForms), OpenNest.Mcp, OpenNest.Console + +**Spec:** `docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md` + +**Deferred:** `StripNester.cs` → `StripNestEngine.cs` conversion is deferred to the strip nester implementation plan (`docs/superpowers/plans/2026-03-15-strip-nester.md`). That plan should be updated to create `StripNestEngine` as a `NestEngineBase` subclass and register it in `NestEngineRegistry`. The UI engine selector combobox is also deferred — it can be added once there are multiple engines to choose from. + +--- + +## Chunk 1: NestEngineBase and DefaultNestEngine + +### Task 1: Create NestEngineBase abstract class + +**Files:** +- Create: `OpenNest.Engine/NestEngineBase.cs` + +This is the abstract base class. It holds shared properties, abstract `Name`/`Description`, virtual methods that return empty lists by default, convenience overloads that mutate the plate, `FillExact` (non-virtual), and protected utility methods extracted from the current `NestEngine`. + +- [ ] **Step 1: Create NestEngineBase.cs** + +```csharp +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest +{ + public abstract class NestEngineBase + { + protected NestEngineBase(Plate plate) + { + Plate = plate; + } + + public Plate Plate { get; set; } + + public int PlateNumber { get; set; } + + public NestDirection NestDirection { get; set; } + + public NestPhase WinnerPhase { get; protected set; } + + public List PhaseResults { get; } = new(); + + public List AngleResults { get; } = new(); + + public abstract string Name { get; } + + public abstract string Description { get; } + + // --- Virtual methods (side-effect-free, return parts) --- + + public virtual List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + return new List(); + } + + public virtual List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + { + return new List(); + } + + public virtual List PackArea(Box box, List items, + IProgress progress, CancellationToken token) + { + return new List(); + } + + // --- FillExact (non-virtual, delegates to virtual Fill) --- + + public List FillExact(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + return Fill(item, workArea, progress, token); + } + + // --- Convenience overloads (mutate plate, return bool) --- + + public bool Fill(NestItem item) + { + return Fill(item, Plate.WorkArea()); + } + + public bool Fill(NestItem item, Box workArea) + { + var parts = Fill(item, workArea, null, CancellationToken.None); + + if (parts == null || parts.Count == 0) + return false; + + Plate.Parts.AddRange(parts); + return true; + } + + public bool Fill(List groupParts) + { + return Fill(groupParts, Plate.WorkArea()); + } + + public bool Fill(List groupParts, Box workArea) + { + var parts = Fill(groupParts, workArea, null, CancellationToken.None); + + if (parts == null || parts.Count == 0) + return false; + + Plate.Parts.AddRange(parts); + return true; + } + + public bool Pack(List items) + { + var workArea = Plate.WorkArea(); + var parts = PackArea(workArea, items, null, CancellationToken.None); + + if (parts == null || parts.Count == 0) + return false; + + Plate.Parts.AddRange(parts); + return true; + } + + // --- Protected utilities --- + + protected static void ReportProgress( + IProgress progress, + NestPhase phase, + int plateNumber, + List best, + Box workArea, + string description) + { + if (progress == null || best == null || best.Count == 0) + return; + + var score = FillScore.Compute(best, workArea); + var clonedParts = new List(best.Count); + var totalPartArea = 0.0; + + foreach (var part in best) + { + clonedParts.Add((Part)part.Clone()); + totalPartArea += part.BaseDrawing.Area; + } + + var bounds = best.GetBoundingBox(); + + var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " + + $"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " + + $"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " + + $"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}"; + Debug.WriteLine(msg); + try { System.IO.File.AppendAllText( + System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"), + $"{DateTime.Now:HH:mm:ss.fff} {msg}\n"); } catch { } + + progress.Report(new NestProgress + { + Phase = phase, + PlateNumber = plateNumber, + BestPartCount = score.Count, + BestDensity = score.Density, + NestedWidth = bounds.Width, + NestedLength = bounds.Length, + NestedArea = totalPartArea, + UsableRemnantArea = workArea.Area() - totalPartArea, + BestParts = clonedParts, + Description = description + }); + } + + protected string BuildProgressSummary() + { + if (PhaseResults.Count == 0) + return null; + + var parts = new List(PhaseResults.Count); + + foreach (var r in PhaseResults) + parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}"); + + return string.Join(" | ", parts); + } + + protected bool IsBetterFill(List candidate, List current, Box workArea) + { + if (candidate == null || candidate.Count == 0) + return false; + + if (current == null || current.Count == 0) + return true; + + return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea); + } + + protected bool IsBetterValidFill(List candidate, List current, Box workArea) + { + if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing)) + { + Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})"); + return false; + } + + return IsBetterFill(candidate, current, workArea); + } + + protected static bool HasOverlaps(List parts, double spacing) + { + if (parts == null || parts.Count <= 1) + return false; + + for (var i = 0; i < parts.Count; i++) + { + var box1 = parts[i].BoundingBox; + + for (var j = i + 1; j < parts.Count; j++) + { + var box2 = parts[j].BoundingBox; + + if (box1.Right < box2.Left || box2.Right < box1.Left || + box1.Top < box2.Bottom || box2.Top < box1.Bottom) + continue; + + List pts; + + if (parts[i].Intersects(parts[j], out pts)) + { + var b1 = parts[i].BoundingBox; + var b2 = parts[j].BoundingBox; + Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" + + $" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" + + $" intersections={pts?.Count ?? 0}"); + return true; + } + } + } + + return false; + } + + protected static string FormatPhaseName(NestPhase phase) + { + switch (phase) + { + case NestPhase.Pairs: return "Pairs"; + case NestPhase.Linear: return "Linear"; + case NestPhase.RectBestFit: return "BestFit"; + case NestPhase.Remainder: return "Remainder"; + default: return phase.ToString(); + } + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngineBase.cs +git commit -m "feat: add NestEngineBase abstract class" +``` + +--- + +### Task 2: Convert NestEngine to DefaultNestEngine + +**Files:** +- Rename: `OpenNest.Engine/NestEngine.cs` → `OpenNest.Engine/DefaultNestEngine.cs` + +Rename the class, make it inherit `NestEngineBase`, add `Name`/`Description`, change the virtual methods to `override`, and remove methods that now live in the base class (convenience overloads, `ReportProgress`, `BuildProgressSummary`, `IsBetterFill`, `IsBetterValidFill`, `HasOverlaps`, `FormatPhaseName`, `FillExact`). + +- [ ] **Step 1: Rename the file** + +```bash +git mv OpenNest.Engine/NestEngine.cs OpenNest.Engine/DefaultNestEngine.cs +``` + +- [ ] **Step 2: Update class declaration and add inheritance** + +In `DefaultNestEngine.cs`, change the class declaration from: + +```csharp + public class NestEngine + { + public NestEngine(Plate plate) + { + Plate = plate; + } + + public Plate Plate { get; set; } + + public NestDirection NestDirection { get; set; } + + public int PlateNumber { get; set; } + + public NestPhase WinnerPhase { get; private set; } + + public List PhaseResults { get; } = new(); + + public bool ForceFullAngleSweep { get; set; } + + public List AngleResults { get; } = new(); +``` + +To: + +```csharp + public class DefaultNestEngine : NestEngineBase + { + public DefaultNestEngine(Plate plate) : base(plate) + { + } + + public override string Name => "Default"; + + public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)"; + + public bool ForceFullAngleSweep { get; set; } +``` + +This removes properties that now come from the base class (`Plate`, `PlateNumber`, `NestDirection`, `WinnerPhase`, `PhaseResults`, `AngleResults`). + +- [ ] **Step 3: Convert the convenience Fill overloads to override the virtual methods** + +Remove the non-progress `Fill` convenience overloads (they are now in the base class). The two remaining `Fill` methods that take `IProgress` and `CancellationToken` become overrides. + +Change: +```csharp + public List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) +``` +To: +```csharp + public override List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) +``` + +Change: +```csharp + public List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) +``` +To: +```csharp + public override List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) +``` + +Remove these methods entirely (now in base class): +- `bool Fill(NestItem item)` (2-arg convenience) +- `bool Fill(NestItem item, Box workArea)` (convenience that calls the 4-arg) +- `bool Fill(List groupParts)` (convenience) +- `bool Fill(List groupParts, Box workArea)` (convenience that calls the 4-arg) +- `FillExact` (now in base class) +- `ReportProgress` (now in base class) +- `BuildProgressSummary` (now in base class) +- `IsBetterFill` (now in base class) +- `IsBetterValidFill` (now in base class) +- `HasOverlaps` (now in base class) +- `FormatPhaseName` (now in base class) + +- [ ] **Step 4: Convert Pack/PackArea to override** + +Remove `Pack(List)` (now in base class). + +Convert `PackArea` to override with the new signature. Replace: + +```csharp + public bool Pack(List items) + { + var workArea = Plate.WorkArea(); + return PackArea(workArea, items); + } + + public bool PackArea(Box box, List items) + { + var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); + var bin = BinConverter.CreateBin(box, Plate.PartSpacing); + + var engine = new PackBottomLeft(bin); + engine.Pack(binItems); + + var parts = BinConverter.ToParts(bin, items); + Plate.Parts.AddRange(parts); + + return parts.Count > 0; + } +``` + +With: + +```csharp + public override List PackArea(Box box, List items, + IProgress progress, CancellationToken token) + { + var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); + var bin = BinConverter.CreateBin(box, Plate.PartSpacing); + + var engine = new PackBottomLeft(bin); + engine.Pack(binItems); + + return BinConverter.ToParts(bin, items); + } +``` + +Note: the `progress` and `token` parameters are not used yet in the default rectangle packing — the contract is there for engines that need them. + +- [ ] **Step 5: Update BruteForceRunner to use DefaultNestEngine** + +`BruteForceRunner.cs` is in the same project and still references `NestEngine`. It must be updated before the Engine project can compile. This is the one callsite that stays as a direct `DefaultNestEngine` reference (not via registry) because training data must come from the known algorithm. + +In `OpenNest.Engine/ML/BruteForceRunner.cs`, change line 30: + +```csharp + var engine = new NestEngine(plate); +``` + +To: + +```csharp + var engine = new DefaultNestEngine(plate); +``` + +- [ ] **Step 6: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded (other projects will have errors since their callsites still reference `NestEngine` — fixed in Chunk 3) + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest.Engine/DefaultNestEngine.cs OpenNest.Engine/ML/BruteForceRunner.cs +git commit -m "refactor: rename NestEngine to DefaultNestEngine, inherit NestEngineBase" +``` + +--- + +## Chunk 2: NestEngineRegistry and NestEngineInfo + +### Task 3: Create NestEngineInfo + +**Files:** +- Create: `OpenNest.Engine/NestEngineInfo.cs` + +- [ ] **Step 1: Create NestEngineInfo.cs** + +```csharp +using System; + +namespace OpenNest +{ + public class NestEngineInfo + { + public NestEngineInfo(string name, string description, Func factory) + { + Name = name; + Description = description; + Factory = factory; + } + + public string Name { get; } + public string Description { get; } + public Func Factory { get; } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngineInfo.cs +git commit -m "feat: add NestEngineInfo metadata class" +``` + +--- + +### Task 4: Create NestEngineRegistry + +**Files:** +- Create: `OpenNest.Engine/NestEngineRegistry.cs` + +Static class with built-in registration, plugin loading, active engine selection, and factory creation. + +- [ ] **Step 1: Create NestEngineRegistry.cs** + +```csharp +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace OpenNest +{ + public static class NestEngineRegistry + { + private static readonly List engines = new(); + + static NestEngineRegistry() + { + Register("Default", + "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)", + plate => new DefaultNestEngine(plate)); + } + + public static IReadOnlyList AvailableEngines => engines; + + public static string ActiveEngineName { get; set; } = "Default"; + + public static NestEngineBase Create(Plate plate) + { + var info = engines.FirstOrDefault(e => + e.Name.Equals(ActiveEngineName, StringComparison.OrdinalIgnoreCase)); + + if (info == null) + { + Debug.WriteLine($"[NestEngineRegistry] Engine '{ActiveEngineName}' not found, falling back to Default"); + info = engines[0]; + } + + return info.Factory(plate); + } + + public static void Register(string name, string description, Func factory) + { + if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + Debug.WriteLine($"[NestEngineRegistry] Duplicate engine '{name}' skipped"); + return; + } + + engines.Add(new NestEngineInfo(name, description, factory)); + } + + public static void LoadPlugins(string directory) + { + if (!Directory.Exists(directory)) + return; + + foreach (var dll in Directory.GetFiles(directory, "*.dll")) + { + try + { + var assembly = Assembly.LoadFrom(dll); + + foreach (var type in assembly.GetTypes()) + { + if (type.IsAbstract || !typeof(NestEngineBase).IsAssignableFrom(type)) + continue; + + var ctor = type.GetConstructor(new[] { typeof(Plate) }); + + if (ctor == null) + { + Debug.WriteLine($"[NestEngineRegistry] Skipping {type.Name}: no Plate constructor"); + continue; + } + + // Create a temporary instance to read Name and Description. + try + { + var tempPlate = new Plate(); + var instance = (NestEngineBase)ctor.Invoke(new object[] { tempPlate }); + Register(instance.Name, instance.Description, + plate => (NestEngineBase)ctor.Invoke(new object[] { plate })); + Debug.WriteLine($"[NestEngineRegistry] Loaded plugin engine: {instance.Name}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[NestEngineRegistry] Failed to instantiate {type.Name}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NestEngineRegistry] Failed to load assembly {Path.GetFileName(dll)}: {ex.Message}"); + } + } + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngineRegistry.cs +git commit -m "feat: add NestEngineRegistry with built-in registration and plugin loading" +``` + +--- + +## Chunk 3: Callsite Migration + +### Task 5: Migrate OpenNest.Mcp callsites + +**Files:** +- Modify: `OpenNest.Mcp/Tools/NestingTools.cs` + +Six `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`. The `PackArea` call on line 276 changes signature since `PackArea` now returns `List` instead of mutating the plate. + +- [ ] **Step 1: Replace all NestEngine instantiations** + +In `NestingTools.cs`, replace all six occurrences of `new NestEngine(plate)` with `NestEngineRegistry.Create(plate)`. + +Lines to change: +- Line 37: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 73: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 114: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 176: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 255: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 275: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` + +- [ ] **Step 2: Fix PackArea call in AutoNestPlate** + +The old code on line 276 was: +```csharp + engine.PackArea(workArea, packItems); +``` + +This used the old `bool PackArea(Box, List)` which mutated the plate. The new virtual method returns `List`. Use the convenience `Pack`-like pattern instead. Replace lines 274-277: + +```csharp + var before = plate.Parts.Count; + var engine = new NestEngine(plate); + engine.PackArea(workArea, packItems); + totalPlaced += plate.Parts.Count - before; +``` + +With: + +```csharp + var engine = NestEngineRegistry.Create(plate); + var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); + if (packParts.Count > 0) + { + plate.Parts.AddRange(packParts); + totalPlaced += packParts.Count; + } +``` + +- [ ] **Step 3: Build OpenNest.Mcp** + +Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Mcp/Tools/NestingTools.cs +git commit -m "refactor: migrate NestingTools to NestEngineRegistry" +``` + +--- + +### Task 6: Migrate OpenNest.Console callsites + +**Files:** +- Modify: `OpenNest.Console/Program.cs` + +Three `new NestEngine(plate)` calls. The `PackArea` call also needs the same signature update. + +- [ ] **Step 1: Replace NestEngine instantiations** + +In `Program.cs`, replace: +- Line 351: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 380: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` + +- [ ] **Step 2: Fix PackArea call** + +Replace lines 370-372: + +```csharp + var engine = new NestEngine(plate); + var before = plate.Parts.Count; + engine.PackArea(workArea, packItems); +``` + +With: + +```csharp + var engine = NestEngineRegistry.Create(plate); + var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); + plate.Parts.AddRange(packParts); +``` + +And update line 374-375 from: +```csharp + if (plate.Parts.Count > before) + success = true; +``` +To: +```csharp + if (packParts.Count > 0) + success = true; +``` + +- [ ] **Step 3: Build OpenNest.Console** + +Run: `dotnet build OpenNest.Console/OpenNest.Console.csproj` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Console/Program.cs +git commit -m "refactor: migrate Console Program to NestEngineRegistry" +``` + +--- + +### Task 7: Migrate OpenNest WinForms callsites + +**Files:** +- Modify: `OpenNest/Actions/ActionFillArea.cs` +- Modify: `OpenNest/Controls/PlateView.cs` +- Modify: `OpenNest/Forms/MainForm.cs` + +- [ ] **Step 1: Migrate ActionFillArea.cs** + +In `ActionFillArea.cs`, replace both `new NestEngine(plateView.Plate)` calls: +- Line 50: `var engine = new NestEngine(plateView.Plate);` → `var engine = NestEngineRegistry.Create(plateView.Plate);` +- Line 64: `var engine = new NestEngine(plateView.Plate);` → `var engine = NestEngineRegistry.Create(plateView.Plate);` + +- [ ] **Step 2: Migrate PlateView.cs** + +In `PlateView.cs`, replace: +- Line 836: `var engine = new NestEngine(Plate);` → `var engine = NestEngineRegistry.Create(Plate);` + +- [ ] **Step 3: Migrate MainForm.cs** + +In `MainForm.cs`, replace all three `new NestEngine(plate)` calls: +- Line 797: `var engine = new NestEngine(plate) { PlateNumber = plateCount };` → `var engine = NestEngineRegistry.Create(plate); engine.PlateNumber = plateCount;` +- Line 829: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 965: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` + +- [ ] **Step 4: Fix MainForm PackArea call** + +In `MainForm.cs`, the auto-nest pack phase (around line 829-832) uses the old `PackArea` signature. Replace: + +```csharp + var engine = new NestEngine(plate); + var partsBefore = plate.Parts.Count; + engine.PackArea(workArea, packItems); + var packed = plate.Parts.Count - partsBefore; +``` + +With: + +```csharp + var engine = NestEngineRegistry.Create(plate); + var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); + plate.Parts.AddRange(packParts); + var packed = packParts.Count; +``` + +- [ ] **Step 5: Add plugin loading at startup** + +In `MainForm.cs`, find where post-processors are loaded at startup (look for `Posts` directory loading) and add engine plugin loading nearby. Add after the existing plugin loading: + +```csharp + var enginesDir = Path.Combine(Application.StartupPath, "Engines"); + NestEngineRegistry.LoadPlugins(enginesDir); +``` + +If there is no explicit post-processor loading call visible, add this to the `MainForm` constructor or `Load` event. + +- [ ] **Step 6: Build the full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded with no errors + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest/Actions/ActionFillArea.cs OpenNest/Controls/PlateView.cs OpenNest/Forms/MainForm.cs +git commit -m "refactor: migrate WinForms callsites to NestEngineRegistry" +``` + +--- + +## Chunk 4: Verification and Cleanup + +### Task 8: Verify no remaining NestEngine references + +**Files:** +- No changes expected — verification only + +- [ ] **Step 1: Search for stale references** + +Run: `grep -rn "new NestEngine(" --include="*.cs" .` +Expected: Only `BruteForceRunner.cs` should have `new DefaultNestEngine(`. No `new NestEngine(` references should remain. + +Also run: `grep -rn "class NestEngine[^B]" --include="*.cs" .` +Expected: No matches (the old `class NestEngine` no longer exists). + +- [ ] **Step 2: Build and run smoke test** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded, 0 errors, 0 warnings related to NestEngine + +- [ ] **Step 3: Publish MCP server** + +Run: `dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"` +Expected: Publish succeeded + +- [ ] **Step 4: Commit if any fixes were needed** + +If any issues were found and fixed in previous steps, commit them now. + +--- + +### Task 9: Update CLAUDE.md architecture documentation + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Update architecture section** + +Update the `### OpenNest.Engine` section in `CLAUDE.md` to document the new engine hierarchy: +- `NestEngineBase` is the abstract base class +- `DefaultNestEngine` is the current multi-phase engine (formerly `NestEngine`) +- `NestEngineRegistry` manages available engines and the active selection +- `NestEngineInfo` holds engine metadata +- Plugin engines loaded from `Engines/` directory + +Also update any references to `NestEngine` that should now say `DefaultNestEngine` or `NestEngineBase`. + +- [ ] **Step 2: Build to verify no docs broke anything** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: update CLAUDE.md for abstract nest engine architecture" +``` From 1d1cf41ba02485e915ff2ee7509dca22b65e3259 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:00:42 -0400 Subject: [PATCH 074/116] docs: update strip nester plan for abstract engine architecture StripNester becomes StripNestEngine extending NestEngineBase. Uses DefaultNestEngine internally via composition. Registered in NestEngineRegistry. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-15-strip-nester.md | 129 ++++++++++++------ 1 file changed, 90 insertions(+), 39 deletions(-) diff --git a/docs/superpowers/plans/2026-03-15-strip-nester.md b/docs/superpowers/plans/2026-03-15-strip-nester.md index f4f5b95..e095b07 100644 --- a/docs/superpowers/plans/2026-03-15-strip-nester.md +++ b/docs/superpowers/plans/2026-03-15-strip-nester.md @@ -2,17 +2,19 @@ > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Implement a strip-based multi-drawing nesting strategy that dedicates a tight strip to the largest-area drawing and fills the remnant with remaining drawings. +**Goal:** Implement a strip-based multi-drawing nesting strategy as a `NestEngineBase` subclass that dedicates a tight strip to the largest-area drawing and fills the remnant with remaining drawings. -**Architecture:** New `StripNester` class in `OpenNest.Engine` that orchestrates strip optimization using `NestEngine.Fill` as a building block. Tries bottom and left strip orientations, finds the tightest strip via a shrink loop, fills remnants with remaining items, and picks the denser result. Integrated into `NestingTools` MCP as an additional strategy in `autonest_plate`. +**Architecture:** `StripNestEngine` extends `NestEngineBase`, uses `DefaultNestEngine` internally (composition) for individual fills. Registered in `NestEngineRegistry`. For single-item fills, delegates to `DefaultNestEngine`. For multi-drawing nesting, orchestrates the strip+remnant strategy. The MCP `autonest_plate` tool always runs `StripNestEngine` as a competitor alongside the current sequential approach, picking the denser result. **Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest.Mcp **Spec:** `docs/superpowers/specs/2026-03-15-strip-nester-design.md` +**Depends on:** `docs/superpowers/plans/2026-03-15-abstract-nest-engine.md` (must be implemented first — provides `NestEngineBase`, `DefaultNestEngine`, `NestEngineRegistry`) + --- -## Chunk 1: Core StripNester +## Chunk 1: Core StripNestEngine ### Task 1: Create StripDirection enum @@ -84,14 +86,14 @@ git commit -m "feat: add StripNestResult internal class" --- -### Task 3: Create StripNester class — strip item selection and initial strip height estimation +### Task 3: Create StripNestEngine — class skeleton with selection and estimation helpers **Files:** -- Create: `OpenNest.Engine/StripNester.cs` +- Create: `OpenNest.Engine/StripNestEngine.cs` -This task creates the class with the constructor and the helper methods for selecting the strip item and estimating the initial strip dimensions. The main `Nest` method is added in the next task. +This task creates the class extending `NestEngineBase`, with `Name`/`Description` overrides, the single-item `Fill` override that delegates to `DefaultNestEngine`, and the helper methods for strip item selection and dimension estimation. The main `Nest` method is added in the next task. -- [ ] **Step 1: Create StripNester with selection and estimation logic** +- [ ] **Step 1: Create StripNestEngine with skeleton and helpers** ```csharp using System; @@ -103,16 +105,28 @@ using OpenNest.Math; namespace OpenNest { - public class StripNester + public class StripNestEngine : NestEngineBase { private const int MaxShrinkIterations = 20; - public StripNester(Plate plate) + public StripNestEngine(Plate plate) : base(plate) { - Plate = plate; } - public Plate Plate { get; } + public override string Name => "Strip"; + + public override string Description => "Strip-based nesting for mixed-drawing layouts"; + + /// + /// Single-item fill delegates to DefaultNestEngine. + /// The strip strategy adds value for multi-drawing nesting, not single-item fills. + /// + public override List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(item, workArea, progress, token); + } /// /// Selects the item that consumes the most plate area (bounding box area x quantity). @@ -145,7 +159,7 @@ namespace OpenNest /// Estimates the strip dimension (height for bottom, width for left) needed /// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter. /// This is only an estimate for the shrink loop starting point — the actual fill - /// uses NestEngine.Fill which tries many rotation angles internally. + /// uses DefaultNestEngine.Fill which tries many rotation angles internally. /// private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension) { @@ -181,26 +195,32 @@ Expected: Build succeeded - [ ] **Step 3: Commit** ```bash -git add OpenNest.Engine/StripNester.cs -git commit -m "feat: add StripNester with strip selection and estimation" +git add OpenNest.Engine/StripNestEngine.cs +git commit -m "feat: add StripNestEngine skeleton with Fill delegate and estimation helpers" ``` --- -### Task 4: Add the core Nest method and TryOrientation +### Task 4: Add the Nest method and TryOrientation **Files:** -- Modify: `OpenNest.Engine/StripNester.cs` +- Modify: `OpenNest.Engine/StripNestEngine.cs` -This is the main algorithm: tries both orientations, fills strip + remnant, compares results. +This is the main multi-drawing algorithm: tries both orientations, fills strip + remnant, compares results. Uses `DefaultNestEngine` internally for all fill operations (composition pattern per the abstract engine spec). -Key detail: The remnant fill must shrink the remnant box after each item fill using `ComputeRemainderWithin` (same pattern as `AutoNestPlate` in `NestingTools.cs:293-306`) to prevent overlapping placements. +Key detail: The remnant fill shrinks the remnant box after each item fill using `ComputeRemainderWithin` to prevent overlapping placements. - [ ] **Step 1: Add Nest, TryOrientation, and ComputeRemainderWithin methods** -Add these methods to the `StripNester` class, after the `EstimateStripDimension` method: +Add these methods to the `StripNestEngine` class, after the `EstimateStripDimension` method: ```csharp + /// + /// Multi-drawing strip nesting strategy. + /// Picks the largest-area drawing for strip treatment, finds the tightest strip + /// in both bottom and left orientations, fills remnants with remaining drawings, + /// and returns the denser result. + /// public List Nest(List items, IProgress progress, CancellationToken token) { @@ -243,9 +263,9 @@ Add these methods to the `StripNester` class, after the `EstimateStripDimension` ? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim) : new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length); - // Initial fill (does NOT add to plate — uses the 4-arg overload). - var engine = new NestEngine(Plate); - var stripParts = engine.Fill( + // Initial fill using DefaultNestEngine (composition, not inheritance). + var inner = new DefaultNestEngine(Plate); + var stripParts = inner.Fill( new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, stripBox, null, token); @@ -276,8 +296,8 @@ Add these methods to the `StripNester` class, after the `EstimateStripDimension` ? new Box(workArea.X, workArea.Y, workArea.Width, trialDim) : new Box(workArea.X, workArea.Y, trialDim, workArea.Length); - var trialEngine = new NestEngine(Plate); - var trialParts = trialEngine.Fill( + var trialInner = new DefaultNestEngine(Plate); + var trialParts = trialInner.Fill( new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, trialBox, null, token); @@ -339,8 +359,8 @@ Add these methods to the `StripNester` class, after the `EstimateStripDimension` if (currentRemnant.Width <= 0 || currentRemnant.Length <= 0) break; - var remnantEngine = new NestEngine(Plate); - var remnantParts = remnantEngine.Fill( + var remnantInner = new DefaultNestEngine(Plate); + var remnantParts = remnantInner.Fill( new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, currentRemnant, null, token); @@ -393,31 +413,62 @@ Expected: Build succeeded - [ ] **Step 3: Commit** ```bash -git add OpenNest.Engine/StripNester.cs -git commit -m "feat: add StripNester.Nest with strip fill, shrink loop, and remnant fill" +git add OpenNest.Engine/StripNestEngine.cs +git commit -m "feat: add StripNestEngine.Nest with strip fill, shrink loop, and remnant fill" +``` + +--- + +### Task 5: Register StripNestEngine in NestEngineRegistry + +**Files:** +- Modify: `OpenNest.Engine/NestEngineRegistry.cs` + +- [ ] **Step 1: Add Strip registration** + +In `NestEngineRegistry.cs`, add the strip engine registration in the static constructor, after the Default registration: + +```csharp + Register("Strip", + "Strip-based nesting for mixed-drawing layouts", + plate => new StripNestEngine(plate)); +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngineRegistry.cs +git commit -m "feat: register StripNestEngine in NestEngineRegistry" ``` --- ## Chunk 2: MCP Integration -### Task 5: Integrate StripNester into autonest_plate MCP tool +### Task 6: Integrate StripNestEngine into autonest_plate MCP tool **Files:** - Modify: `OpenNest.Mcp/Tools/NestingTools.cs` -Run the strip nester alongside the existing sequential approach. Both use the 4-arg `Fill` overload (no side effects), then the winner's parts are added to the plate. +Run the strip nester alongside the existing sequential approach. Both use side-effect-free fills (4-arg `Fill` returning `List`), then the winner's parts are added to the plate. + +Note: After the abstract engine migration, callsites already use `NestEngineRegistry.Create(plate)`. The `autonest_plate` tool creates a `StripNestEngine` directly for the strip strategy competition (it's always tried, regardless of active engine selection). - [ ] **Step 1: Refactor AutoNestPlate to run both strategies** -In `NestingTools.cs`, replace the fill/pack logic in `AutoNestPlate` (lines 236-278) with a strategy competition. The existing sequential logic is extracted to a `SequentialFill` helper. +In `NestingTools.cs`, replace the fill/pack logic in `AutoNestPlate` (the section after the items list is built) with a strategy competition. -Replace lines 236-278 with: +Replace the fill/pack logic with: ```csharp // Strategy 1: Strip nesting - var stripNester = new StripNester(plate); - var stripResult = stripNester.Nest(items, null, CancellationToken.None); + var stripEngine = new StripNestEngine(plate); + var stripResult = stripEngine.Nest(items, null, CancellationToken.None); var stripScore = FillScore.Compute(stripResult, plate.WorkArea()); // Strategy 2: Current sequential fill @@ -431,7 +482,7 @@ Replace lines 236-278 with: var totalPlaced = winner.Count; ``` -Update the output section (around line 280): +Update the output section: ```csharp var sb = new StringBuilder(); @@ -451,7 +502,7 @@ Update the output section (around line 280): - [ ] **Step 2: Add the SequentialFill helper method** -Add this private method to `NestingTools`. It mirrors the existing `AutoNestPlate` fill phase using the 4-arg `Fill` overload for side-effect-free comparison. +Add this private method to `NestingTools`. It mirrors the existing sequential fill phase using side-effect-free fills. ```csharp private static List SequentialFill(Plate plate, List items) @@ -470,7 +521,7 @@ Add this private method to `NestingTools`. It mirrors the existing `AutoNestPlat if (item.Quantity == 0 || workArea.Width <= 0 || workArea.Length <= 0) continue; - var engine = new NestEngine(plate); + var engine = new DefaultNestEngine(plate); var parts = engine.Fill( new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, workArea, null, CancellationToken.None); @@ -500,14 +551,14 @@ Expected: Build succeeded ```bash git add OpenNest.Mcp/Tools/NestingTools.cs -git commit -m "feat: integrate StripNester into autonest_plate MCP tool" +git commit -m "feat: integrate StripNestEngine into autonest_plate MCP tool" ``` --- ## Chunk 3: Publish and Test -### Task 6: Publish MCP server and test with real parts +### Task 7: Publish MCP server and test with real parts **Files:** - No code changes — publish and manual testing From 1bcfe5d03148307d954fab56c2f9e9f62c1e3636 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:06:28 -0400 Subject: [PATCH 075/116] feat: add NestEngineBase abstract class, rename NestEngine to DefaultNestEngine Co-Authored-By: Claude Opus 4.6 (1M context) --- .../{NestEngine.cs => DefaultNestEngine.cs} | 363 +++--------------- OpenNest.Engine/ML/BruteForceRunner.cs | 2 +- OpenNest.Engine/NestEngineBase.cs | 237 ++++++++++++ 3 files changed, 301 insertions(+), 301 deletions(-) rename OpenNest.Engine/{NestEngine.cs => DefaultNestEngine.cs} (68%) create mode 100644 OpenNest.Engine/NestEngineBase.cs diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/DefaultNestEngine.cs similarity index 68% rename from OpenNest.Engine/NestEngine.cs rename to OpenNest.Engine/DefaultNestEngine.cs index 79d8aab..827ad7d 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/DefaultNestEngine.cs @@ -12,50 +12,23 @@ using OpenNest.RectanglePacking; namespace OpenNest { - public class NestEngine + public class DefaultNestEngine : NestEngineBase { - public NestEngine(Plate plate) - { - Plate = plate; - } + public DefaultNestEngine(Plate plate) : base(plate) { } - public Plate Plate { get; set; } + public override string Name => "Default"; - public NestDirection NestDirection { get; set; } - - public int PlateNumber { get; set; } - - public NestPhase WinnerPhase { get; private set; } - - public List PhaseResults { get; } = new(); + public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)"; public bool ForceFullAngleSweep { get; set; } - public List AngleResults { get; } = new(); - // Angles that have produced results across multiple Fill calls. // Populated after each Fill; used to prune subsequent fills. private readonly HashSet knownGoodAngles = new(); // --- Public Fill API --- - public bool Fill(NestItem item) - { - return Fill(item, Plate.WorkArea()); - } - - public bool Fill(NestItem item, Box workArea) - { - var parts = Fill(item, workArea, null, CancellationToken.None); - - if (parts == null || parts.Count == 0) - return false; - - Plate.Parts.AddRange(parts); - return true; - } - - public List Fill(NestItem item, Box workArea, + public override List Fill(NestItem item, Box workArea, IProgress progress, CancellationToken token) { PhaseResults.Clear(); @@ -89,155 +62,60 @@ namespace OpenNest } /// - /// Finds the smallest sub-area of workArea that fits exactly item.Quantity parts. - /// Uses binary search on both orientations and picks the tightest fit. - /// Falls through to standard Fill for unlimited (0) or single (1) quantities. + /// Fast fill count using linear fill with two angles plus the top cached + /// pair candidates. Used by binary search to estimate capacity at a given + /// box size without running the full Fill pipeline. /// - public List FillExact(NestItem item, Box workArea, - IProgress progress, CancellationToken token) + private int QuickFillCount(Drawing drawing, Box testBox, double bestRotation) { - // Early exits: unlimited or single quantity — no benefit from area search. - if (item.Quantity <= 1) - return Fill(item, workArea, progress, token); + var engine = new FillLinear(testBox, Plate.PartSpacing); + var bestCount = 0; - // Quick capacity check: estimate how many parts fit via bounding box. - var partBox = item.Drawing.Program.BoundingBox(); - var cols = (int)(workArea.Width / (partBox.Width + Plate.PartSpacing)); - var rows = (int)(workArea.Length / (partBox.Length + Plate.PartSpacing)); - var capacity = System.Math.Max(cols * rows, 1); + // Single-part linear fills. + var angles = new[] { bestRotation, bestRotation + Angle.HalfPI }; - // Also check rotated orientation. - var colsR = (int)(workArea.Width / (partBox.Length + Plate.PartSpacing)); - var rowsR = (int)(workArea.Length / (partBox.Width + Plate.PartSpacing)); - capacity = System.Math.Max(capacity, colsR * rowsR); - - Debug.WriteLine($"[FillExact] Capacity estimate: {capacity}, target: {item.Quantity}, workArea: {workArea.Width:F1}x{workArea.Length:F1}"); - - if (capacity <= item.Quantity) + foreach (var angle in angles) { - // Plate can't fit more than requested — do a normal fill. - return Fill(item, workArea, progress, token); + var h = engine.Fill(drawing, angle, NestDirection.Horizontal); + if (h != null && h.Count > bestCount) + bestCount = h.Count; + + var v = engine.Fill(drawing, angle, NestDirection.Vertical); + if (v != null && v.Count > bestCount) + bestCount = v.Count; } - // Binary search: try shrinking each dimension. - Debug.WriteLine($"[FillExact] Starting binary search (capacity={capacity} > target={item.Quantity})"); - var (lengthFound, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, progress, token); - Debug.WriteLine($"[FillExact] Shrink-length: found={lengthFound}, dim={lengthDim:F1}"); - var (widthFound, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, progress, token); - Debug.WriteLine($"[FillExact] Shrink-width: found={widthFound}, dim={widthDim:F1}"); + // Top pair candidates — check if pairs tile better in this box. + var bestFits = BestFitCache.GetOrCompute( + drawing, Plate.Size.Width, Plate.Size.Length, Plate.PartSpacing); + var topPairs = bestFits.Where(r => r.Keep).Take(3); - // Pick winner by smallest test box area. Tie-break: prefer shrink-length. - Box winnerBox; - - var lengthArea = lengthFound ? workArea.Width * lengthDim : double.MaxValue; - var widthArea = widthFound ? widthDim * workArea.Length : double.MaxValue; - - if (lengthFound && lengthArea <= widthArea) + foreach (var pair in topPairs) { - winnerBox = new Box(workArea.X, workArea.Y, workArea.Width, lengthDim); - } - else if (widthFound) - { - winnerBox = new Box(workArea.X, workArea.Y, widthDim, workArea.Length); - } - else - { - // Neither search found the exact quantity — do a normal fill. - return Fill(item, workArea, progress, token); - } + var pairParts = pair.BuildParts(drawing); + var pairAngles = pair.HullAngles ?? new List { 0 }; + var pairEngine = new FillLinear(testBox, Plate.PartSpacing); - Debug.WriteLine($"[FillExact] Winner box: {winnerBox.Width:F1}x{winnerBox.Length:F1}"); - - // Run the full Fill on the winning box with progress. - return Fill(item, winnerBox, progress, token); - } - - /// - /// Binary-searches for the smallest sub-area (one dimension fixed) that fits - /// exactly item.Quantity parts. Returns the best parts list and the dimension - /// value that achieved it. - /// - private (bool found, double usedDim) BinarySearchFill( - NestItem item, Box workArea, bool shrinkWidth, - IProgress progress, CancellationToken token) - { - var quantity = item.Quantity; - var partBox = item.Drawing.Program.BoundingBox(); - var partArea = item.Drawing.Area; - - // Fixed and variable dimensions. - var fixedDim = shrinkWidth ? workArea.Length : workArea.Width; - var highDim = shrinkWidth ? workArea.Width : workArea.Length; - - // Estimate search range from part area and utilization assumptions. - var minPartDim = shrinkWidth - ? partBox.Width + Plate.PartSpacing - : partBox.Length + Plate.PartSpacing; - - // Low: tight estimate at 70% utilization. - var lowEstimate = System.Math.Max(minPartDim, partArea * quantity / (0.7 * fixedDim)); - // High: generous estimate at 25% utilization, capped to work area. - var highEstimate = System.Math.Min(highDim, partArea * quantity / (0.25 * fixedDim)); - // Ensure high is at least low. - highEstimate = System.Math.Max(highEstimate, lowEstimate + Plate.PartSpacing); - - var low = lowEstimate; - var high = highEstimate; - - var found = false; - var bestDim = highEstimate; - - for (var iter = 0; iter < 8; iter++) - { - if (token.IsCancellationRequested) - break; - - if (high - low < Plate.PartSpacing) - break; - - var mid = (low + high) / 2.0; - - var testBox = shrinkWidth - ? new Box(workArea.X, workArea.Y, mid, workArea.Length) - : new Box(workArea.X, workArea.Y, workArea.Width, mid); - - // Fill with unlimited qty to get the true count for this box size. - // After the first iteration, angle pruning kicks in and makes this fast. - var searchItem = new NestItem { Drawing = item.Drawing, Quantity = 0 }; - var result = Fill(searchItem, testBox, progress, token); - - if (result.Count >= quantity) + foreach (var angle in pairAngles) { - bestDim = mid; - found = true; - high = mid; - } - else - { - low = mid; + var pattern = BuildRotatedPattern(pairParts, angle); + if (pattern.Parts.Count == 0) + continue; + + var h = pairEngine.Fill(pattern, NestDirection.Horizontal); + if (h != null && h.Count > bestCount) + bestCount = h.Count; + + var v = pairEngine.Fill(pattern, NestDirection.Vertical); + if (v != null && v.Count > bestCount) + bestCount = v.Count; } } - return (found, bestDim); + return bestCount; } - public bool Fill(List groupParts) - { - return Fill(groupParts, Plate.WorkArea()); - } - - public bool Fill(List groupParts, Box workArea) - { - var parts = Fill(groupParts, workArea, null, CancellationToken.None); - - if (parts == null || parts.Count == 0) - return false; - - Plate.Parts.AddRange(parts); - return true; - } - - public List Fill(List groupParts, Box workArea, + public override List Fill(List groupParts, Box workArea, IProgress progress, CancellationToken token) { if (groupParts == null || groupParts.Count == 0) @@ -306,13 +184,8 @@ namespace OpenNest // --- Pack API --- - public bool Pack(List items) - { - var workArea = Plate.WorkArea(); - return PackArea(workArea, items); - } - - public bool PackArea(Box box, List items) + public override List PackArea(Box box, List items, + IProgress progress, CancellationToken token) { var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); var bin = BinConverter.CreateBin(box, Plate.PartSpacing); @@ -320,10 +193,7 @@ namespace OpenNest var engine = new PackBottomLeft(bin); engine.Pack(binItems); - var parts = BinConverter.ToParts(bin, items); - Plate.Parts.AddRange(parts); - - return parts.Count > 0; + return BinConverter.ToParts(bin, items); } // --- FindBestFill: core orchestration --- @@ -534,6 +404,7 @@ namespace OpenNest List best = null; var bestScore = default(FillScore); + var sinceImproved = 0; try { @@ -554,11 +425,27 @@ namespace OpenNest { best = filled; bestScore = score; + sinceImproved = 0; } + else + { + sinceImproved++; + } + } + else + { + sinceImproved++; } ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts"); + + // Early exit: stop if we've tried enough candidates without improvement. + if (i >= 9 && sinceImproved >= 10) + { + Debug.WriteLine($"[FillWithPairs] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates"); + break; + } } } catch (OperationCanceledException) @@ -806,129 +693,5 @@ namespace OpenNest return clusters; } - // --- Scoring / comparison --- - - private bool IsBetterFill(List candidate, List current, Box workArea) - { - if (candidate == null || candidate.Count == 0) - return false; - - if (current == null || current.Count == 0) - return true; - - return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea); - } - - private bool IsBetterValidFill(List candidate, List current, Box workArea) - { - if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing)) - { - Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})"); - return false; - } - - return IsBetterFill(candidate, current, workArea); - } - - private bool HasOverlaps(List parts, double spacing) - { - if (parts == null || parts.Count <= 1) - return false; - - for (var i = 0; i < parts.Count; i++) - { - var box1 = parts[i].BoundingBox; - - for (var j = i + 1; j < parts.Count; j++) - { - var box2 = parts[j].BoundingBox; - - // Fast bounding box rejection. - if (box1.Right < box2.Left || box2.Right < box1.Left || - box1.Top < box2.Bottom || box2.Top < box1.Bottom) - continue; - - List pts; - - if (parts[i].Intersects(parts[j], out pts)) - { - var b1 = parts[i].BoundingBox; - var b2 = parts[j].BoundingBox; - Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" + - $" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" + - $" intersections={pts?.Count ?? 0}"); - return true; - } - } - } - - return false; - } - - // --- Progress reporting --- - - private static void ReportProgress( - IProgress progress, - NestPhase phase, - int plateNumber, - List best, - Box workArea, - string description) - { - if (progress == null || best == null || best.Count == 0) - return; - - var score = FillScore.Compute(best, workArea); - var clonedParts = new List(best.Count); - var totalPartArea = 0.0; - - foreach (var part in best) - { - clonedParts.Add((Part)part.Clone()); - totalPartArea += part.BaseDrawing.Area; - } - - var bounds = best.GetBoundingBox(); - - progress.Report(new NestProgress - { - Phase = phase, - PlateNumber = plateNumber, - BestPartCount = score.Count, - BestDensity = score.Density, - NestedWidth = bounds.Width, - NestedLength = bounds.Length, - NestedArea = totalPartArea, - UsableRemnantArea = workArea.Area() - totalPartArea, - BestParts = clonedParts, - Description = description - }); - } - - private string BuildProgressSummary() - { - if (PhaseResults.Count == 0) - return null; - - var parts = new List(PhaseResults.Count); - - foreach (var r in PhaseResults) - parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}"); - - return string.Join(" | ", parts); - } - - private static string FormatPhaseName(NestPhase phase) - { - switch (phase) - { - case NestPhase.Pairs: return "Pairs"; - case NestPhase.Linear: return "Linear"; - case NestPhase.RectBestFit: return "BestFit"; - case NestPhase.Remainder: return "Remainder"; - default: return phase.ToString(); - } - } - } } diff --git a/OpenNest.Engine/ML/BruteForceRunner.cs b/OpenNest.Engine/ML/BruteForceRunner.cs index f273e94..a29e272 100644 --- a/OpenNest.Engine/ML/BruteForceRunner.cs +++ b/OpenNest.Engine/ML/BruteForceRunner.cs @@ -27,7 +27,7 @@ namespace OpenNest.Engine.ML { public static BruteForceResult Run(Drawing drawing, Plate plate, bool forceFullAngleSweep = false) { - var engine = new NestEngine(plate); + var engine = new DefaultNestEngine(plate); engine.ForceFullAngleSweep = forceFullAngleSweep; var item = new NestItem { Drawing = drawing }; diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs new file mode 100644 index 0000000..64aa0ec --- /dev/null +++ b/OpenNest.Engine/NestEngineBase.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest +{ + public abstract class NestEngineBase + { + protected NestEngineBase(Plate plate) + { + Plate = plate; + } + + public Plate Plate { get; set; } + + public int PlateNumber { get; set; } + + public NestDirection NestDirection { get; set; } + + public NestPhase WinnerPhase { get; protected set; } + + public List PhaseResults { get; } = new(); + + public List AngleResults { get; } = new(); + + public abstract string Name { get; } + + public abstract string Description { get; } + + // --- Virtual methods (side-effect-free, return parts) --- + + public virtual List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + return new List(); + } + + public virtual List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + { + return new List(); + } + + public virtual List PackArea(Box box, List items, + IProgress progress, CancellationToken token) + { + return new List(); + } + + // --- FillExact (non-virtual, delegates to virtual Fill) --- + + public List FillExact(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + return Fill(item, workArea, progress, token); + } + + // --- Convenience overloads (mutate plate, return bool) --- + + public bool Fill(NestItem item) + { + return Fill(item, Plate.WorkArea()); + } + + public bool Fill(NestItem item, Box workArea) + { + var parts = Fill(item, workArea, null, CancellationToken.None); + + if (parts == null || parts.Count == 0) + return false; + + Plate.Parts.AddRange(parts); + return true; + } + + public bool Fill(List groupParts) + { + return Fill(groupParts, Plate.WorkArea()); + } + + public bool Fill(List groupParts, Box workArea) + { + var parts = Fill(groupParts, workArea, null, CancellationToken.None); + + if (parts == null || parts.Count == 0) + return false; + + Plate.Parts.AddRange(parts); + return true; + } + + public bool Pack(List items) + { + var workArea = Plate.WorkArea(); + var parts = PackArea(workArea, items, null, CancellationToken.None); + + if (parts == null || parts.Count == 0) + return false; + + Plate.Parts.AddRange(parts); + return true; + } + + // --- Protected utilities --- + + protected static void ReportProgress( + IProgress progress, + NestPhase phase, + int plateNumber, + List best, + Box workArea, + string description) + { + if (progress == null || best == null || best.Count == 0) + return; + + var score = FillScore.Compute(best, workArea); + var clonedParts = new List(best.Count); + var totalPartArea = 0.0; + + foreach (var part in best) + { + clonedParts.Add((Part)part.Clone()); + totalPartArea += part.BaseDrawing.Area; + } + + var bounds = best.GetBoundingBox(); + + var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " + + $"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " + + $"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " + + $"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}"; + Debug.WriteLine(msg); + try { System.IO.File.AppendAllText( + System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"), + $"{DateTime.Now:HH:mm:ss.fff} {msg}\n"); } catch { } + + progress.Report(new NestProgress + { + Phase = phase, + PlateNumber = plateNumber, + BestPartCount = score.Count, + BestDensity = score.Density, + NestedWidth = bounds.Width, + NestedLength = bounds.Length, + NestedArea = totalPartArea, + UsableRemnantArea = workArea.Area() - totalPartArea, + BestParts = clonedParts, + Description = description + }); + } + + protected string BuildProgressSummary() + { + if (PhaseResults.Count == 0) + return null; + + var parts = new List(PhaseResults.Count); + + foreach (var r in PhaseResults) + parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}"); + + return string.Join(" | ", parts); + } + + protected bool IsBetterFill(List candidate, List current, Box workArea) + { + if (candidate == null || candidate.Count == 0) + return false; + + if (current == null || current.Count == 0) + return true; + + return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea); + } + + protected bool IsBetterValidFill(List candidate, List current, Box workArea) + { + if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing)) + { + Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})"); + return false; + } + + return IsBetterFill(candidate, current, workArea); + } + + protected static bool HasOverlaps(List parts, double spacing) + { + if (parts == null || parts.Count <= 1) + return false; + + for (var i = 0; i < parts.Count; i++) + { + var box1 = parts[i].BoundingBox; + + for (var j = i + 1; j < parts.Count; j++) + { + var box2 = parts[j].BoundingBox; + + if (box1.Right < box2.Left || box2.Right < box1.Left || + box1.Top < box2.Bottom || box2.Top < box1.Bottom) + continue; + + List pts; + + if (parts[i].Intersects(parts[j], out pts)) + { + var b1 = parts[i].BoundingBox; + var b2 = parts[j].BoundingBox; + Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" + + $" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" + + $" intersections={pts?.Count ?? 0}"); + return true; + } + } + } + + return false; + } + + protected static string FormatPhaseName(NestPhase phase) + { + switch (phase) + { + case NestPhase.Pairs: return "Pairs"; + case NestPhase.Linear: return "Linear"; + case NestPhase.RectBestFit: return "BestFit"; + case NestPhase.Remainder: return "Remainder"; + default: return phase.ToString(); + } + } + } +} From 4baeb57e841a46cbc0c168115da34a6bf38bc477 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:07:59 -0400 Subject: [PATCH 076/116] feat: add NestEngineInfo and NestEngineRegistry with plugin loading Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngineInfo.cs | 18 +++++ OpenNest.Engine/NestEngineRegistry.cs | 96 +++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 OpenNest.Engine/NestEngineInfo.cs create mode 100644 OpenNest.Engine/NestEngineRegistry.cs diff --git a/OpenNest.Engine/NestEngineInfo.cs b/OpenNest.Engine/NestEngineInfo.cs new file mode 100644 index 0000000..a18a93d --- /dev/null +++ b/OpenNest.Engine/NestEngineInfo.cs @@ -0,0 +1,18 @@ +using System; + +namespace OpenNest +{ + public class NestEngineInfo + { + public NestEngineInfo(string name, string description, Func factory) + { + Name = name; + Description = description; + Factory = factory; + } + + public string Name { get; } + public string Description { get; } + public Func Factory { get; } + } +} diff --git a/OpenNest.Engine/NestEngineRegistry.cs b/OpenNest.Engine/NestEngineRegistry.cs new file mode 100644 index 0000000..a189de9 --- /dev/null +++ b/OpenNest.Engine/NestEngineRegistry.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace OpenNest +{ + public static class NestEngineRegistry + { + private static readonly List engines = new(); + + static NestEngineRegistry() + { + Register("Default", + "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)", + plate => new DefaultNestEngine(plate)); + } + + public static IReadOnlyList AvailableEngines => engines; + + public static string ActiveEngineName { get; set; } = "Default"; + + public static NestEngineBase Create(Plate plate) + { + var info = engines.FirstOrDefault(e => + e.Name.Equals(ActiveEngineName, StringComparison.OrdinalIgnoreCase)); + + if (info == null) + { + Debug.WriteLine($"[NestEngineRegistry] Engine '{ActiveEngineName}' not found, falling back to Default"); + info = engines[0]; + } + + return info.Factory(plate); + } + + public static void Register(string name, string description, Func factory) + { + if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + { + Debug.WriteLine($"[NestEngineRegistry] Duplicate engine '{name}' skipped"); + return; + } + + engines.Add(new NestEngineInfo(name, description, factory)); + } + + public static void LoadPlugins(string directory) + { + if (!Directory.Exists(directory)) + return; + + foreach (var dll in Directory.GetFiles(directory, "*.dll")) + { + try + { + var assembly = Assembly.LoadFrom(dll); + + foreach (var type in assembly.GetTypes()) + { + if (type.IsAbstract || !typeof(NestEngineBase).IsAssignableFrom(type)) + continue; + + var ctor = type.GetConstructor(new[] { typeof(Plate) }); + + if (ctor == null) + { + Debug.WriteLine($"[NestEngineRegistry] Skipping {type.Name}: no Plate constructor"); + continue; + } + + // Create a temporary instance to read Name and Description. + try + { + var tempPlate = new Plate(); + var instance = (NestEngineBase)ctor.Invoke(new object[] { tempPlate }); + Register(instance.Name, instance.Description, + plate => (NestEngineBase)ctor.Invoke(new object[] { plate })); + Debug.WriteLine($"[NestEngineRegistry] Loaded plugin engine: {instance.Name}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[NestEngineRegistry] Failed to instantiate {type.Name}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NestEngineRegistry] Failed to load assembly {Path.GetFileName(dll)}: {ex.Message}"); + } + } + } + } +} From a26ab2ba284cf367421e96251086a497b6ff59f6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:09:46 -0400 Subject: [PATCH 077/116] refactor: migrate NestingTools to NestEngineRegistry Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Mcp/Tools/NestingTools.cs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 8bd5e00..4ee5fc3 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -34,7 +34,7 @@ namespace OpenNest.Mcp.Tools return $"Error: drawing '{drawingName}' not found"; var countBefore = plate.Parts.Count; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var item = new NestItem { Drawing = drawing, Quantity = quantity }; var success = engine.Fill(item); @@ -70,7 +70,7 @@ namespace OpenNest.Mcp.Tools return $"Error: drawing '{drawingName}' not found"; var countBefore = plate.Parts.Count; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var item = new NestItem { Drawing = drawing, Quantity = quantity }; var area = new Box(x, y, width, height); var success = engine.Fill(item, area); @@ -111,7 +111,7 @@ namespace OpenNest.Mcp.Tools sb.AppendLine($"Found {remnants.Count} remnant area(s) on plate {plateIndex}"); var totalAdded = 0; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); for (var i = 0; i < remnants.Count; i++) { @@ -173,7 +173,7 @@ namespace OpenNest.Mcp.Tools } var countBefore = plate.Parts.Count; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var success = engine.Pack(items); var countAfter = plate.Parts.Count; var added = countAfter - countBefore; @@ -252,7 +252,7 @@ namespace OpenNest.Mcp.Tools if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0) continue; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var parts = engine.FillExact(item, workArea, null, CancellationToken.None); if (parts.Count > 0) @@ -271,10 +271,13 @@ namespace OpenNest.Mcp.Tools if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0) { - var before = plate.Parts.Count; - var engine = new NestEngine(plate); - engine.PackArea(workArea, packItems); - totalPlaced += plate.Parts.Count - before; + var engine = NestEngineRegistry.Create(plate); + var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); + if (packParts.Count > 0) + { + plate.Parts.AddRange(packParts); + totalPlaced += packParts.Count; + } } var sb = new StringBuilder(); From 01283d2b187886bdc44691a13d40a6d78f3dc2c3 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:10:10 -0400 Subject: [PATCH 078/116] refactor: migrate Console Program to NestEngineRegistry Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/Program.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 9709889..61cb1ad 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -348,7 +348,7 @@ static class NestConsole if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0) continue; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var parts = engine.FillExact(item, workArea, null, CancellationToken.None); if (parts.Count > 0) @@ -367,17 +367,17 @@ static class NestConsole if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0) { - var engine = new NestEngine(plate); - var before = plate.Parts.Count; - engine.PackArea(workArea, packItems); + var engine = NestEngineRegistry.Create(plate); + var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); + plate.Parts.AddRange(packParts); - if (plate.Parts.Count > before) + if (packParts.Count > 0) success = true; } } else { - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var item = new NestItem { Drawing = drawing, Quantity = options.Quantity }; success = engine.Fill(item); } From bd3984037c7db2896c4fcad0a57c233ec7c2fa84 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:11:11 -0400 Subject: [PATCH 079/116] refactor: migrate WinForms callsites to NestEngineRegistry Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Actions/ActionFillArea.cs | 4 ++-- OpenNest/Controls/PlateView.cs | 2 +- OpenNest/Forms/MainForm.cs | 16 ++++++++++------ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/OpenNest/Actions/ActionFillArea.cs b/OpenNest/Actions/ActionFillArea.cs index 00d2da3..4101232 100644 --- a/OpenNest/Actions/ActionFillArea.cs +++ b/OpenNest/Actions/ActionFillArea.cs @@ -47,7 +47,7 @@ namespace OpenNest.Actions { try { - var engine = new NestEngine(plateView.Plate); + var engine = NestEngineRegistry.Create(plateView.Plate); var parts = await Task.Run(() => engine.Fill(new NestItem { Drawing = drawing }, SelectedArea, progress, cts.Token)); @@ -61,7 +61,7 @@ namespace OpenNest.Actions } else { - var engine = new NestEngine(plateView.Plate); + var engine = NestEngineRegistry.Create(plateView.Plate); engine.Fill(new NestItem { Drawing = drawing }, SelectedArea); plateView.Invalidate(); } diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index 5db1be3..5d884ac 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -833,7 +833,7 @@ namespace OpenNest.Controls try { - var engine = new NestEngine(Plate); + var engine = NestEngineRegistry.Create(Plate); var parts = await Task.Run(() => engine.Fill(groupParts, workArea, progress, cts.Token)); diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index a90a5e7..afdf448 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -53,6 +53,9 @@ namespace OpenNest.Forms if (GpuEvaluatorFactory.GpuAvailable) BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer(); + + var enginesDir = Path.Combine(Application.StartupPath, "Engines"); + NestEngineRegistry.LoadPlugins(enginesDir); } private Nest CreateDefaultNest() @@ -794,7 +797,8 @@ namespace OpenNest.Forms if (workArea.Width <= 0 || workArea.Length <= 0) break; - var engine = new NestEngine(plate) { PlateNumber = plateCount }; + var engine = NestEngineRegistry.Create(plate); + engine.PlateNumber = plateCount; var parts = await Task.Run(() => engine.FillExact(item, workArea, progress, token)); @@ -826,10 +830,10 @@ namespace OpenNest.Forms if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0 && !token.IsCancellationRequested) { - var engine = new NestEngine(plate); - var partsBefore = plate.Parts.Count; - engine.PackArea(workArea, packItems); - var packed = plate.Parts.Count - partsBefore; + var engine = NestEngineRegistry.Create(plate); + var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); + plate.Parts.AddRange(packParts); + var packed = packParts.Count; if (packed > 0) { @@ -962,7 +966,7 @@ namespace OpenNest.Forms try { var plate = activeForm.PlateView.Plate; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var parts = await Task.Run(() => engine.Fill(new NestItem { Drawing = drawing }, From 81a57dc47097f3ebb3a82f4a1c66b764b7616ca1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:12:53 -0400 Subject: [PATCH 080/116] docs: update CLAUDE.md for abstract nest engine architecture Document NestEngineBase hierarchy, NestEngineRegistry, and plugin loading in the Engine section. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 04692f8..bf99449 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,8 +33,10 @@ Domain model, geometry, and CNC primitives organized into namespaces: - **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning. ### OpenNest.Engine (class library, depends on Core) -Nesting algorithms. `NestEngine` orchestrates filling plates with parts. `AutoNester` handles multi-plate auto-nesting. +Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine. `AutoNester` handles mixed-part NFP-based nesting with simulated annealing (not yet integrated into the registry). +- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`. +- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency. - **BestFit/**: NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups. - **RectanglePacking/**: `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions. - **CirclePacking/**: Alternative packing for circular parts. From 42d404577b4d44ae32cd914bd480a6d5c642986e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:39:01 -0400 Subject: [PATCH 081/116] feat: add StripNestEngine with strip-based multi-drawing nesting New NestEngineBase subclass that dedicates a tight strip to the largest-area drawing and fills the remnant with remaining drawings. Tries both bottom and left orientations, uses a shrink loop to find the tightest strip, and picks the denser result. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/StripDirection.cs | 8 + OpenNest.Engine/StripNestEngine.cs | 278 +++++++++++++++++++++++++++++ OpenNest.Engine/StripNestResult.cs | 14 ++ 3 files changed, 300 insertions(+) create mode 100644 OpenNest.Engine/StripDirection.cs create mode 100644 OpenNest.Engine/StripNestEngine.cs create mode 100644 OpenNest.Engine/StripNestResult.cs diff --git a/OpenNest.Engine/StripDirection.cs b/OpenNest.Engine/StripDirection.cs new file mode 100644 index 0000000..10005c8 --- /dev/null +++ b/OpenNest.Engine/StripDirection.cs @@ -0,0 +1,8 @@ +namespace OpenNest +{ + public enum StripDirection + { + Bottom, + Left + } +} diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs new file mode 100644 index 0000000..3050c4e --- /dev/null +++ b/OpenNest.Engine/StripNestEngine.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + public class StripNestEngine : NestEngineBase + { + private const int MaxShrinkIterations = 20; + + public StripNestEngine(Plate plate) : base(plate) + { + } + + public override string Name => "Strip"; + + public override string Description => "Strip-based nesting for mixed-drawing layouts"; + + /// + /// Single-item fill delegates to DefaultNestEngine. + /// The strip strategy adds value for multi-drawing nesting, not single-item fills. + /// + public override List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(item, workArea, progress, token); + } + + /// + /// Selects the item that consumes the most plate area (bounding box area x quantity). + /// Returns the index into the items list. + /// + private static int SelectStripItemIndex(List items, Box workArea) + { + var bestIndex = 0; + var bestArea = 0.0; + + for (var i = 0; i < items.Count; i++) + { + var bbox = items[i].Drawing.Program.BoundingBox(); + var qty = items[i].Quantity > 0 + ? items[i].Quantity + : (int)(workArea.Area() / bbox.Area()); + var totalArea = bbox.Area() * qty; + + if (totalArea > bestArea) + { + bestArea = totalArea; + bestIndex = i; + } + } + + return bestIndex; + } + + /// + /// Estimates the strip dimension (height for bottom, width for left) needed + /// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter. + /// This is only an estimate for the shrink loop starting point — the actual fill + /// uses DefaultNestEngine.Fill which tries many rotation angles internally. + /// + private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension) + { + var bbox = item.Drawing.Program.BoundingBox(); + var qty = item.Quantity > 0 + ? item.Quantity + : System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area())); + + // At 0 deg: parts per row along strip length, strip dimension is bbox.Length + var perRow0 = (int)(stripLength / bbox.Width); + var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue; + var dim0 = rows0 * bbox.Length; + + // At 90 deg: rotated bounding box (Width and Length swap) + var perRow90 = (int)(stripLength / bbox.Length); + var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue; + var dim90 = rows90 * bbox.Width; + + var estimate = System.Math.Min(dim0, dim90); + + // Clamp to available dimension + return System.Math.Min(estimate, maxDimension); + } + + /// + /// Multi-drawing strip nesting strategy. + /// Picks the largest-area drawing for strip treatment, finds the tightest strip + /// in both bottom and left orientations, fills remnants with remaining drawings, + /// and returns the denser result. + /// + public List Nest(List items, + IProgress progress, CancellationToken token) + { + if (items == null || items.Count == 0) + return new List(); + + var workArea = Plate.WorkArea(); + + // Select which item gets the strip treatment. + var stripIndex = SelectStripItemIndex(items, workArea); + var stripItem = items[stripIndex]; + var remainderItems = items.Where((_, i) => i != stripIndex).ToList(); + + // Try both orientations. + var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, token); + var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, token); + + // Pick the better result. + if (bottomResult.Score >= leftResult.Score) + return bottomResult.Parts; + + return leftResult.Parts; + } + + private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem, + List remainderItems, Box workArea, CancellationToken token) + { + var result = new StripNestResult { Direction = direction }; + + if (token.IsCancellationRequested) + return result; + + // Estimate initial strip dimension. + var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length; + var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width; + var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension); + + // Create the initial strip box. + var stripBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim) + : new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length); + + // Initial fill using DefaultNestEngine (composition, not inheritance). + var inner = new DefaultNestEngine(Plate); + var stripParts = inner.Fill( + new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, + stripBox, null, token); + + if (stripParts == null || stripParts.Count == 0) + return result; + + // Measure actual strip dimension from placed parts. + var placedBox = stripParts.Cast().GetBoundingBox(); + var actualDim = direction == StripDirection.Bottom + ? placedBox.Top - workArea.Y + : placedBox.Right - workArea.X; + + var bestParts = stripParts; + var bestDim = actualDim; + var targetCount = stripParts.Count; + + // Shrink loop: reduce strip dimension by PartSpacing until count drops. + for (var i = 0; i < MaxShrinkIterations; i++) + { + if (token.IsCancellationRequested) + break; + + var trialDim = bestDim - Plate.PartSpacing; + if (trialDim <= 0) + break; + + var trialBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y, workArea.Width, trialDim) + : new Box(workArea.X, workArea.Y, trialDim, workArea.Length); + + var trialInner = new DefaultNestEngine(Plate); + var trialParts = trialInner.Fill( + new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, + trialBox, null, token); + + if (trialParts == null || trialParts.Count < targetCount) + break; + + // Same count in a tighter strip — keep going. + bestParts = trialParts; + var trialPlacedBox = trialParts.Cast().GetBoundingBox(); + bestDim = direction == StripDirection.Bottom + ? trialPlacedBox.Top - workArea.Y + : trialPlacedBox.Right - workArea.X; + } + + // Build remnant box with spacing gap. + var spacing = Plate.PartSpacing; + var remnantBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y + bestDim + spacing, + workArea.Width, workArea.Length - bestDim - spacing) + : new Box(workArea.X + bestDim + spacing, workArea.Y, + workArea.Width - bestDim - spacing, workArea.Length); + + // Collect all parts. + var allParts = new List(bestParts); + + // If strip item was only partially placed, add leftovers to remainder. + var placed = bestParts.Count; + var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0; + var effectiveRemainder = new List(remainderItems); + + if (leftover > 0) + { + effectiveRemainder.Add(new NestItem + { + Drawing = stripItem.Drawing, + Quantity = leftover + }); + } + + // Sort remainder by descending bounding box area x quantity. + effectiveRemainder = effectiveRemainder + .OrderByDescending(i => + { + var bb = i.Drawing.Program.BoundingBox(); + return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1); + }) + .ToList(); + + // Fill remnant with remainder items, shrinking the available area after each. + if (remnantBox.Width > 0 && remnantBox.Length > 0) + { + var currentRemnant = remnantBox; + + foreach (var item in effectiveRemainder) + { + if (token.IsCancellationRequested) + break; + + if (currentRemnant.Width <= 0 || currentRemnant.Length <= 0) + break; + + var remnantInner = new DefaultNestEngine(Plate); + var remnantParts = remnantInner.Fill( + new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, + currentRemnant, null, token); + + if (remnantParts != null && remnantParts.Count > 0) + { + allParts.AddRange(remnantParts); + + // Shrink remnant to avoid overlap with next item. + var usedBox = remnantParts.Cast().GetBoundingBox(); + currentRemnant = ComputeRemainderWithin(currentRemnant, usedBox, spacing); + } + } + } + + result.Parts = allParts; + result.StripBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y, workArea.Width, bestDim) + : new Box(workArea.X, workArea.Y, bestDim, workArea.Length); + result.RemnantBox = remnantBox; + result.Score = FillScore.Compute(allParts, workArea); + + return result; + } + + /// + /// Computes the largest usable remainder within a work area after a portion has been used. + /// Picks whichever is larger: the horizontal strip to the right, or the vertical strip above. + /// + private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) + { + var hWidth = workArea.Right - usedBox.Right - spacing; + var hStrip = hWidth > 0 + ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) + : Box.Empty; + + var vHeight = workArea.Top - usedBox.Top - spacing; + var vStrip = vHeight > 0 + ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) + : Box.Empty; + + return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; + } + } +} diff --git a/OpenNest.Engine/StripNestResult.cs b/OpenNest.Engine/StripNestResult.cs new file mode 100644 index 0000000..f849d8e --- /dev/null +++ b/OpenNest.Engine/StripNestResult.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest +{ + internal class StripNestResult + { + public List Parts { get; set; } = new(); + public Box StripBox { get; set; } + public Box RemnantBox { get; set; } + public FillScore Score { get; set; } + public StripDirection Direction { get; set; } + } +} From 4d80710b4862ada96da69aad37d115ca0487a304 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:39:25 -0400 Subject: [PATCH 082/116] feat: register StripNestEngine in NestEngineRegistry Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/NestEngineRegistry.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/OpenNest.Engine/NestEngineRegistry.cs b/OpenNest.Engine/NestEngineRegistry.cs index a189de9..82973ea 100644 --- a/OpenNest.Engine/NestEngineRegistry.cs +++ b/OpenNest.Engine/NestEngineRegistry.cs @@ -16,6 +16,10 @@ namespace OpenNest Register("Default", "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)", plate => new DefaultNestEngine(plate)); + + Register("Strip", + "Strip-based nesting for mixed-drawing layouts", + plate => new StripNestEngine(plate)); } public static IReadOnlyList AvailableEngines => engines; From cd858578166d2a3493ce963b0a243b6d02026279 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:40:21 -0400 Subject: [PATCH 083/116] feat: integrate StripNestEngine into autonest_plate MCP tool Runs strip and sequential strategies in competition, picks the denser result. Reports scores for both strategies in output. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Mcp/Tools/NestingTools.cs | 92 +++++++++++++++--------------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 4ee5fc3..21f02ef 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -233,58 +233,28 @@ namespace OpenNest.Mcp.Tools items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); } - var fillItems = items - .Where(i => i.Quantity > 1) - .OrderBy(i => i.Priority) - .ThenByDescending(i => i.Drawing.Area) - .ToList(); + // Strategy 1: Strip nesting + var stripEngine = new StripNestEngine(plate); + var stripResult = stripEngine.Nest(items, null, CancellationToken.None); + var stripScore = FillScore.Compute(stripResult, plate.WorkArea()); - var packItems = items - .Where(i => i.Quantity == 1) - .ToList(); + // Strategy 2: Current sequential fill + var seqResult = SequentialFill(plate, items); + var seqScore = FillScore.Compute(seqResult, plate.WorkArea()); - var workArea = plate.WorkArea(); - var totalPlaced = 0; - - // Phase 1: Fill multi-quantity drawings with NestEngine. - foreach (var item in fillItems) - { - if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0) - continue; - - var engine = NestEngineRegistry.Create(plate); - var parts = engine.FillExact(item, workArea, null, CancellationToken.None); - - if (parts.Count > 0) - { - plate.Parts.AddRange(parts); - // TODO: Compactor.Compact(parts, plate); - item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); - totalPlaced += parts.Count; - var placedBox = parts.Cast().GetBoundingBox(); - workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); - } - } - - // Phase 2: Pack single-quantity items into remaining space. - packItems = packItems.Where(i => i.Quantity > 0).ToList(); - - if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0) - { - var engine = NestEngineRegistry.Create(plate); - var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); - if (packParts.Count > 0) - { - plate.Parts.AddRange(packParts); - totalPlaced += packParts.Count; - } - } + // Pick winner and apply to plate. + var winner = stripScore >= seqScore ? stripResult : seqResult; + var winnerName = stripScore >= seqScore ? "strip" : "sequential"; + plate.Parts.AddRange(winner); + var totalPlaced = winner.Count; var sb = new StringBuilder(); - sb.AppendLine($"AutoNest plate {plateIndex}: {(totalPlaced > 0 ? "success" : "no parts placed")}"); + sb.AppendLine($"AutoNest plate {plateIndex} ({winnerName} strategy): {(totalPlaced > 0 ? "success" : "no parts placed")}"); sb.AppendLine($" Parts placed: {totalPlaced}"); sb.AppendLine($" Total parts: {plate.Parts.Count}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}"); + sb.AppendLine($" Strip score: {stripScore.Count} parts, density {stripScore.Density:P1}"); + sb.AppendLine($" Sequential score: {seqScore.Count} parts, density {seqScore.Density:P1}"); var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name); foreach (var group in groups) @@ -293,6 +263,38 @@ namespace OpenNest.Mcp.Tools return sb.ToString(); } + private static List SequentialFill(Plate plate, List items) + { + var fillItems = items + .Where(i => i.Quantity != 1) + .OrderBy(i => i.Priority) + .ThenByDescending(i => i.Drawing.Area) + .ToList(); + + var workArea = plate.WorkArea(); + var allParts = new List(); + + foreach (var item in fillItems) + { + if (item.Quantity == 0 || workArea.Width <= 0 || workArea.Length <= 0) + continue; + + var engine = new DefaultNestEngine(plate); + var parts = engine.Fill( + new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, + workArea, null, CancellationToken.None); + + if (parts.Count > 0) + { + allParts.AddRange(parts); + var placedBox = parts.Cast().GetBoundingBox(); + workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); + } + } + + return allParts; + } + private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) { var hWidth = workArea.Right - usedBox.Right - spacing; From 7462d1bdcaab6266049be1cc2706b4a4c3400e74 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 22:01:06 -0400 Subject: [PATCH 084/116] feat(ui): add engine selector dropdown to main toolstrip ToolStripComboBox populated from NestEngineRegistry.AvailableEngines. Changing selection sets NestEngineRegistry.ActiveEngineName globally. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Forms/MainForm.Designer.cs | 30 +++++++++++++++++++++++++++-- OpenNest/Forms/MainForm.cs | 12 ++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index e32d58d..8e608af 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -145,6 +145,9 @@ this.btnZoomOut = new System.Windows.Forms.ToolStripButton(); this.btnZoomIn = new System.Windows.Forms.ToolStripButton(); this.btnZoomToFit = new System.Windows.Forms.ToolStripButton(); + this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator(); + this.engineLabel = new System.Windows.Forms.ToolStripLabel(); + this.engineComboBox = new System.Windows.Forms.ToolStripComboBox(); this.pEPToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.openNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.menuStrip1.SuspendLayout(); @@ -996,7 +999,10 @@ this.toolStripSeparator3, this.btnZoomOut, this.btnZoomIn, - this.btnZoomToFit}); + this.btnZoomToFit, + this.toolStripSeparator4, + this.engineLabel, + this.engineComboBox}); this.toolStrip1.Location = new System.Drawing.Point(0, 24); this.toolStrip1.Name = "toolStrip1"; this.toolStrip1.Size = new System.Drawing.Size(1098, 35); @@ -1149,7 +1155,24 @@ this.btnZoomToFit.Size = new System.Drawing.Size(38, 32); this.btnZoomToFit.Text = "Zoom To Fit"; this.btnZoomToFit.Click += new System.EventHandler(this.ZoomToFit_Click); - // + // + // toolStripSeparator4 + // + this.toolStripSeparator4.Name = "toolStripSeparator4"; + this.toolStripSeparator4.Size = new System.Drawing.Size(6, 35); + // + // engineLabel + // + this.engineLabel.Name = "engineLabel"; + this.engineLabel.Size = new System.Drawing.Size(46, 32); + this.engineLabel.Text = "Engine:"; + // + // engineComboBox + // + this.engineComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.engineComboBox.Name = "engineComboBox"; + this.engineComboBox.Size = new System.Drawing.Size(100, 35); + // // pEPToolStripMenuItem // this.pEPToolStripMenuItem.Name = "pEPToolStripMenuItem"; @@ -1309,5 +1332,8 @@ private System.Windows.Forms.ToolStripButton btnSaveAs; private System.Windows.Forms.ToolStripMenuItem centerPartsToolStripMenuItem; private System.Windows.Forms.ToolStripStatusLabel gpuStatusLabel; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator4; + private System.Windows.Forms.ToolStripLabel engineLabel; + private System.Windows.Forms.ToolStripComboBox engineComboBox; } } \ No newline at end of file diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index afdf448..c540656 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -56,6 +56,12 @@ namespace OpenNest.Forms var enginesDir = Path.Combine(Application.StartupPath, "Engines"); NestEngineRegistry.LoadPlugins(enginesDir); + + foreach (var engine in NestEngineRegistry.AvailableEngines) + engineComboBox.Items.Add(engine.Name); + + engineComboBox.SelectedItem = NestEngineRegistry.ActiveEngineName; + engineComboBox.SelectedIndexChanged += EngineComboBox_SelectedIndexChanged; } private Nest CreateDefaultNest() @@ -252,6 +258,12 @@ namespace OpenNest.Forms } } + private void EngineComboBox_SelectedIndexChanged(object sender, EventArgs e) + { + if (engineComboBox.SelectedItem is string name) + NestEngineRegistry.ActiveEngineName = name; + } + private void UpdateLocationMode() { if (activeForm == null) From bb703ef8eb7390cb100b611c7dbfd19fdb68c1b5 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 22:09:29 -0400 Subject: [PATCH 085/116] feat(ui): wire strip engine into UI auto-nest flow When Strip is selected in the engine dropdown, RunAutoNest_Click calls StripNestEngine.Nest() instead of sequential FillExact+Pack. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Forms/MainForm.cs | 138 ++++++++++++++++++++++--------------- 1 file changed, 81 insertions(+), 57 deletions(-) diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index c540656..c0c75ea 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -785,80 +785,104 @@ namespace OpenNest.Forms if (plate != activeForm.PlateView.Plate) activeForm.LoadLastPlate(); - // Split items: Fill produces great results for qty > 1, - // Pack is fast for single-quantity items. - var fillItems = remaining - .Where(i => i.Quantity > 1) - .OrderBy(i => i.Priority) - .ThenByDescending(i => i.Drawing.Area) - .ToList(); - - var packItems = remaining - .Where(i => i.Quantity == 1) - .ToList(); - - var workArea = plate.WorkArea(); var anyPlaced = false; - // Phase 1: Fill each multi-quantity drawing with NestEngine. - foreach (var item in fillItems) + // Strip engine: use Nest() for multi-drawing strategy. + if (NestEngineRegistry.Create(plate) is StripNestEngine) { - if (item.Quantity <= 0 || token.IsCancellationRequested) - continue; - - if (workArea.Width <= 0 || workArea.Length <= 0) - break; - - var engine = NestEngineRegistry.Create(plate); - engine.PlateNumber = plateCount; - - var parts = await Task.Run(() => - engine.FillExact(item, workArea, progress, token)); + var stripEngine = new StripNestEngine(plate); + var stripParts = await Task.Run(() => + stripEngine.Nest(remaining, progress, token)); activeForm.PlateView.ClearTemporaryParts(); - if (token.IsCancellationRequested) - break; - - if (parts.Count > 0) + if (stripParts.Count > 0 && !token.IsCancellationRequested) { - plate.Parts.AddRange(parts); - // TODO: Compactor.Compact(parts, plate); + plate.Parts.AddRange(stripParts); activeForm.PlateView.Invalidate(); anyPlaced = true; - item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); - - // Compute remainder within the current work area based on - // what was just placed — not the full plate bounding box. - var placedBox = parts.Cast().GetBoundingBox(); - workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); + // Deduct placed quantities. + foreach (var item in remaining) + { + var placed = stripParts.Count(p => + p.BaseDrawing.Name == item.Drawing.Name); + item.Quantity = System.Math.Max(0, item.Quantity - placed); + } } } - - // Phase 2: Pack single-quantity items into remaining space. - packItems = packItems.Where(i => i.Quantity > 0).ToList(); - - if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0 - && !token.IsCancellationRequested) + else { - var engine = NestEngineRegistry.Create(plate); - var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); - plate.Parts.AddRange(packParts); - var packed = packParts.Count; + // Default: sequential Fill + Pack. + var fillItems = remaining + .Where(i => i.Quantity > 1) + .OrderBy(i => i.Priority) + .ThenByDescending(i => i.Drawing.Area) + .ToList(); - if (packed > 0) + var packItems = remaining + .Where(i => i.Quantity == 1) + .ToList(); + + var workArea = plate.WorkArea(); + + // Phase 1: Fill each multi-quantity drawing with NestEngine. + foreach (var item in fillItems) { - activeForm.PlateView.Invalidate(); - anyPlaced = true; + if (item.Quantity <= 0 || token.IsCancellationRequested) + continue; - // Deduct packed quantities. - foreach (var item in packItems) + if (workArea.Width <= 0 || workArea.Length <= 0) + break; + + var engine = NestEngineRegistry.Create(plate); + engine.PlateNumber = plateCount; + + var parts = await Task.Run(() => + engine.FillExact(item, workArea, progress, token)); + + activeForm.PlateView.ClearTemporaryParts(); + + if (token.IsCancellationRequested) + break; + + if (parts.Count > 0) { - var placed = plate.Parts.Count(p => - p.BaseDrawing.Name == item.Drawing.Name); - item.Quantity = System.Math.Max(0, - item.Quantity - placed); + plate.Parts.AddRange(parts); + // TODO: Compactor.Compact(parts, plate); + activeForm.PlateView.Invalidate(); + anyPlaced = true; + + item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); + + var placedBox = parts.Cast().GetBoundingBox(); + workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); + } + } + + // Phase 2: Pack single-quantity items into remaining space. + packItems = packItems.Where(i => i.Quantity > 0).ToList(); + + if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0 + && !token.IsCancellationRequested) + { + var engine = NestEngineRegistry.Create(plate); + var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); + plate.Parts.AddRange(packParts); + var packed = packParts.Count; + + if (packed > 0) + { + activeForm.PlateView.Invalidate(); + anyPlaced = true; + + foreach (var item in packItems) + { + var placed = plate.Parts.Count(p => + p.BaseDrawing.Name == item.Drawing.Name); + item.Quantity = System.Math.Max(0, + item.Quantity - placed); + } } } } From 48be4d5d46caabfe4535fedb85a2dfcc43253180 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 22:16:08 -0400 Subject: [PATCH 086/116] feat: add virtual Nest method to NestEngineBase for polymorphic auto-nest The auto-nest code paths (MainForm, MCP, Console) now call engine.Nest(items, progress, token) instead of manually orchestrating sequential fill+pack. The default implementation in NestEngineBase does sequential FillExact+PackArea. StripNestEngine overrides with its strip strategy. This makes the engine dropdown actually work. Also consolidates ComputeRemainderWithin into NestEngineBase, removing duplicates from MainForm and StripNestEngine. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/Program.cs | 64 +-------------- OpenNest.Engine/NestEngineBase.cs | 82 +++++++++++++++++++ OpenNest.Engine/StripNestEngine.cs | 21 +---- OpenNest.Mcp/Tools/NestingTools.cs | 69 ++-------------- OpenNest/Forms/MainForm.cs | 124 +++-------------------------- 5 files changed, 105 insertions(+), 255 deletions(-) diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 61cb1ad..3bb08f0 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -329,51 +329,10 @@ static class NestConsole Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); - var fillItems = nestItems - .Where(i => i.Quantity > 1) - .OrderBy(i => i.Priority) - .ThenByDescending(i => i.Drawing.Area) - .ToList(); - - var packItems = nestItems - .Where(i => i.Quantity == 1) - .ToList(); - - var workArea = plate.WorkArea(); - success = false; - - // Phase 1: Fill multi-quantity drawings with NestEngine. - foreach (var item in fillItems) - { - if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0) - continue; - - var engine = NestEngineRegistry.Create(plate); - var parts = engine.FillExact(item, workArea, null, CancellationToken.None); - - if (parts.Count > 0) - { - plate.Parts.AddRange(parts); - // TODO: Compactor.Compact(parts, plate); - item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); - success = true; - var placedBox = parts.Cast().GetBoundingBox(); - workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); - } - } - - // Phase 2: Pack single-quantity items into remaining space. - packItems = packItems.Where(i => i.Quantity > 0).ToList(); - - if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0) - { - var engine = NestEngineRegistry.Create(plate); - var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); - plate.Parts.AddRange(packParts); - - if (packParts.Count > 0) - success = true; - } + var engine = NestEngineRegistry.Create(plate); + var nestParts = engine.Nest(nestItems, null, CancellationToken.None); + plate.Parts.AddRange(nestParts); + success = nestParts.Count > 0; } else { @@ -386,21 +345,6 @@ static class NestConsole return (success, sw.ElapsedMilliseconds); } - static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) - { - var hWidth = workArea.Right - usedBox.Right - spacing; - var hStrip = hWidth > 0 - ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) - : Box.Empty; - - var vHeight = workArea.Top - usedBox.Top - spacing; - var vStrip = vHeight > 0 - ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) - : Box.Empty; - - return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; - } - static int CheckOverlaps(Plate plate, Options options) { if (!options.CheckOverlaps || plate.Parts.Count == 0) diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs index 64aa0ec..2bbeb88 100644 --- a/OpenNest.Engine/NestEngineBase.cs +++ b/OpenNest.Engine/NestEngineBase.cs @@ -50,6 +50,88 @@ namespace OpenNest return new List(); } + // --- Nest: multi-item strategy (virtual, side-effect-free) --- + + public virtual List Nest(List items, + IProgress progress, CancellationToken token) + { + if (items == null || items.Count == 0) + return new List(); + + var workArea = Plate.WorkArea(); + var allParts = new List(); + + var fillItems = items + .Where(i => i.Quantity != 1) + .OrderBy(i => i.Priority) + .ThenByDescending(i => i.Drawing.Area) + .ToList(); + + var packItems = items + .Where(i => i.Quantity == 1) + .ToList(); + + // Phase 1: Fill multi-quantity drawings sequentially. + foreach (var item in fillItems) + { + if (token.IsCancellationRequested) + break; + + if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0) + continue; + + var parts = FillExact( + new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, + workArea, progress, token); + + if (parts.Count > 0) + { + allParts.AddRange(parts); + item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); + var placedBox = parts.Cast().GetBoundingBox(); + workArea = ComputeRemainderWithin(workArea, placedBox, Plate.PartSpacing); + } + } + + // Phase 2: Pack single-quantity items into remaining space. + packItems = packItems.Where(i => i.Quantity > 0).ToList(); + + if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0 + && !token.IsCancellationRequested) + { + var packParts = PackArea(workArea, packItems, progress, token); + + if (packParts.Count > 0) + { + allParts.AddRange(packParts); + + foreach (var item in packItems) + { + var placed = packParts.Count(p => + p.BaseDrawing.Name == item.Drawing.Name); + item.Quantity = System.Math.Max(0, item.Quantity - placed); + } + } + } + + return allParts; + } + + protected static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) + { + var hWidth = workArea.Right - usedBox.Right - spacing; + var hStrip = hWidth > 0 + ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) + : Box.Empty; + + var vHeight = workArea.Top - usedBox.Top - spacing; + var vStrip = vHeight > 0 + ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) + : Box.Empty; + + return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; + } + // --- FillExact (non-virtual, delegates to virtual Fill) --- public List FillExact(NestItem item, Box workArea, diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs index 3050c4e..0901ea5 100644 --- a/OpenNest.Engine/StripNestEngine.cs +++ b/OpenNest.Engine/StripNestEngine.cs @@ -92,7 +92,7 @@ namespace OpenNest /// in both bottom and left orientations, fills remnants with remaining drawings, /// and returns the denser result. /// - public List Nest(List items, + public override List Nest(List items, IProgress progress, CancellationToken token) { if (items == null || items.Count == 0) @@ -256,23 +256,6 @@ namespace OpenNest return result; } - /// - /// Computes the largest usable remainder within a work area after a portion has been used. - /// Picks whichever is larger: the horizontal strip to the right, or the vertical strip above. - /// - private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) - { - var hWidth = workArea.Right - usedBox.Right - spacing; - var hStrip = hWidth > 0 - ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) - : Box.Empty; - - var vHeight = workArea.Top - usedBox.Top - spacing; - var vStrip = vHeight > 0 - ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) - : Box.Empty; - - return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; - } + // ComputeRemainderWithin inherited from NestEngineBase } } diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 21f02ef..b1c7b28 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -233,28 +233,16 @@ namespace OpenNest.Mcp.Tools items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); } - // Strategy 1: Strip nesting - var stripEngine = new StripNestEngine(plate); - var stripResult = stripEngine.Nest(items, null, CancellationToken.None); - var stripScore = FillScore.Compute(stripResult, plate.WorkArea()); - - // Strategy 2: Current sequential fill - var seqResult = SequentialFill(plate, items); - var seqScore = FillScore.Compute(seqResult, plate.WorkArea()); - - // Pick winner and apply to plate. - var winner = stripScore >= seqScore ? stripResult : seqResult; - var winnerName = stripScore >= seqScore ? "strip" : "sequential"; - plate.Parts.AddRange(winner); - var totalPlaced = winner.Count; + var engine = NestEngineRegistry.Create(plate); + var nestParts = engine.Nest(items, null, CancellationToken.None); + plate.Parts.AddRange(nestParts); + var totalPlaced = nestParts.Count; var sb = new StringBuilder(); - sb.AppendLine($"AutoNest plate {plateIndex} ({winnerName} strategy): {(totalPlaced > 0 ? "success" : "no parts placed")}"); + sb.AppendLine($"AutoNest plate {plateIndex} ({engine.Name} engine): {(totalPlaced > 0 ? "success" : "no parts placed")}"); sb.AppendLine($" Parts placed: {totalPlaced}"); sb.AppendLine($" Total parts: {plate.Parts.Count}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}"); - sb.AppendLine($" Strip score: {stripScore.Count} parts, density {stripScore.Density:P1}"); - sb.AppendLine($" Sequential score: {seqScore.Count} parts, density {seqScore.Density:P1}"); var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name); foreach (var group in groups) @@ -262,52 +250,5 @@ namespace OpenNest.Mcp.Tools return sb.ToString(); } - - private static List SequentialFill(Plate plate, List items) - { - var fillItems = items - .Where(i => i.Quantity != 1) - .OrderBy(i => i.Priority) - .ThenByDescending(i => i.Drawing.Area) - .ToList(); - - var workArea = plate.WorkArea(); - var allParts = new List(); - - foreach (var item in fillItems) - { - if (item.Quantity == 0 || workArea.Width <= 0 || workArea.Length <= 0) - continue; - - var engine = new DefaultNestEngine(plate); - var parts = engine.Fill( - new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, - workArea, null, CancellationToken.None); - - if (parts.Count > 0) - { - allParts.AddRange(parts); - var placedBox = parts.Cast().GetBoundingBox(); - workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); - } - } - - return allParts; - } - - private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) - { - var hWidth = workArea.Right - usedBox.Right - spacing; - var hStrip = hWidth > 0 - ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) - : Box.Empty; - - var vHeight = workArea.Top - usedBox.Top - spacing; - var vStrip = vHeight > 0 - ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) - : Box.Empty; - - return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; - } } } diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index c0c75ea..26c6d50 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -787,104 +787,19 @@ namespace OpenNest.Forms var anyPlaced = false; - // Strip engine: use Nest() for multi-drawing strategy. - if (NestEngineRegistry.Create(plate) is StripNestEngine) + var engine = NestEngineRegistry.Create(plate); + engine.PlateNumber = plateCount; + + var nestParts = await Task.Run(() => + engine.Nest(remaining, progress, token)); + + activeForm.PlateView.ClearTemporaryParts(); + + if (nestParts.Count > 0 && !token.IsCancellationRequested) { - var stripEngine = new StripNestEngine(plate); - var stripParts = await Task.Run(() => - stripEngine.Nest(remaining, progress, token)); - - activeForm.PlateView.ClearTemporaryParts(); - - if (stripParts.Count > 0 && !token.IsCancellationRequested) - { - plate.Parts.AddRange(stripParts); - activeForm.PlateView.Invalidate(); - anyPlaced = true; - - // Deduct placed quantities. - foreach (var item in remaining) - { - var placed = stripParts.Count(p => - p.BaseDrawing.Name == item.Drawing.Name); - item.Quantity = System.Math.Max(0, item.Quantity - placed); - } - } - } - else - { - // Default: sequential Fill + Pack. - var fillItems = remaining - .Where(i => i.Quantity > 1) - .OrderBy(i => i.Priority) - .ThenByDescending(i => i.Drawing.Area) - .ToList(); - - var packItems = remaining - .Where(i => i.Quantity == 1) - .ToList(); - - var workArea = plate.WorkArea(); - - // Phase 1: Fill each multi-quantity drawing with NestEngine. - foreach (var item in fillItems) - { - if (item.Quantity <= 0 || token.IsCancellationRequested) - continue; - - if (workArea.Width <= 0 || workArea.Length <= 0) - break; - - var engine = NestEngineRegistry.Create(plate); - engine.PlateNumber = plateCount; - - var parts = await Task.Run(() => - engine.FillExact(item, workArea, progress, token)); - - activeForm.PlateView.ClearTemporaryParts(); - - if (token.IsCancellationRequested) - break; - - if (parts.Count > 0) - { - plate.Parts.AddRange(parts); - // TODO: Compactor.Compact(parts, plate); - activeForm.PlateView.Invalidate(); - anyPlaced = true; - - item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); - - var placedBox = parts.Cast().GetBoundingBox(); - workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); - } - } - - // Phase 2: Pack single-quantity items into remaining space. - packItems = packItems.Where(i => i.Quantity > 0).ToList(); - - if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0 - && !token.IsCancellationRequested) - { - var engine = NestEngineRegistry.Create(plate); - var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); - plate.Parts.AddRange(packParts); - var packed = packParts.Count; - - if (packed > 0) - { - activeForm.PlateView.Invalidate(); - anyPlaced = true; - - foreach (var item in packItems) - { - var placed = plate.Parts.Count(p => - p.BaseDrawing.Name == item.Drawing.Name); - item.Quantity = System.Math.Max(0, - item.Quantity - placed); - } - } - } + plate.Parts.AddRange(nestParts); + activeForm.PlateView.Invalidate(); + anyPlaced = true; } if (!anyPlaced) @@ -909,21 +824,6 @@ namespace OpenNest.Forms } } - private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) - { - var hWidth = workArea.Right - usedBox.Right - spacing; - var hStrip = hWidth > 0 - ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) - : Box.Empty; - - var vHeight = workArea.Top - usedBox.Top - spacing; - var vStrip = vHeight > 0 - ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) - : Box.Empty; - - return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; - } - private void SequenceAllPlates_Click(object sender, EventArgs e) { if (activeForm == null) From 310165db02d99774b4089c576083cdff322a4f3b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 22:26:29 -0400 Subject: [PATCH 087/116] fix: add quantity deduction and progress reporting to StripNestEngine Nest() now deducts placed counts from input NestItem.Quantity so the UI loop doesn't create extra plates. All inner DefaultNestEngine.Fill calls forward the IProgress parameter for live progress updates. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/StripNestEngine.cs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs index 0901ea5..2c9ad85 100644 --- a/OpenNest.Engine/StripNestEngine.cs +++ b/OpenNest.Engine/StripNestEngine.cs @@ -106,18 +106,29 @@ namespace OpenNest var remainderItems = items.Where((_, i) => i != stripIndex).ToList(); // Try both orientations. - var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, token); - var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, token); + var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, progress, token); + var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, progress, token); // Pick the better result. - if (bottomResult.Score >= leftResult.Score) - return bottomResult.Parts; + var winner = bottomResult.Score >= leftResult.Score + ? bottomResult.Parts + : leftResult.Parts; - return leftResult.Parts; + // Deduct placed quantities from the original items. + foreach (var item in items) + { + if (item.Quantity <= 0) + continue; + + var placed = winner.Count(p => p.BaseDrawing.Name == item.Drawing.Name); + item.Quantity = System.Math.Max(0, item.Quantity - placed); + } + + return winner; } private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem, - List remainderItems, Box workArea, CancellationToken token) + List remainderItems, Box workArea, IProgress progress, CancellationToken token) { var result = new StripNestResult { Direction = direction }; @@ -138,7 +149,7 @@ namespace OpenNest var inner = new DefaultNestEngine(Plate); var stripParts = inner.Fill( new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, - stripBox, null, token); + stripBox, progress, token); if (stripParts == null || stripParts.Count == 0) return result; @@ -170,7 +181,7 @@ namespace OpenNest var trialInner = new DefaultNestEngine(Plate); var trialParts = trialInner.Fill( new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, - trialBox, null, token); + trialBox, progress, token); if (trialParts == null || trialParts.Count < targetCount) break; @@ -233,7 +244,7 @@ namespace OpenNest var remnantInner = new DefaultNestEngine(Plate); var remnantParts = remnantInner.Fill( new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, - currentRemnant, null, token); + currentRemnant, progress, token); if (remnantParts != null && remnantParts.Count > 0) { From 4e747a8e6cc7b2206b43746df68d749b237cc179 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 22:31:11 -0400 Subject: [PATCH 088/116] fix: show strip + remnant parts together during progress updates Wrap IProgress with AccumulatingProgress so remnant fills prepend previously placed strip parts to each report. The UI now shows the full picture (red + purple) instead of replacing strip parts. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/StripNestEngine.cs | 35 +++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs index 2c9ad85..45d6384 100644 --- a/OpenNest.Engine/StripNestEngine.cs +++ b/OpenNest.Engine/StripNestEngine.cs @@ -229,9 +229,13 @@ namespace OpenNest .ToList(); // Fill remnant with remainder items, shrinking the available area after each. + // Wrap progress so remnant fills include the strip parts already found. if (remnantBox.Width > 0 && remnantBox.Length > 0) { var currentRemnant = remnantBox; + var remnantProgress = progress != null + ? new AccumulatingProgress(progress, allParts) + : null; foreach (var item in effectiveRemainder) { @@ -244,7 +248,7 @@ namespace OpenNest var remnantInner = new DefaultNestEngine(Plate); var remnantParts = remnantInner.Fill( new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, - currentRemnant, progress, token); + currentRemnant, remnantProgress, token); if (remnantParts != null && remnantParts.Count > 0) { @@ -268,5 +272,34 @@ namespace OpenNest } // ComputeRemainderWithin inherited from NestEngineBase + /// + /// Wraps an IProgress to prepend previously placed parts to each report, + /// so the UI shows the full picture (strip + remnant) during remnant fills. + /// + private class AccumulatingProgress : IProgress + { + private readonly IProgress inner; + private readonly List previousParts; + + public AccumulatingProgress(IProgress inner, List previousParts) + { + this.inner = inner; + this.previousParts = previousParts; + } + + public void Report(NestProgress value) + { + if (value.BestParts != null && previousParts.Count > 0) + { + var combined = new List(previousParts.Count + value.BestParts.Count); + combined.AddRange(previousParts); + combined.AddRange(value.BestParts); + value.BestParts = combined; + value.BestPartCount = combined.Count; + } + + inner.Report(value); + } + } } } From 5c79fbe73d9c9b8c5e649588f380ee91e51425a7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 22:36:54 -0400 Subject: [PATCH 089/116] feat(ui): add Auto Nest button to toolstrip next to engine selector Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Forms/MainForm.Designer.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index 8e608af..4a030de 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -148,6 +148,7 @@ this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator(); this.engineLabel = new System.Windows.Forms.ToolStripLabel(); this.engineComboBox = new System.Windows.Forms.ToolStripComboBox(); + this.btnAutoNest = new System.Windows.Forms.ToolStripButton(); this.pEPToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.openNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.menuStrip1.SuspendLayout(); @@ -1002,7 +1003,8 @@ this.btnZoomToFit, this.toolStripSeparator4, this.engineLabel, - this.engineComboBox}); + this.engineComboBox, + this.btnAutoNest}); this.toolStrip1.Location = new System.Drawing.Point(0, 24); this.toolStrip1.Name = "toolStrip1"; this.toolStrip1.Size = new System.Drawing.Size(1098, 35); @@ -1173,6 +1175,14 @@ this.engineComboBox.Name = "engineComboBox"; this.engineComboBox.Size = new System.Drawing.Size(100, 35); // + // btnAutoNest + // + this.btnAutoNest.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnAutoNest.Name = "btnAutoNest"; + this.btnAutoNest.Size = new System.Drawing.Size(64, 32); + this.btnAutoNest.Text = "Auto Nest"; + this.btnAutoNest.Click += new System.EventHandler(this.RunAutoNest_Click); + // // pEPToolStripMenuItem // this.pEPToolStripMenuItem.Name = "pEPToolStripMenuItem"; @@ -1335,5 +1345,6 @@ private System.Windows.Forms.ToolStripSeparator toolStripSeparator4; private System.Windows.Forms.ToolStripLabel engineLabel; private System.Windows.Forms.ToolStripComboBox engineComboBox; + private System.Windows.Forms.ToolStripButton btnAutoNest; } } \ No newline at end of file From 5e346270c6dafe240d7a3ff471e1050755e43b73 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 22:43:42 -0400 Subject: [PATCH 090/116] fix: delegate Fill(groupParts) and PackArea to DefaultNestEngine StripNestEngine only overrode Fill(NestItem), so ActionClone.Fill and Pack operations fell through to the empty base class defaults. Now all virtual methods delegate to DefaultNestEngine. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/StripNestEngine.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs index 45d6384..8e08bcc 100644 --- a/OpenNest.Engine/StripNestEngine.cs +++ b/OpenNest.Engine/StripNestEngine.cs @@ -30,6 +30,26 @@ namespace OpenNest return inner.Fill(item, workArea, progress, token); } + /// + /// Group-parts fill delegates to DefaultNestEngine. + /// + public override List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(groupParts, workArea, progress, token); + } + + /// + /// Pack delegates to DefaultNestEngine. + /// + public override List PackArea(Box box, List items, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.PackArea(box, items, progress, token); + } + /// /// Selects the item that consumes the most plate area (bounding box area x quantity). /// Returns the index into the items list. From 5cd2875b35e437988599b33694832cee89c16d29 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 23:06:04 -0400 Subject: [PATCH 091/116] chore(ui): regenerate MainForm designer file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual Studio re-serialized the designer — removes `this.` prefixes, modernizes event handler syntax, trims trailing whitespace in resx. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Forms/MainForm.Designer.cs | 1558 +++++++++++++-------------- OpenNest/Forms/MainForm.resx | 54 +- 2 files changed, 749 insertions(+), 863 deletions(-) diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index 4a030de..6aad83f 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -28,1196 +28,1082 @@ /// private void InitializeComponent() { - this.menuStrip1 = new System.Windows.Forms.MenuStrip(); - this.mnuFile = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuFileNew = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuFileOpen = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem1 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuFileSave = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuFileSaveAs = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem2 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuFileExport = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuFileExportAll = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem3 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuFileExit = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuEdit = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuEditCut = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuEditCopy = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuEditPaste = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem4 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuEditSelectAll = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuView = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewDrawOffset = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem5 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuViewZoomTo = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewZoomToArea = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewZoomToFit = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewZoomToPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewZoomToSelected = new System.Windows.Forms.ToolStripMenuItem(); - 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(); - this.mnuToolsAlignRight = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsAlignTop = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsAlignBottom = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem11 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuToolsAlignHorizontal = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsAlignVertically = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem8 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuToolsEvenlySpaceHorizontal = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsEvenlySpaceVertical = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem14 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuSetOffsetIncrement = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuSetRotationIncrement = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem15 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuToolsOptions = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNest = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestEdit = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestImportDrawing = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem7 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuNestFirstPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestLastPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem6 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuNestNextPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestPreviousPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem12 = new System.Windows.Forms.ToolStripSeparator(); - this.runAutoNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.autoSequenceAllPlatesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestRemoveEmptyPlates = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestPost = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem19 = new System.Windows.Forms.ToolStripSeparator(); - this.calculateCutTimeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateEdit = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateSetAsDefault = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem18 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuPlateAdd = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateRemove = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem16 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuPlateFill = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem9 = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateRotate = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateRotateCw = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateRotateCcw = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuPlateRotate180 = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuResizeToFitParts = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem13 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuPlateViewInCad = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem20 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuSequenceParts = new System.Windows.Forms.ToolStripMenuItem(); - this.autoSequenceCurrentPlateToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.manualSequenceToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.calculateCutTimeToolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem(); - this.centerPartsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuWindow = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuWindowCascade = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuWindowTileVertical = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuWindowTileHorizontal = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem10 = new System.Windows.Forms.ToolStripSeparator(); - this.closeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuCloseAll = new System.Windows.Forms.ToolStripMenuItem(); - this.statusStrip1 = new System.Windows.Forms.StatusStrip(); - this.statusLabel1 = new System.Windows.Forms.ToolStripStatusLabel(); - this.locationStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); - this.spacerLabel = new System.Windows.Forms.ToolStripStatusLabel(); - 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(); - this.btnSave = new System.Windows.Forms.ToolStripButton(); - this.btnSaveAs = new System.Windows.Forms.ToolStripButton(); - this.toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator(); - this.btnFirstPlate = new System.Windows.Forms.ToolStripButton(); - this.btnPreviousPlate = new System.Windows.Forms.ToolStripButton(); - this.btnNextPlate = new System.Windows.Forms.ToolStripButton(); - this.btnLastPlate = new System.Windows.Forms.ToolStripButton(); - this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); - this.btnZoomOut = new System.Windows.Forms.ToolStripButton(); - this.btnZoomIn = new System.Windows.Forms.ToolStripButton(); - this.btnZoomToFit = new System.Windows.Forms.ToolStripButton(); - this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator(); - this.engineLabel = new System.Windows.Forms.ToolStripLabel(); - this.engineComboBox = new System.Windows.Forms.ToolStripComboBox(); - this.btnAutoNest = new System.Windows.Forms.ToolStripButton(); - this.pEPToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.openNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.menuStrip1.SuspendLayout(); - this.statusStrip1.SuspendLayout(); - this.toolStrip1.SuspendLayout(); - this.SuspendLayout(); + menuStrip1 = new System.Windows.Forms.MenuStrip(); + mnuFile = new System.Windows.Forms.ToolStripMenuItem(); + mnuFileNew = new System.Windows.Forms.ToolStripMenuItem(); + mnuFileOpen = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem1 = new System.Windows.Forms.ToolStripSeparator(); + mnuFileSave = new System.Windows.Forms.ToolStripMenuItem(); + mnuFileSaveAs = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem2 = new System.Windows.Forms.ToolStripSeparator(); + mnuFileExport = new System.Windows.Forms.ToolStripMenuItem(); + mnuFileExportAll = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem3 = new System.Windows.Forms.ToolStripSeparator(); + mnuFileExit = new System.Windows.Forms.ToolStripMenuItem(); + mnuEdit = new System.Windows.Forms.ToolStripMenuItem(); + mnuEditCut = new System.Windows.Forms.ToolStripMenuItem(); + mnuEditCopy = new System.Windows.Forms.ToolStripMenuItem(); + mnuEditPaste = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem4 = new System.Windows.Forms.ToolStripSeparator(); + mnuEditSelectAll = new System.Windows.Forms.ToolStripMenuItem(); + mnuView = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewDrawOffset = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem5 = new System.Windows.Forms.ToolStripSeparator(); + mnuViewZoomTo = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomToArea = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomToFit = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomToPlate = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomToSelected = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomIn = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomOut = new System.Windows.Forms.ToolStripMenuItem(); + mnuTools = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsMeasureArea = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlign = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlignLeft = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlignRight = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlignTop = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlignBottom = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem11 = new System.Windows.Forms.ToolStripSeparator(); + mnuToolsAlignHorizontal = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlignVertically = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem8 = new System.Windows.Forms.ToolStripSeparator(); + mnuToolsEvenlySpaceHorizontal = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsEvenlySpaceVertical = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem14 = new System.Windows.Forms.ToolStripSeparator(); + mnuSetOffsetIncrement = new System.Windows.Forms.ToolStripMenuItem(); + mnuSetRotationIncrement = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem15 = new System.Windows.Forms.ToolStripSeparator(); + mnuToolsOptions = new System.Windows.Forms.ToolStripMenuItem(); + mnuNest = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestEdit = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestImportDrawing = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem7 = new System.Windows.Forms.ToolStripSeparator(); + mnuNestFirstPlate = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestLastPlate = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem6 = new System.Windows.Forms.ToolStripSeparator(); + mnuNestNextPlate = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestPreviousPlate = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem12 = new System.Windows.Forms.ToolStripSeparator(); + runAutoNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + autoSequenceAllPlatesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestRemoveEmptyPlates = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestPost = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem19 = new System.Windows.Forms.ToolStripSeparator(); + calculateCutTimeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlate = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateEdit = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateSetAsDefault = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem18 = new System.Windows.Forms.ToolStripSeparator(); + mnuPlateAdd = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateRemove = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem16 = new System.Windows.Forms.ToolStripSeparator(); + mnuPlateFill = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem9 = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateRotate = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateRotateCw = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateRotateCcw = new System.Windows.Forms.ToolStripMenuItem(); + toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); + mnuPlateRotate180 = new System.Windows.Forms.ToolStripMenuItem(); + mnuResizeToFitParts = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem13 = new System.Windows.Forms.ToolStripSeparator(); + mnuPlateViewInCad = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem20 = new System.Windows.Forms.ToolStripSeparator(); + mnuSequenceParts = new System.Windows.Forms.ToolStripMenuItem(); + autoSequenceCurrentPlateToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + manualSequenceToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + calculateCutTimeToolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem(); + centerPartsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + mnuWindow = new System.Windows.Forms.ToolStripMenuItem(); + mnuWindowCascade = new System.Windows.Forms.ToolStripMenuItem(); + mnuWindowTileVertical = new System.Windows.Forms.ToolStripMenuItem(); + mnuWindowTileHorizontal = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem10 = new System.Windows.Forms.ToolStripSeparator(); + closeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + mnuCloseAll = new System.Windows.Forms.ToolStripMenuItem(); + statusStrip1 = new System.Windows.Forms.StatusStrip(); + statusLabel1 = new System.Windows.Forms.ToolStripStatusLabel(); + locationStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + spacerLabel = new System.Windows.Forms.ToolStripStatusLabel(); + plateIndexStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + plateSizeStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + plateQtyStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + gpuStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + toolStrip1 = new System.Windows.Forms.ToolStrip(); + btnNew = new System.Windows.Forms.ToolStripButton(); + btnOpen = new System.Windows.Forms.ToolStripButton(); + btnSave = new System.Windows.Forms.ToolStripButton(); + btnSaveAs = new System.Windows.Forms.ToolStripButton(); + toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator(); + btnFirstPlate = new System.Windows.Forms.ToolStripButton(); + btnPreviousPlate = new System.Windows.Forms.ToolStripButton(); + btnNextPlate = new System.Windows.Forms.ToolStripButton(); + btnLastPlate = new System.Windows.Forms.ToolStripButton(); + toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); + btnZoomOut = new System.Windows.Forms.ToolStripButton(); + btnZoomIn = new System.Windows.Forms.ToolStripButton(); + btnZoomToFit = new System.Windows.Forms.ToolStripButton(); + toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator(); + engineLabel = new System.Windows.Forms.ToolStripLabel(); + engineComboBox = new System.Windows.Forms.ToolStripComboBox(); + btnAutoNest = new System.Windows.Forms.ToolStripButton(); + pEPToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + openNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + menuStrip1.SuspendLayout(); + statusStrip1.SuspendLayout(); + toolStrip1.SuspendLayout(); + SuspendLayout(); // // menuStrip1 // - this.menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuFile, - this.mnuEdit, - this.mnuView, - this.mnuTools, - this.mnuNest, - this.mnuPlate, - this.mnuWindow}); - this.menuStrip1.Location = new System.Drawing.Point(0, 0); - this.menuStrip1.Name = "menuStrip1"; - this.menuStrip1.Size = new System.Drawing.Size(1098, 24); - this.menuStrip1.TabIndex = 7; - this.menuStrip1.Text = "menuStrip1"; + menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFile, mnuEdit, mnuView, mnuTools, mnuNest, mnuPlate, mnuWindow }); + menuStrip1.Location = new System.Drawing.Point(0, 0); + menuStrip1.Name = "menuStrip1"; + menuStrip1.Padding = new System.Windows.Forms.Padding(7, 2, 0, 2); + menuStrip1.Size = new System.Drawing.Size(1281, 24); + menuStrip1.TabIndex = 7; + menuStrip1.Text = "menuStrip1"; // // mnuFile // - this.mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuFileNew, - this.mnuFileOpen, - this.toolStripMenuItem1, - this.mnuFileSave, - this.mnuFileSaveAs, - this.toolStripMenuItem2, - this.mnuFileExport, - this.mnuFileExportAll, - this.toolStripMenuItem3, - this.mnuFileExit}); - this.mnuFile.Name = "mnuFile"; - this.mnuFile.Size = new System.Drawing.Size(37, 20); - this.mnuFile.Text = "&File"; + mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, toolStripMenuItem3, mnuFileExit }); + mnuFile.Name = "mnuFile"; + mnuFile.Size = new System.Drawing.Size(37, 20); + mnuFile.Text = "&File"; // // mnuFileNew // - this.mnuFileNew.Image = global::OpenNest.Properties.Resources.doc_new; - this.mnuFileNew.Name = "mnuFileNew"; - this.mnuFileNew.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.N))); - this.mnuFileNew.Size = new System.Drawing.Size(146, 22); - this.mnuFileNew.Text = "New"; - this.mnuFileNew.Click += new System.EventHandler(this.New_Click); + mnuFileNew.Image = Properties.Resources.doc_new; + mnuFileNew.Name = "mnuFileNew"; + mnuFileNew.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.N; + mnuFileNew.Size = new System.Drawing.Size(146, 22); + mnuFileNew.Text = "New"; + mnuFileNew.Click += New_Click; // // mnuFileOpen // - this.mnuFileOpen.Image = global::OpenNest.Properties.Resources.doc_open; - this.mnuFileOpen.Name = "mnuFileOpen"; - this.mnuFileOpen.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.O))); - this.mnuFileOpen.Size = new System.Drawing.Size(146, 22); - this.mnuFileOpen.Text = "Open"; - this.mnuFileOpen.Click += new System.EventHandler(this.Open_Click); + mnuFileOpen.Image = Properties.Resources.doc_open; + mnuFileOpen.Name = "mnuFileOpen"; + mnuFileOpen.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.O; + mnuFileOpen.Size = new System.Drawing.Size(146, 22); + mnuFileOpen.Text = "Open"; + mnuFileOpen.Click += Open_Click; // // toolStripMenuItem1 // - this.toolStripMenuItem1.Name = "toolStripMenuItem1"; - this.toolStripMenuItem1.Size = new System.Drawing.Size(143, 6); + toolStripMenuItem1.Name = "toolStripMenuItem1"; + toolStripMenuItem1.Size = new System.Drawing.Size(143, 6); // // mnuFileSave // - this.mnuFileSave.Enabled = false; - this.mnuFileSave.Image = global::OpenNest.Properties.Resources.save; - this.mnuFileSave.Name = "mnuFileSave"; - this.mnuFileSave.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.S))); - this.mnuFileSave.Size = new System.Drawing.Size(146, 22); - this.mnuFileSave.Text = "Save"; - this.mnuFileSave.Click += new System.EventHandler(this.Save_Click); + mnuFileSave.Enabled = false; + mnuFileSave.Image = Properties.Resources.save; + mnuFileSave.Name = "mnuFileSave"; + mnuFileSave.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.S; + mnuFileSave.Size = new System.Drawing.Size(146, 22); + mnuFileSave.Text = "Save"; + mnuFileSave.Click += Save_Click; // // mnuFileSaveAs // - this.mnuFileSaveAs.Enabled = false; - this.mnuFileSaveAs.Image = global::OpenNest.Properties.Resources.save_as; - this.mnuFileSaveAs.Name = "mnuFileSaveAs"; - this.mnuFileSaveAs.Size = new System.Drawing.Size(146, 22); - this.mnuFileSaveAs.Text = "Save As"; - this.mnuFileSaveAs.Click += new System.EventHandler(this.SaveAs_Click); + mnuFileSaveAs.Enabled = false; + mnuFileSaveAs.Image = Properties.Resources.save_as; + mnuFileSaveAs.Name = "mnuFileSaveAs"; + mnuFileSaveAs.Size = new System.Drawing.Size(146, 22); + mnuFileSaveAs.Text = "Save As"; + mnuFileSaveAs.Click += SaveAs_Click; // // toolStripMenuItem2 // - this.toolStripMenuItem2.Name = "toolStripMenuItem2"; - this.toolStripMenuItem2.Size = new System.Drawing.Size(143, 6); + toolStripMenuItem2.Name = "toolStripMenuItem2"; + toolStripMenuItem2.Size = new System.Drawing.Size(143, 6); // // mnuFileExport // - this.mnuFileExport.Name = "mnuFileExport"; - this.mnuFileExport.Size = new System.Drawing.Size(146, 22); - this.mnuFileExport.Text = "Export"; - this.mnuFileExport.Click += new System.EventHandler(this.Export_Click); + mnuFileExport.Name = "mnuFileExport"; + mnuFileExport.Size = new System.Drawing.Size(146, 22); + mnuFileExport.Text = "Export"; + mnuFileExport.Click += Export_Click; // // mnuFileExportAll // - this.mnuFileExportAll.Name = "mnuFileExportAll"; - this.mnuFileExportAll.Size = new System.Drawing.Size(146, 22); - this.mnuFileExportAll.Text = "Export All"; - this.mnuFileExportAll.Click += new System.EventHandler(this.ExportAll_Click); + mnuFileExportAll.Name = "mnuFileExportAll"; + mnuFileExportAll.Size = new System.Drawing.Size(146, 22); + mnuFileExportAll.Text = "Export All"; + mnuFileExportAll.Click += ExportAll_Click; // // toolStripMenuItem3 // - this.toolStripMenuItem3.Name = "toolStripMenuItem3"; - this.toolStripMenuItem3.Size = new System.Drawing.Size(143, 6); + toolStripMenuItem3.Name = "toolStripMenuItem3"; + toolStripMenuItem3.Size = new System.Drawing.Size(143, 6); // // mnuFileExit // - this.mnuFileExit.Name = "mnuFileExit"; - this.mnuFileExit.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Q))); - this.mnuFileExit.Size = new System.Drawing.Size(146, 22); - this.mnuFileExit.Text = "Exit"; - this.mnuFileExit.Click += new System.EventHandler(this.Exit_Click); + mnuFileExit.Name = "mnuFileExit"; + mnuFileExit.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Q; + mnuFileExit.Size = new System.Drawing.Size(146, 22); + mnuFileExit.Text = "Exit"; + mnuFileExit.Click += Exit_Click; // // mnuEdit // - this.mnuEdit.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuEditCut, - this.mnuEditCopy, - this.mnuEditPaste, - this.toolStripMenuItem4, - this.mnuEditSelectAll}); - this.mnuEdit.Name = "mnuEdit"; - this.mnuEdit.Size = new System.Drawing.Size(39, 20); - this.mnuEdit.Text = "&Edit"; + mnuEdit.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuEditCut, mnuEditCopy, mnuEditPaste, toolStripMenuItem4, mnuEditSelectAll }); + mnuEdit.Name = "mnuEdit"; + mnuEdit.Size = new System.Drawing.Size(39, 20); + mnuEdit.Text = "&Edit"; // // mnuEditCut // - this.mnuEditCut.Name = "mnuEditCut"; - this.mnuEditCut.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.X))); - this.mnuEditCut.Size = new System.Drawing.Size(164, 22); - this.mnuEditCut.Text = "Cut"; - this.mnuEditCut.Click += new System.EventHandler(this.EditCut_Click); + mnuEditCut.Name = "mnuEditCut"; + mnuEditCut.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.X; + mnuEditCut.Size = new System.Drawing.Size(164, 22); + mnuEditCut.Text = "Cut"; + mnuEditCut.Click += EditCut_Click; // // mnuEditCopy // - this.mnuEditCopy.Name = "mnuEditCopy"; - this.mnuEditCopy.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.C))); - this.mnuEditCopy.Size = new System.Drawing.Size(164, 22); - this.mnuEditCopy.Text = "Copy"; - this.mnuEditCopy.Click += new System.EventHandler(this.EditCopy_Click); + mnuEditCopy.Name = "mnuEditCopy"; + mnuEditCopy.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.C; + mnuEditCopy.Size = new System.Drawing.Size(164, 22); + mnuEditCopy.Text = "Copy"; + mnuEditCopy.Click += EditCopy_Click; // // mnuEditPaste // - this.mnuEditPaste.Enabled = false; - this.mnuEditPaste.Name = "mnuEditPaste"; - this.mnuEditPaste.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.V))); - this.mnuEditPaste.Size = new System.Drawing.Size(164, 22); - this.mnuEditPaste.Text = "Paste"; - this.mnuEditPaste.Visible = false; - this.mnuEditPaste.Click += new System.EventHandler(this.EditPaste_Click); + mnuEditPaste.Enabled = false; + mnuEditPaste.Name = "mnuEditPaste"; + mnuEditPaste.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.V; + mnuEditPaste.Size = new System.Drawing.Size(164, 22); + mnuEditPaste.Text = "Paste"; + mnuEditPaste.Visible = false; + mnuEditPaste.Click += EditPaste_Click; // // toolStripMenuItem4 // - this.toolStripMenuItem4.Name = "toolStripMenuItem4"; - this.toolStripMenuItem4.Size = new System.Drawing.Size(161, 6); + toolStripMenuItem4.Name = "toolStripMenuItem4"; + toolStripMenuItem4.Size = new System.Drawing.Size(161, 6); // // mnuEditSelectAll // - this.mnuEditSelectAll.Name = "mnuEditSelectAll"; - this.mnuEditSelectAll.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.A))); - this.mnuEditSelectAll.Size = new System.Drawing.Size(164, 22); - this.mnuEditSelectAll.Text = "Select All"; - this.mnuEditSelectAll.Click += new System.EventHandler(this.EditSelectAll_Click); + mnuEditSelectAll.Name = "mnuEditSelectAll"; + mnuEditSelectAll.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.A; + mnuEditSelectAll.Size = new System.Drawing.Size(164, 22); + mnuEditSelectAll.Text = "Select All"; + mnuEditSelectAll.Click += EditSelectAll_Click; // // mnuView // - this.mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuViewDrawRapids, - this.mnuViewDrawBounds, - this.mnuViewDrawOffset, - this.toolStripMenuItem5, - this.mnuViewZoomTo, - this.mnuViewZoomIn, - this.mnuViewZoomOut}); - this.mnuView.Name = "mnuView"; - this.mnuView.Size = new System.Drawing.Size(44, 20); - this.mnuView.Text = "&View"; + mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewDrawRapids, mnuViewDrawBounds, mnuViewDrawOffset, toolStripMenuItem5, mnuViewZoomTo, mnuViewZoomIn, mnuViewZoomOut }); + mnuView.Name = "mnuView"; + mnuView.Size = new System.Drawing.Size(44, 20); + mnuView.Text = "&View"; // // mnuViewDrawRapids // - this.mnuViewDrawRapids.Name = "mnuViewDrawRapids"; - this.mnuViewDrawRapids.Size = new System.Drawing.Size(222, 22); - this.mnuViewDrawRapids.Text = "Draw Rapids"; - this.mnuViewDrawRapids.Click += new System.EventHandler(this.ToggleDrawRapids_Click); + mnuViewDrawRapids.Name = "mnuViewDrawRapids"; + mnuViewDrawRapids.Size = new System.Drawing.Size(222, 22); + mnuViewDrawRapids.Text = "Draw Rapids"; + mnuViewDrawRapids.Click += ToggleDrawRapids_Click; // // mnuViewDrawBounds // - this.mnuViewDrawBounds.CheckOnClick = true; - this.mnuViewDrawBounds.Name = "mnuViewDrawBounds"; - this.mnuViewDrawBounds.Size = new System.Drawing.Size(222, 22); - this.mnuViewDrawBounds.Text = "Draw Bounds"; - this.mnuViewDrawBounds.Click += new System.EventHandler(this.ToggleDrawBounds_Click); - // + mnuViewDrawBounds.CheckOnClick = true; + mnuViewDrawBounds.Name = "mnuViewDrawBounds"; + mnuViewDrawBounds.Size = new System.Drawing.Size(222, 22); + mnuViewDrawBounds.Text = "Draw Bounds"; + mnuViewDrawBounds.Click += ToggleDrawBounds_Click; + // // mnuViewDrawOffset - // - this.mnuViewDrawOffset.CheckOnClick = true; - this.mnuViewDrawOffset.Name = "mnuViewDrawOffset"; - this.mnuViewDrawOffset.Size = new System.Drawing.Size(222, 22); - this.mnuViewDrawOffset.Text = "Draw Offset"; - this.mnuViewDrawOffset.Click += new System.EventHandler(this.ToggleDrawOffset_Click); - // + // + mnuViewDrawOffset.CheckOnClick = true; + mnuViewDrawOffset.Name = "mnuViewDrawOffset"; + mnuViewDrawOffset.Size = new System.Drawing.Size(222, 22); + mnuViewDrawOffset.Text = "Draw Offset"; + mnuViewDrawOffset.Click += ToggleDrawOffset_Click; + // // toolStripMenuItem5 // - this.toolStripMenuItem5.Name = "toolStripMenuItem5"; - this.toolStripMenuItem5.Size = new System.Drawing.Size(219, 6); + toolStripMenuItem5.Name = "toolStripMenuItem5"; + toolStripMenuItem5.Size = new System.Drawing.Size(219, 6); // // mnuViewZoomTo // - this.mnuViewZoomTo.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuViewZoomToArea, - this.mnuViewZoomToFit, - this.mnuViewZoomToPlate, - this.mnuViewZoomToSelected}); - this.mnuViewZoomTo.Name = "mnuViewZoomTo"; - this.mnuViewZoomTo.Size = new System.Drawing.Size(222, 22); - this.mnuViewZoomTo.Text = "Zoom To"; + mnuViewZoomTo.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewZoomToArea, mnuViewZoomToFit, mnuViewZoomToPlate, mnuViewZoomToSelected }); + mnuViewZoomTo.Name = "mnuViewZoomTo"; + mnuViewZoomTo.Size = new System.Drawing.Size(222, 22); + mnuViewZoomTo.Text = "Zoom To"; // // mnuViewZoomToArea // - this.mnuViewZoomToArea.Name = "mnuViewZoomToArea"; - this.mnuViewZoomToArea.Size = new System.Drawing.Size(118, 22); - this.mnuViewZoomToArea.Text = "Area"; - this.mnuViewZoomToArea.Click += new System.EventHandler(this.ZoomToArea_Click); + mnuViewZoomToArea.Name = "mnuViewZoomToArea"; + mnuViewZoomToArea.Size = new System.Drawing.Size(118, 22); + mnuViewZoomToArea.Text = "Area"; + mnuViewZoomToArea.Click += ZoomToArea_Click; // // mnuViewZoomToFit // - this.mnuViewZoomToFit.Image = global::OpenNest.Properties.Resources.zoom_all; - this.mnuViewZoomToFit.Name = "mnuViewZoomToFit"; - this.mnuViewZoomToFit.Size = new System.Drawing.Size(118, 22); - this.mnuViewZoomToFit.Text = "Fit"; - this.mnuViewZoomToFit.Click += new System.EventHandler(this.ZoomToFit_Click); + mnuViewZoomToFit.Image = Properties.Resources.zoom_all; + mnuViewZoomToFit.Name = "mnuViewZoomToFit"; + mnuViewZoomToFit.Size = new System.Drawing.Size(118, 22); + mnuViewZoomToFit.Text = "Fit"; + mnuViewZoomToFit.Click += ZoomToFit_Click; // // mnuViewZoomToPlate // - this.mnuViewZoomToPlate.Name = "mnuViewZoomToPlate"; - this.mnuViewZoomToPlate.Size = new System.Drawing.Size(118, 22); - this.mnuViewZoomToPlate.Text = "Plate"; - this.mnuViewZoomToPlate.Click += new System.EventHandler(this.ZoomToPlate_Click); + mnuViewZoomToPlate.Name = "mnuViewZoomToPlate"; + mnuViewZoomToPlate.Size = new System.Drawing.Size(118, 22); + mnuViewZoomToPlate.Text = "Plate"; + mnuViewZoomToPlate.Click += ZoomToPlate_Click; // // mnuViewZoomToSelected // - this.mnuViewZoomToSelected.Name = "mnuViewZoomToSelected"; - this.mnuViewZoomToSelected.Size = new System.Drawing.Size(118, 22); - this.mnuViewZoomToSelected.Text = "Selected"; - this.mnuViewZoomToSelected.Click += new System.EventHandler(this.ZoomToSelected_Click); + mnuViewZoomToSelected.Name = "mnuViewZoomToSelected"; + mnuViewZoomToSelected.Size = new System.Drawing.Size(118, 22); + mnuViewZoomToSelected.Text = "Selected"; + mnuViewZoomToSelected.Click += ZoomToSelected_Click; // // mnuViewZoomIn // - this.mnuViewZoomIn.Image = global::OpenNest.Properties.Resources.zoom_in; - this.mnuViewZoomIn.Name = "mnuViewZoomIn"; - this.mnuViewZoomIn.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Oemplus))); - this.mnuViewZoomIn.Size = new System.Drawing.Size(222, 22); - this.mnuViewZoomIn.Text = "Zoom In"; - this.mnuViewZoomIn.Click += new System.EventHandler(this.ZoomIn_Click); + mnuViewZoomIn.Image = Properties.Resources.zoom_in; + mnuViewZoomIn.Name = "mnuViewZoomIn"; + mnuViewZoomIn.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Oemplus; + mnuViewZoomIn.Size = new System.Drawing.Size(222, 22); + mnuViewZoomIn.Text = "Zoom In"; + mnuViewZoomIn.Click += ZoomIn_Click; // // mnuViewZoomOut // - this.mnuViewZoomOut.Image = global::OpenNest.Properties.Resources.zoom_out; - this.mnuViewZoomOut.Name = "mnuViewZoomOut"; - this.mnuViewZoomOut.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.OemMinus))); - this.mnuViewZoomOut.Size = new System.Drawing.Size(222, 22); - this.mnuViewZoomOut.Text = "Zoom Out"; - this.mnuViewZoomOut.Click += new System.EventHandler(this.ZoomOut_Click); + mnuViewZoomOut.Image = Properties.Resources.zoom_out; + mnuViewZoomOut.Name = "mnuViewZoomOut"; + mnuViewZoomOut.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.OemMinus; + mnuViewZoomOut.Size = new System.Drawing.Size(222, 22); + mnuViewZoomOut.Text = "Zoom Out"; + mnuViewZoomOut.Click += ZoomOut_Click; // // mnuTools // - this.mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuToolsMeasureArea, - this.mnuToolsBestFitViewer, - this.mnuToolsAlign, - this.toolStripMenuItem14, - this.mnuSetOffsetIncrement, - this.mnuSetRotationIncrement, - this.toolStripMenuItem15, - this.mnuToolsOptions}); - this.mnuTools.Name = "mnuTools"; - this.mnuTools.Size = new System.Drawing.Size(47, 20); - this.mnuTools.Text = "&Tools"; + mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsAlign, toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement, toolStripMenuItem15, mnuToolsOptions }); + mnuTools.Name = "mnuTools"; + mnuTools.Size = new System.Drawing.Size(47, 20); + mnuTools.Text = "&Tools"; // // mnuToolsMeasureArea // - this.mnuToolsMeasureArea.Name = "mnuToolsMeasureArea"; - this.mnuToolsMeasureArea.Size = new System.Drawing.Size(214, 22); - this.mnuToolsMeasureArea.Text = "Measure Area"; - this.mnuToolsMeasureArea.Click += new System.EventHandler(this.MeasureArea_Click); - // + mnuToolsMeasureArea.Name = "mnuToolsMeasureArea"; + mnuToolsMeasureArea.Size = new System.Drawing.Size(214, 22); + mnuToolsMeasureArea.Text = "Measure Area"; + mnuToolsMeasureArea.Click += 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); - // + // + mnuToolsBestFitViewer.Name = "mnuToolsBestFitViewer"; + mnuToolsBestFitViewer.Size = new System.Drawing.Size(214, 22); + mnuToolsBestFitViewer.Text = "Best-Fit Viewer"; + mnuToolsBestFitViewer.Click += BestFitViewer_Click; + // // mnuToolsAlign // - this.mnuToolsAlign.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuToolsAlignLeft, - this.mnuToolsAlignRight, - this.mnuToolsAlignTop, - this.mnuToolsAlignBottom, - this.toolStripMenuItem11, - this.mnuToolsAlignHorizontal, - this.mnuToolsAlignVertically, - this.toolStripMenuItem8, - this.mnuToolsEvenlySpaceHorizontal, - this.mnuToolsEvenlySpaceVertical}); - this.mnuToolsAlign.Name = "mnuToolsAlign"; - this.mnuToolsAlign.Size = new System.Drawing.Size(214, 22); - this.mnuToolsAlign.Text = "Align Selected"; + mnuToolsAlign.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsAlignLeft, mnuToolsAlignRight, mnuToolsAlignTop, mnuToolsAlignBottom, toolStripMenuItem11, mnuToolsAlignHorizontal, mnuToolsAlignVertically, toolStripMenuItem8, mnuToolsEvenlySpaceHorizontal, mnuToolsEvenlySpaceVertical }); + mnuToolsAlign.Name = "mnuToolsAlign"; + mnuToolsAlign.Size = new System.Drawing.Size(214, 22); + mnuToolsAlign.Text = "Align Selected"; // // mnuToolsAlignLeft // - this.mnuToolsAlignLeft.Name = "mnuToolsAlignLeft"; - this.mnuToolsAlignLeft.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignLeft.Text = "Left"; - this.mnuToolsAlignLeft.Click += new System.EventHandler(this.AlignLeft_Click); + mnuToolsAlignLeft.Name = "mnuToolsAlignLeft"; + mnuToolsAlignLeft.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignLeft.Text = "Left"; + mnuToolsAlignLeft.Click += AlignLeft_Click; // // mnuToolsAlignRight // - this.mnuToolsAlignRight.Name = "mnuToolsAlignRight"; - this.mnuToolsAlignRight.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignRight.Text = "Right"; - this.mnuToolsAlignRight.Click += new System.EventHandler(this.AlignRight_Click); + mnuToolsAlignRight.Name = "mnuToolsAlignRight"; + mnuToolsAlignRight.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignRight.Text = "Right"; + mnuToolsAlignRight.Click += AlignRight_Click; // // mnuToolsAlignTop // - this.mnuToolsAlignTop.Name = "mnuToolsAlignTop"; - this.mnuToolsAlignTop.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignTop.Text = "Top"; - this.mnuToolsAlignTop.Click += new System.EventHandler(this.AlignTop_Click); + mnuToolsAlignTop.Name = "mnuToolsAlignTop"; + mnuToolsAlignTop.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignTop.Text = "Top"; + mnuToolsAlignTop.Click += AlignTop_Click; // // mnuToolsAlignBottom // - this.mnuToolsAlignBottom.Name = "mnuToolsAlignBottom"; - this.mnuToolsAlignBottom.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignBottom.Text = "Bottom"; - this.mnuToolsAlignBottom.Click += new System.EventHandler(this.AlignBottom_Click); + mnuToolsAlignBottom.Name = "mnuToolsAlignBottom"; + mnuToolsAlignBottom.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignBottom.Text = "Bottom"; + mnuToolsAlignBottom.Click += AlignBottom_Click; // // toolStripMenuItem11 // - this.toolStripMenuItem11.Name = "toolStripMenuItem11"; - this.toolStripMenuItem11.Size = new System.Drawing.Size(206, 6); + toolStripMenuItem11.Name = "toolStripMenuItem11"; + toolStripMenuItem11.Size = new System.Drawing.Size(206, 6); // // mnuToolsAlignHorizontal // - this.mnuToolsAlignHorizontal.Name = "mnuToolsAlignHorizontal"; - this.mnuToolsAlignHorizontal.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignHorizontal.Text = "Horizontally"; - this.mnuToolsAlignHorizontal.Click += new System.EventHandler(this.AlignHorizontal_Click); + mnuToolsAlignHorizontal.Name = "mnuToolsAlignHorizontal"; + mnuToolsAlignHorizontal.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignHorizontal.Text = "Horizontally"; + mnuToolsAlignHorizontal.Click += AlignHorizontal_Click; // // mnuToolsAlignVertically // - this.mnuToolsAlignVertically.Name = "mnuToolsAlignVertically"; - this.mnuToolsAlignVertically.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignVertically.Text = "Vertically"; - this.mnuToolsAlignVertically.Click += new System.EventHandler(this.AlignVertical_Click); + mnuToolsAlignVertically.Name = "mnuToolsAlignVertically"; + mnuToolsAlignVertically.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignVertically.Text = "Vertically"; + mnuToolsAlignVertically.Click += AlignVertical_Click; // // toolStripMenuItem8 // - this.toolStripMenuItem8.Name = "toolStripMenuItem8"; - this.toolStripMenuItem8.Size = new System.Drawing.Size(206, 6); + toolStripMenuItem8.Name = "toolStripMenuItem8"; + toolStripMenuItem8.Size = new System.Drawing.Size(206, 6); // // mnuToolsEvenlySpaceHorizontal // - this.mnuToolsEvenlySpaceHorizontal.Name = "mnuToolsEvenlySpaceHorizontal"; - this.mnuToolsEvenlySpaceHorizontal.Size = new System.Drawing.Size(209, 22); - this.mnuToolsEvenlySpaceHorizontal.Text = "Evenly Space Horizontally"; - this.mnuToolsEvenlySpaceHorizontal.Click += new System.EventHandler(this.EvenlySpaceHorizontally_Click); + mnuToolsEvenlySpaceHorizontal.Name = "mnuToolsEvenlySpaceHorizontal"; + mnuToolsEvenlySpaceHorizontal.Size = new System.Drawing.Size(209, 22); + mnuToolsEvenlySpaceHorizontal.Text = "Evenly Space Horizontally"; + mnuToolsEvenlySpaceHorizontal.Click += EvenlySpaceHorizontally_Click; // // mnuToolsEvenlySpaceVertical // - this.mnuToolsEvenlySpaceVertical.Name = "mnuToolsEvenlySpaceVertical"; - this.mnuToolsEvenlySpaceVertical.Size = new System.Drawing.Size(209, 22); - this.mnuToolsEvenlySpaceVertical.Text = "Evenly Space Vertically"; - this.mnuToolsEvenlySpaceVertical.Click += new System.EventHandler(this.EvenlySpaceVertically_Click); + mnuToolsEvenlySpaceVertical.Name = "mnuToolsEvenlySpaceVertical"; + mnuToolsEvenlySpaceVertical.Size = new System.Drawing.Size(209, 22); + mnuToolsEvenlySpaceVertical.Text = "Evenly Space Vertically"; + mnuToolsEvenlySpaceVertical.Click += EvenlySpaceVertically_Click; // // toolStripMenuItem14 // - this.toolStripMenuItem14.Name = "toolStripMenuItem14"; - this.toolStripMenuItem14.Size = new System.Drawing.Size(211, 6); + toolStripMenuItem14.Name = "toolStripMenuItem14"; + toolStripMenuItem14.Size = new System.Drawing.Size(211, 6); // // mnuSetOffsetIncrement // - this.mnuSetOffsetIncrement.Name = "mnuSetOffsetIncrement"; - this.mnuSetOffsetIncrement.ShortcutKeys = System.Windows.Forms.Keys.F5; - this.mnuSetOffsetIncrement.Size = new System.Drawing.Size(214, 22); - this.mnuSetOffsetIncrement.Text = "Set Offset Increment"; - this.mnuSetOffsetIncrement.Click += new System.EventHandler(this.SetOffsetIncrement_Click); + mnuSetOffsetIncrement.Name = "mnuSetOffsetIncrement"; + mnuSetOffsetIncrement.ShortcutKeys = System.Windows.Forms.Keys.F5; + mnuSetOffsetIncrement.Size = new System.Drawing.Size(214, 22); + mnuSetOffsetIncrement.Text = "Set Offset Increment"; + mnuSetOffsetIncrement.Click += SetOffsetIncrement_Click; // // mnuSetRotationIncrement // - this.mnuSetRotationIncrement.Name = "mnuSetRotationIncrement"; - this.mnuSetRotationIncrement.ShortcutKeys = System.Windows.Forms.Keys.F6; - this.mnuSetRotationIncrement.Size = new System.Drawing.Size(214, 22); - this.mnuSetRotationIncrement.Text = "Set Rotation Increment"; - this.mnuSetRotationIncrement.Click += new System.EventHandler(this.SetRotationIncrement_Click); + mnuSetRotationIncrement.Name = "mnuSetRotationIncrement"; + mnuSetRotationIncrement.ShortcutKeys = System.Windows.Forms.Keys.F6; + mnuSetRotationIncrement.Size = new System.Drawing.Size(214, 22); + mnuSetRotationIncrement.Text = "Set Rotation Increment"; + mnuSetRotationIncrement.Click += SetRotationIncrement_Click; // // toolStripMenuItem15 // - this.toolStripMenuItem15.Name = "toolStripMenuItem15"; - this.toolStripMenuItem15.Size = new System.Drawing.Size(211, 6); + toolStripMenuItem15.Name = "toolStripMenuItem15"; + toolStripMenuItem15.Size = new System.Drawing.Size(211, 6); // // mnuToolsOptions // - this.mnuToolsOptions.Name = "mnuToolsOptions"; - this.mnuToolsOptions.Size = new System.Drawing.Size(214, 22); - this.mnuToolsOptions.Text = "Options"; - this.mnuToolsOptions.Click += new System.EventHandler(this.Options_Click); + mnuToolsOptions.Name = "mnuToolsOptions"; + mnuToolsOptions.Size = new System.Drawing.Size(214, 22); + mnuToolsOptions.Text = "Options"; + mnuToolsOptions.Click += Options_Click; // // mnuNest // - this.mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuNestEdit, - this.mnuNestImportDrawing, - this.toolStripMenuItem7, - this.mnuNestFirstPlate, - this.mnuNestLastPlate, - this.toolStripMenuItem6, - this.mnuNestNextPlate, - this.mnuNestPreviousPlate, - this.toolStripMenuItem12, - this.runAutoNestToolStripMenuItem, - this.autoSequenceAllPlatesToolStripMenuItem, - this.mnuNestRemoveEmptyPlates, - this.mnuNestPost, - this.toolStripMenuItem19, - this.calculateCutTimeToolStripMenuItem}); - this.mnuNest.Name = "mnuNest"; - this.mnuNest.Size = new System.Drawing.Size(43, 20); - this.mnuNest.Text = "&Nest"; + mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestImportDrawing, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem }); + mnuNest.Name = "mnuNest"; + mnuNest.Size = new System.Drawing.Size(43, 20); + mnuNest.Text = "&Nest"; // // mnuNestEdit // - this.mnuNestEdit.Name = "mnuNestEdit"; - this.mnuNestEdit.Size = new System.Drawing.Size(205, 22); - this.mnuNestEdit.Text = "Edit"; - this.mnuNestEdit.Click += new System.EventHandler(this.EditNest_Click); + mnuNestEdit.Name = "mnuNestEdit"; + mnuNestEdit.Size = new System.Drawing.Size(205, 22); + mnuNestEdit.Text = "Edit"; + mnuNestEdit.Click += EditNest_Click; // // mnuNestImportDrawing // - this.mnuNestImportDrawing.Image = global::OpenNest.Properties.Resources.import; - this.mnuNestImportDrawing.Name = "mnuNestImportDrawing"; - this.mnuNestImportDrawing.Size = new System.Drawing.Size(205, 22); - this.mnuNestImportDrawing.Text = "Import Drawing"; - this.mnuNestImportDrawing.Click += new System.EventHandler(this.Import_Click); + mnuNestImportDrawing.Image = Properties.Resources.import; + mnuNestImportDrawing.Name = "mnuNestImportDrawing"; + mnuNestImportDrawing.Size = new System.Drawing.Size(205, 22); + mnuNestImportDrawing.Text = "Import Drawing"; + mnuNestImportDrawing.Click += Import_Click; // // toolStripMenuItem7 // - this.toolStripMenuItem7.Name = "toolStripMenuItem7"; - this.toolStripMenuItem7.Size = new System.Drawing.Size(202, 6); + toolStripMenuItem7.Name = "toolStripMenuItem7"; + toolStripMenuItem7.Size = new System.Drawing.Size(202, 6); // // mnuNestFirstPlate // - this.mnuNestFirstPlate.Image = global::OpenNest.Properties.Resources.move_first; - this.mnuNestFirstPlate.Name = "mnuNestFirstPlate"; - this.mnuNestFirstPlate.Size = new System.Drawing.Size(205, 22); - this.mnuNestFirstPlate.Text = "First Plate"; - this.mnuNestFirstPlate.Click += new System.EventHandler(this.LoadFirstPlate_Click); + mnuNestFirstPlate.Image = Properties.Resources.move_first; + mnuNestFirstPlate.Name = "mnuNestFirstPlate"; + mnuNestFirstPlate.Size = new System.Drawing.Size(205, 22); + mnuNestFirstPlate.Text = "First Plate"; + mnuNestFirstPlate.Click += LoadFirstPlate_Click; // // mnuNestLastPlate // - this.mnuNestLastPlate.Image = global::OpenNest.Properties.Resources.move_last; - this.mnuNestLastPlate.Name = "mnuNestLastPlate"; - this.mnuNestLastPlate.Size = new System.Drawing.Size(205, 22); - this.mnuNestLastPlate.Text = "Last Plate"; - this.mnuNestLastPlate.Click += new System.EventHandler(this.LoadLastPlate_Click); + mnuNestLastPlate.Image = Properties.Resources.move_last; + mnuNestLastPlate.Name = "mnuNestLastPlate"; + mnuNestLastPlate.Size = new System.Drawing.Size(205, 22); + mnuNestLastPlate.Text = "Last Plate"; + mnuNestLastPlate.Click += LoadLastPlate_Click; // // toolStripMenuItem6 // - this.toolStripMenuItem6.Name = "toolStripMenuItem6"; - this.toolStripMenuItem6.Size = new System.Drawing.Size(202, 6); + toolStripMenuItem6.Name = "toolStripMenuItem6"; + toolStripMenuItem6.Size = new System.Drawing.Size(202, 6); // // mnuNestNextPlate // - this.mnuNestNextPlate.Image = global::OpenNest.Properties.Resources.move_next; - this.mnuNestNextPlate.Name = "mnuNestNextPlate"; - this.mnuNestNextPlate.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Right))); - this.mnuNestNextPlate.Size = new System.Drawing.Size(205, 22); - this.mnuNestNextPlate.Text = "Next Plate"; - this.mnuNestNextPlate.Click += new System.EventHandler(this.LoadNextPlate_Click); + mnuNestNextPlate.Image = Properties.Resources.move_next; + mnuNestNextPlate.Name = "mnuNestNextPlate"; + mnuNestNextPlate.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Right; + mnuNestNextPlate.Size = new System.Drawing.Size(205, 22); + mnuNestNextPlate.Text = "Next Plate"; + mnuNestNextPlate.Click += LoadNextPlate_Click; // // mnuNestPreviousPlate // - this.mnuNestPreviousPlate.Image = global::OpenNest.Properties.Resources.move_previous; - this.mnuNestPreviousPlate.Name = "mnuNestPreviousPlate"; - this.mnuNestPreviousPlate.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Left))); - this.mnuNestPreviousPlate.Size = new System.Drawing.Size(205, 22); - this.mnuNestPreviousPlate.Text = "Previous Plate"; - this.mnuNestPreviousPlate.Click += new System.EventHandler(this.LoadPreviousPlate_Click); + mnuNestPreviousPlate.Image = Properties.Resources.move_previous; + mnuNestPreviousPlate.Name = "mnuNestPreviousPlate"; + mnuNestPreviousPlate.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Left; + mnuNestPreviousPlate.Size = new System.Drawing.Size(205, 22); + mnuNestPreviousPlate.Text = "Previous Plate"; + mnuNestPreviousPlate.Click += LoadPreviousPlate_Click; // // toolStripMenuItem12 // - this.toolStripMenuItem12.Name = "toolStripMenuItem12"; - this.toolStripMenuItem12.Size = new System.Drawing.Size(202, 6); + toolStripMenuItem12.Name = "toolStripMenuItem12"; + toolStripMenuItem12.Size = new System.Drawing.Size(202, 6); // // runAutoNestToolStripMenuItem // - this.runAutoNestToolStripMenuItem.Name = "runAutoNestToolStripMenuItem"; - this.runAutoNestToolStripMenuItem.Size = new System.Drawing.Size(205, 22); - this.runAutoNestToolStripMenuItem.Text = "Auto Nest"; - this.runAutoNestToolStripMenuItem.Click += new System.EventHandler(this.RunAutoNest_Click); + runAutoNestToolStripMenuItem.Name = "runAutoNestToolStripMenuItem"; + runAutoNestToolStripMenuItem.Size = new System.Drawing.Size(205, 22); + runAutoNestToolStripMenuItem.Text = "Auto Nest"; + runAutoNestToolStripMenuItem.Click += RunAutoNest_Click; // // autoSequenceAllPlatesToolStripMenuItem // - this.autoSequenceAllPlatesToolStripMenuItem.Name = "autoSequenceAllPlatesToolStripMenuItem"; - this.autoSequenceAllPlatesToolStripMenuItem.Size = new System.Drawing.Size(205, 22); - this.autoSequenceAllPlatesToolStripMenuItem.Text = "Auto Sequence All Plates"; - this.autoSequenceAllPlatesToolStripMenuItem.Click += new System.EventHandler(this.SequenceAllPlates_Click); + autoSequenceAllPlatesToolStripMenuItem.Name = "autoSequenceAllPlatesToolStripMenuItem"; + autoSequenceAllPlatesToolStripMenuItem.Size = new System.Drawing.Size(205, 22); + autoSequenceAllPlatesToolStripMenuItem.Text = "Auto Sequence All Plates"; + autoSequenceAllPlatesToolStripMenuItem.Click += SequenceAllPlates_Click; // // mnuNestRemoveEmptyPlates // - this.mnuNestRemoveEmptyPlates.Name = "mnuNestRemoveEmptyPlates"; - this.mnuNestRemoveEmptyPlates.Size = new System.Drawing.Size(205, 22); - this.mnuNestRemoveEmptyPlates.Text = "Remove Empty Plates"; - this.mnuNestRemoveEmptyPlates.Click += new System.EventHandler(this.RemoveEmptyPlates_Click); + mnuNestRemoveEmptyPlates.Name = "mnuNestRemoveEmptyPlates"; + mnuNestRemoveEmptyPlates.Size = new System.Drawing.Size(205, 22); + mnuNestRemoveEmptyPlates.Text = "Remove Empty Plates"; + mnuNestRemoveEmptyPlates.Click += RemoveEmptyPlates_Click; // // mnuNestPost // - this.mnuNestPost.Name = "mnuNestPost"; - this.mnuNestPost.Size = new System.Drawing.Size(205, 22); - this.mnuNestPost.Text = "Post"; + mnuNestPost.Name = "mnuNestPost"; + mnuNestPost.Size = new System.Drawing.Size(205, 22); + mnuNestPost.Text = "Post"; // // toolStripMenuItem19 // - this.toolStripMenuItem19.Name = "toolStripMenuItem19"; - this.toolStripMenuItem19.Size = new System.Drawing.Size(202, 6); + toolStripMenuItem19.Name = "toolStripMenuItem19"; + toolStripMenuItem19.Size = new System.Drawing.Size(202, 6); // // calculateCutTimeToolStripMenuItem // - this.calculateCutTimeToolStripMenuItem.Name = "calculateCutTimeToolStripMenuItem"; - this.calculateCutTimeToolStripMenuItem.Size = new System.Drawing.Size(205, 22); - this.calculateCutTimeToolStripMenuItem.Text = "Calculate Cut Time"; - this.calculateCutTimeToolStripMenuItem.Click += new System.EventHandler(this.CalculateNestCutTime_Click); + calculateCutTimeToolStripMenuItem.Name = "calculateCutTimeToolStripMenuItem"; + calculateCutTimeToolStripMenuItem.Size = new System.Drawing.Size(205, 22); + calculateCutTimeToolStripMenuItem.Text = "Calculate Cut Time"; + calculateCutTimeToolStripMenuItem.Click += CalculateNestCutTime_Click; // // mnuPlate // - this.mnuPlate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuPlateEdit, - this.mnuPlateSetAsDefault, - this.toolStripMenuItem18, - this.mnuPlateAdd, - this.mnuPlateRemove, - this.toolStripMenuItem16, - this.mnuPlateFill, - this.toolStripMenuItem9, - this.mnuPlateRotate, - this.mnuResizeToFitParts, - this.toolStripMenuItem13, - this.mnuPlateViewInCad, - this.toolStripMenuItem20, - this.mnuSequenceParts, - this.calculateCutTimeToolStripMenuItem1, - this.centerPartsToolStripMenuItem}); - this.mnuPlate.Name = "mnuPlate"; - this.mnuPlate.Size = new System.Drawing.Size(45, 20); - this.mnuPlate.Text = "&Plate"; + mnuPlate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuPlateEdit, mnuPlateSetAsDefault, toolStripMenuItem18, mnuPlateAdd, mnuPlateRemove, toolStripMenuItem16, mnuPlateFill, toolStripMenuItem9, mnuPlateRotate, mnuResizeToFitParts, toolStripMenuItem13, mnuPlateViewInCad, toolStripMenuItem20, mnuSequenceParts, calculateCutTimeToolStripMenuItem1, centerPartsToolStripMenuItem }); + mnuPlate.Name = "mnuPlate"; + mnuPlate.Size = new System.Drawing.Size(45, 20); + mnuPlate.Text = "&Plate"; // // mnuPlateEdit // - this.mnuPlateEdit.Name = "mnuPlateEdit"; - this.mnuPlateEdit.Size = new System.Drawing.Size(177, 22); - this.mnuPlateEdit.Text = "Edit"; - this.mnuPlateEdit.Click += new System.EventHandler(this.EditPlate_Click); + mnuPlateEdit.Name = "mnuPlateEdit"; + mnuPlateEdit.Size = new System.Drawing.Size(177, 22); + mnuPlateEdit.Text = "Edit"; + mnuPlateEdit.Click += EditPlate_Click; // // mnuPlateSetAsDefault // - this.mnuPlateSetAsDefault.Name = "mnuPlateSetAsDefault"; - this.mnuPlateSetAsDefault.Size = new System.Drawing.Size(177, 22); - this.mnuPlateSetAsDefault.Text = "Set as Default"; - this.mnuPlateSetAsDefault.Click += new System.EventHandler(this.SetAsNestDefault_Click); + mnuPlateSetAsDefault.Name = "mnuPlateSetAsDefault"; + mnuPlateSetAsDefault.Size = new System.Drawing.Size(177, 22); + mnuPlateSetAsDefault.Text = "Set as Default"; + mnuPlateSetAsDefault.Click += SetAsNestDefault_Click; // // toolStripMenuItem18 // - this.toolStripMenuItem18.Name = "toolStripMenuItem18"; - this.toolStripMenuItem18.Size = new System.Drawing.Size(174, 6); + toolStripMenuItem18.Name = "toolStripMenuItem18"; + toolStripMenuItem18.Size = new System.Drawing.Size(174, 6); // // mnuPlateAdd // - this.mnuPlateAdd.Image = global::OpenNest.Properties.Resources.add; - this.mnuPlateAdd.Name = "mnuPlateAdd"; - this.mnuPlateAdd.Size = new System.Drawing.Size(177, 22); - this.mnuPlateAdd.Text = "Add"; - this.mnuPlateAdd.Click += new System.EventHandler(this.AddPlate_Click); + mnuPlateAdd.Image = Properties.Resources.add; + mnuPlateAdd.Name = "mnuPlateAdd"; + mnuPlateAdd.Size = new System.Drawing.Size(177, 22); + mnuPlateAdd.Text = "Add"; + mnuPlateAdd.Click += AddPlate_Click; // // mnuPlateRemove // - this.mnuPlateRemove.Image = global::OpenNest.Properties.Resources.remove; - this.mnuPlateRemove.Name = "mnuPlateRemove"; - this.mnuPlateRemove.Size = new System.Drawing.Size(177, 22); - this.mnuPlateRemove.Text = "Remove"; - this.mnuPlateRemove.Click += new System.EventHandler(this.RemovePlate_Click); + mnuPlateRemove.Image = Properties.Resources.remove; + mnuPlateRemove.Name = "mnuPlateRemove"; + mnuPlateRemove.Size = new System.Drawing.Size(177, 22); + mnuPlateRemove.Text = "Remove"; + mnuPlateRemove.Click += RemovePlate_Click; // // toolStripMenuItem16 // - this.toolStripMenuItem16.Name = "toolStripMenuItem16"; - this.toolStripMenuItem16.Size = new System.Drawing.Size(174, 6); + toolStripMenuItem16.Name = "toolStripMenuItem16"; + toolStripMenuItem16.Size = new System.Drawing.Size(174, 6); // // mnuPlateFill // - this.mnuPlateFill.Name = "mnuPlateFill"; - this.mnuPlateFill.Size = new System.Drawing.Size(177, 22); - this.mnuPlateFill.Text = "Fill"; - this.mnuPlateFill.Click += new System.EventHandler(this.FillPlate_Click); + mnuPlateFill.Name = "mnuPlateFill"; + mnuPlateFill.Size = new System.Drawing.Size(177, 22); + mnuPlateFill.Text = "Fill"; + mnuPlateFill.Click += FillPlate_Click; // // toolStripMenuItem9 // - this.toolStripMenuItem9.Name = "toolStripMenuItem9"; - this.toolStripMenuItem9.Size = new System.Drawing.Size(177, 22); - this.toolStripMenuItem9.Text = "Fill Area"; - this.toolStripMenuItem9.Click += new System.EventHandler(this.FillArea_Click); + toolStripMenuItem9.Name = "toolStripMenuItem9"; + toolStripMenuItem9.Size = new System.Drawing.Size(177, 22); + toolStripMenuItem9.Text = "Fill Area"; + toolStripMenuItem9.Click += FillArea_Click; // // mnuPlateRotate // - this.mnuPlateRotate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuPlateRotateCw, - this.mnuPlateRotateCcw, - this.toolStripSeparator2, - this.mnuPlateRotate180}); - this.mnuPlateRotate.Name = "mnuPlateRotate"; - this.mnuPlateRotate.Size = new System.Drawing.Size(177, 22); - this.mnuPlateRotate.Text = "Rotate"; + mnuPlateRotate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuPlateRotateCw, mnuPlateRotateCcw, toolStripSeparator2, mnuPlateRotate180 }); + mnuPlateRotate.Name = "mnuPlateRotate"; + mnuPlateRotate.Size = new System.Drawing.Size(177, 22); + mnuPlateRotate.Text = "Rotate"; // // mnuPlateRotateCw // - this.mnuPlateRotateCw.Image = global::OpenNest.Properties.Resources.rotate_cw; - this.mnuPlateRotateCw.Name = "mnuPlateRotateCw"; - this.mnuPlateRotateCw.Size = new System.Drawing.Size(121, 22); - this.mnuPlateRotateCw.Text = "90° CW"; - this.mnuPlateRotateCw.Click += new System.EventHandler(this.RotateCw_Click); + mnuPlateRotateCw.Image = Properties.Resources.rotate_cw; + mnuPlateRotateCw.Name = "mnuPlateRotateCw"; + mnuPlateRotateCw.Size = new System.Drawing.Size(121, 22); + mnuPlateRotateCw.Text = "90° CW"; + mnuPlateRotateCw.Click += RotateCw_Click; // // mnuPlateRotateCcw // - this.mnuPlateRotateCcw.Image = global::OpenNest.Properties.Resources.rotate_ccw; - this.mnuPlateRotateCcw.Name = "mnuPlateRotateCcw"; - this.mnuPlateRotateCcw.Size = new System.Drawing.Size(121, 22); - this.mnuPlateRotateCcw.Text = "90° CCW"; - this.mnuPlateRotateCcw.Click += new System.EventHandler(this.RotateCcw_Click); + mnuPlateRotateCcw.Image = Properties.Resources.rotate_ccw; + mnuPlateRotateCcw.Name = "mnuPlateRotateCcw"; + mnuPlateRotateCcw.Size = new System.Drawing.Size(121, 22); + mnuPlateRotateCcw.Text = "90° CCW"; + mnuPlateRotateCcw.Click += RotateCcw_Click; // // toolStripSeparator2 // - this.toolStripSeparator2.Name = "toolStripSeparator2"; - this.toolStripSeparator2.Size = new System.Drawing.Size(118, 6); + toolStripSeparator2.Name = "toolStripSeparator2"; + toolStripSeparator2.Size = new System.Drawing.Size(118, 6); // // mnuPlateRotate180 // - this.mnuPlateRotate180.Name = "mnuPlateRotate180"; - this.mnuPlateRotate180.Size = new System.Drawing.Size(121, 22); - this.mnuPlateRotate180.Text = "180°"; - this.mnuPlateRotate180.Click += new System.EventHandler(this.Rotate180_Click); + mnuPlateRotate180.Name = "mnuPlateRotate180"; + mnuPlateRotate180.Size = new System.Drawing.Size(121, 22); + mnuPlateRotate180.Text = "180°"; + mnuPlateRotate180.Click += Rotate180_Click; // // mnuResizeToFitParts // - this.mnuResizeToFitParts.Name = "mnuResizeToFitParts"; - this.mnuResizeToFitParts.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.P))); - this.mnuResizeToFitParts.Size = new System.Drawing.Size(177, 22); - this.mnuResizeToFitParts.Text = "Resize to Fit"; - this.mnuResizeToFitParts.Click += new System.EventHandler(this.ResizeToFitParts_Click); + mnuResizeToFitParts.Name = "mnuResizeToFitParts"; + mnuResizeToFitParts.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.P; + mnuResizeToFitParts.Size = new System.Drawing.Size(177, 22); + mnuResizeToFitParts.Text = "Resize to Fit"; + mnuResizeToFitParts.Click += ResizeToFitParts_Click; // // toolStripMenuItem13 // - this.toolStripMenuItem13.Name = "toolStripMenuItem13"; - this.toolStripMenuItem13.Size = new System.Drawing.Size(174, 6); + toolStripMenuItem13.Name = "toolStripMenuItem13"; + toolStripMenuItem13.Size = new System.Drawing.Size(174, 6); // // mnuPlateViewInCad // - this.mnuPlateViewInCad.Name = "mnuPlateViewInCad"; - this.mnuPlateViewInCad.Size = new System.Drawing.Size(177, 22); - this.mnuPlateViewInCad.Text = "View in CAD"; - this.mnuPlateViewInCad.Click += new System.EventHandler(this.OpenInExternalCad_Click); + mnuPlateViewInCad.Name = "mnuPlateViewInCad"; + mnuPlateViewInCad.Size = new System.Drawing.Size(177, 22); + mnuPlateViewInCad.Text = "View in CAD"; + mnuPlateViewInCad.Click += OpenInExternalCad_Click; // // toolStripMenuItem20 // - this.toolStripMenuItem20.Name = "toolStripMenuItem20"; - this.toolStripMenuItem20.Size = new System.Drawing.Size(174, 6); + toolStripMenuItem20.Name = "toolStripMenuItem20"; + toolStripMenuItem20.Size = new System.Drawing.Size(174, 6); // // mnuSequenceParts // - this.mnuSequenceParts.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.autoSequenceCurrentPlateToolStripMenuItem, - this.manualSequenceToolStripMenuItem}); - this.mnuSequenceParts.Name = "mnuSequenceParts"; - this.mnuSequenceParts.Size = new System.Drawing.Size(177, 22); - this.mnuSequenceParts.Text = "Sequence Parts"; + mnuSequenceParts.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { autoSequenceCurrentPlateToolStripMenuItem, manualSequenceToolStripMenuItem }); + mnuSequenceParts.Name = "mnuSequenceParts"; + mnuSequenceParts.Size = new System.Drawing.Size(177, 22); + mnuSequenceParts.Text = "Sequence Parts"; // // autoSequenceCurrentPlateToolStripMenuItem // - this.autoSequenceCurrentPlateToolStripMenuItem.Name = "autoSequenceCurrentPlateToolStripMenuItem"; - this.autoSequenceCurrentPlateToolStripMenuItem.Size = new System.Drawing.Size(168, 22); - this.autoSequenceCurrentPlateToolStripMenuItem.Text = "Auto Sequence"; - this.autoSequenceCurrentPlateToolStripMenuItem.Click += new System.EventHandler(this.AutoSequenceCurrentPlate_Click); + autoSequenceCurrentPlateToolStripMenuItem.Name = "autoSequenceCurrentPlateToolStripMenuItem"; + autoSequenceCurrentPlateToolStripMenuItem.Size = new System.Drawing.Size(168, 22); + autoSequenceCurrentPlateToolStripMenuItem.Text = "Auto Sequence"; + autoSequenceCurrentPlateToolStripMenuItem.Click += AutoSequenceCurrentPlate_Click; // // manualSequenceToolStripMenuItem // - this.manualSequenceToolStripMenuItem.Name = "manualSequenceToolStripMenuItem"; - this.manualSequenceToolStripMenuItem.Size = new System.Drawing.Size(168, 22); - this.manualSequenceToolStripMenuItem.Text = "Manual Sequence"; - this.manualSequenceToolStripMenuItem.Click += new System.EventHandler(this.ManualSequenceParts_Click); + manualSequenceToolStripMenuItem.Name = "manualSequenceToolStripMenuItem"; + manualSequenceToolStripMenuItem.Size = new System.Drawing.Size(168, 22); + manualSequenceToolStripMenuItem.Text = "Manual Sequence"; + manualSequenceToolStripMenuItem.Click += ManualSequenceParts_Click; // // calculateCutTimeToolStripMenuItem1 // - this.calculateCutTimeToolStripMenuItem1.Name = "calculateCutTimeToolStripMenuItem1"; - this.calculateCutTimeToolStripMenuItem1.Size = new System.Drawing.Size(177, 22); - this.calculateCutTimeToolStripMenuItem1.Text = "Calculate Cut Time"; - this.calculateCutTimeToolStripMenuItem1.Click += new System.EventHandler(this.CalculatePlateCutTime_Click); + calculateCutTimeToolStripMenuItem1.Name = "calculateCutTimeToolStripMenuItem1"; + calculateCutTimeToolStripMenuItem1.Size = new System.Drawing.Size(177, 22); + calculateCutTimeToolStripMenuItem1.Text = "Calculate Cut Time"; + calculateCutTimeToolStripMenuItem1.Click += CalculatePlateCutTime_Click; // // centerPartsToolStripMenuItem // - this.centerPartsToolStripMenuItem.Name = "centerPartsToolStripMenuItem"; - this.centerPartsToolStripMenuItem.Size = new System.Drawing.Size(177, 22); - this.centerPartsToolStripMenuItem.Text = "Center Parts"; - this.centerPartsToolStripMenuItem.Click += new System.EventHandler(this.centerPartsToolStripMenuItem_Click); + centerPartsToolStripMenuItem.Name = "centerPartsToolStripMenuItem"; + centerPartsToolStripMenuItem.Size = new System.Drawing.Size(177, 22); + centerPartsToolStripMenuItem.Text = "Center Parts"; + centerPartsToolStripMenuItem.Click += centerPartsToolStripMenuItem_Click; // // mnuWindow // - this.mnuWindow.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuWindowCascade, - this.mnuWindowTileVertical, - this.mnuWindowTileHorizontal, - this.toolStripMenuItem10, - this.closeToolStripMenuItem, - this.mnuCloseAll}); - this.mnuWindow.Name = "mnuWindow"; - this.mnuWindow.Size = new System.Drawing.Size(63, 20); - this.mnuWindow.Text = "&Window"; + mnuWindow.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuWindowCascade, mnuWindowTileVertical, mnuWindowTileHorizontal, toolStripMenuItem10, closeToolStripMenuItem, mnuCloseAll }); + mnuWindow.Name = "mnuWindow"; + mnuWindow.Size = new System.Drawing.Size(63, 20); + mnuWindow.Text = "&Window"; // // mnuWindowCascade // - this.mnuWindowCascade.Name = "mnuWindowCascade"; - this.mnuWindowCascade.Size = new System.Drawing.Size(151, 22); - this.mnuWindowCascade.Text = "Cascade"; - this.mnuWindowCascade.Click += new System.EventHandler(this.CascadeWindows_Click); + mnuWindowCascade.Name = "mnuWindowCascade"; + mnuWindowCascade.Size = new System.Drawing.Size(151, 22); + mnuWindowCascade.Text = "Cascade"; + mnuWindowCascade.Click += CascadeWindows_Click; // // mnuWindowTileVertical // - this.mnuWindowTileVertical.Name = "mnuWindowTileVertical"; - this.mnuWindowTileVertical.Size = new System.Drawing.Size(151, 22); - this.mnuWindowTileVertical.Text = "Tile Vertical"; - this.mnuWindowTileVertical.Click += new System.EventHandler(this.TileVertical_Click); + mnuWindowTileVertical.Name = "mnuWindowTileVertical"; + mnuWindowTileVertical.Size = new System.Drawing.Size(151, 22); + mnuWindowTileVertical.Text = "Tile Vertical"; + mnuWindowTileVertical.Click += TileVertical_Click; // // mnuWindowTileHorizontal // - this.mnuWindowTileHorizontal.Name = "mnuWindowTileHorizontal"; - this.mnuWindowTileHorizontal.Size = new System.Drawing.Size(151, 22); - this.mnuWindowTileHorizontal.Text = "Tile Horizontal"; - this.mnuWindowTileHorizontal.Click += new System.EventHandler(this.TileHorizontal_Click); + mnuWindowTileHorizontal.Name = "mnuWindowTileHorizontal"; + mnuWindowTileHorizontal.Size = new System.Drawing.Size(151, 22); + mnuWindowTileHorizontal.Text = "Tile Horizontal"; + mnuWindowTileHorizontal.Click += TileHorizontal_Click; // // toolStripMenuItem10 // - this.toolStripMenuItem10.Name = "toolStripMenuItem10"; - this.toolStripMenuItem10.Size = new System.Drawing.Size(148, 6); + toolStripMenuItem10.Name = "toolStripMenuItem10"; + toolStripMenuItem10.Size = new System.Drawing.Size(148, 6); // // closeToolStripMenuItem // - this.closeToolStripMenuItem.Name = "closeToolStripMenuItem"; - this.closeToolStripMenuItem.Size = new System.Drawing.Size(151, 22); - this.closeToolStripMenuItem.Text = "Close"; - this.closeToolStripMenuItem.Click += new System.EventHandler(this.Close_Click); + closeToolStripMenuItem.Name = "closeToolStripMenuItem"; + closeToolStripMenuItem.Size = new System.Drawing.Size(151, 22); + closeToolStripMenuItem.Text = "Close"; + closeToolStripMenuItem.Click += Close_Click; // // mnuCloseAll // - this.mnuCloseAll.Name = "mnuCloseAll"; - this.mnuCloseAll.Size = new System.Drawing.Size(151, 22); - this.mnuCloseAll.Text = "Close All"; - this.mnuCloseAll.Click += new System.EventHandler(this.CloseAll_Click); + mnuCloseAll.Name = "mnuCloseAll"; + mnuCloseAll.Size = new System.Drawing.Size(151, 22); + mnuCloseAll.Text = "Close All"; + mnuCloseAll.Click += CloseAll_Click; // // statusStrip1 // - this.statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.statusLabel1, - this.locationStatusLabel, - this.spacerLabel, - this.plateIndexStatusLabel, - this.plateSizeStatusLabel, - 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); - this.statusStrip1.TabIndex = 9; - this.statusStrip1.Text = "statusStrip1"; + statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { statusLabel1, locationStatusLabel, spacerLabel, plateIndexStatusLabel, plateSizeStatusLabel, plateQtyStatusLabel, gpuStatusLabel }); + statusStrip1.Location = new System.Drawing.Point(0, 630); + statusStrip1.Name = "statusStrip1"; + statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0); + statusStrip1.Size = new System.Drawing.Size(1281, 24); + statusStrip1.TabIndex = 9; + statusStrip1.Text = "statusStrip1"; // // statusLabel1 // - this.statusLabel1.Name = "statusLabel1"; - this.statusLabel1.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.statusLabel1.Size = new System.Drawing.Size(10, 19); + statusLabel1.Name = "statusLabel1"; + statusLabel1.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + statusLabel1.Size = new System.Drawing.Size(10, 19); // // locationStatusLabel // - this.locationStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; - this.locationStatusLabel.Name = "locationStatusLabel"; - this.locationStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.locationStatusLabel.Size = new System.Drawing.Size(102, 19); - this.locationStatusLabel.Text = "Location : [0, 0]"; - this.locationStatusLabel.Click += new System.EventHandler(this.LocationStatusLabel_Click); + locationStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + locationStatusLabel.Name = "locationStatusLabel"; + locationStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + locationStatusLabel.Size = new System.Drawing.Size(102, 19); + locationStatusLabel.Text = "Location : [0, 0]"; + locationStatusLabel.Click += LocationStatusLabel_Click; // // spacerLabel // - this.spacerLabel.Name = "spacerLabel"; - this.spacerLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.spacerLabel.Size = new System.Drawing.Size(764, 19); - this.spacerLabel.Spring = true; + spacerLabel.Name = "spacerLabel"; + spacerLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + spacerLabel.Size = new System.Drawing.Size(931, 19); + spacerLabel.Spring = true; // // plateIndexStatusLabel // - this.plateIndexStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; - this.plateIndexStatusLabel.Name = "plateIndexStatusLabel"; - this.plateIndexStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.plateIndexStatusLabel.Size = new System.Drawing.Size(85, 19); - this.plateIndexStatusLabel.Text = "Plate : 0 of 0"; + plateIndexStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + plateIndexStatusLabel.Name = "plateIndexStatusLabel"; + plateIndexStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + plateIndexStatusLabel.Size = new System.Drawing.Size(85, 19); + plateIndexStatusLabel.Text = "Plate : 0 of 0"; // // plateSizeStatusLabel // - this.plateSizeStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; - this.plateSizeStatusLabel.Name = "plateSizeStatusLabel"; - this.plateSizeStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.plateSizeStatusLabel.Size = new System.Drawing.Size(67, 19); - this.plateSizeStatusLabel.Text = "Size : 0x0"; + plateSizeStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + plateSizeStatusLabel.Name = "plateSizeStatusLabel"; + plateSizeStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + plateSizeStatusLabel.Size = new System.Drawing.Size(67, 19); + plateSizeStatusLabel.Text = "Size : 0x0"; // // plateQtyStatusLabel // - this.plateQtyStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; - this.plateQtyStatusLabel.Name = "plateQtyStatusLabel"; - 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"; - // + plateQtyStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + plateQtyStatusLabel.Name = "plateQtyStatusLabel"; + plateQtyStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + plateQtyStatusLabel.Size = new System.Drawing.Size(55, 19); + 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); - // + // + gpuStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + gpuStatusLabel.Name = "gpuStatusLabel"; + gpuStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + gpuStatusLabel.Size = new System.Drawing.Size(14, 19); + // // toolStrip1 // - this.toolStrip1.AutoSize = false; - this.toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.btnNew, - this.btnOpen, - this.btnSave, - this.btnSaveAs, - this.toolStripSeparator1, - this.btnFirstPlate, - this.btnPreviousPlate, - this.btnNextPlate, - this.btnLastPlate, - this.toolStripSeparator3, - this.btnZoomOut, - this.btnZoomIn, - this.btnZoomToFit, - this.toolStripSeparator4, - this.engineLabel, - this.engineComboBox, - this.btnAutoNest}); - this.toolStrip1.Location = new System.Drawing.Point(0, 24); - this.toolStrip1.Name = "toolStrip1"; - this.toolStrip1.Size = new System.Drawing.Size(1098, 35); - this.toolStrip1.TabIndex = 10; - this.toolStrip1.Text = "toolStrip1"; + toolStrip1.AutoSize = false; + toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { btnNew, btnOpen, btnSave, btnSaveAs, toolStripSeparator1, btnFirstPlate, btnPreviousPlate, btnNextPlate, btnLastPlate, toolStripSeparator3, btnZoomOut, btnZoomIn, btnZoomToFit, toolStripSeparator4, engineLabel, engineComboBox, btnAutoNest }); + toolStrip1.Location = new System.Drawing.Point(0, 24); + toolStrip1.Name = "toolStrip1"; + toolStrip1.Size = new System.Drawing.Size(1281, 40); + toolStrip1.TabIndex = 10; + toolStrip1.Text = "toolStrip1"; // // btnNew // - this.btnNew.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnNew.Image = global::OpenNest.Properties.Resources.doc_new; - this.btnNew.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnNew.Name = "btnNew"; - this.btnNew.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnNew.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnNew.Size = new System.Drawing.Size(38, 32); - this.btnNew.Text = "New"; - this.btnNew.Click += new System.EventHandler(this.New_Click); + btnNew.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnNew.Image = Properties.Resources.doc_new; + btnNew.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnNew.Name = "btnNew"; + btnNew.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnNew.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnNew.Size = new System.Drawing.Size(38, 37); + btnNew.Text = "New"; + btnNew.Click += New_Click; // // btnOpen // - this.btnOpen.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnOpen.Image = global::OpenNest.Properties.Resources.doc_open; - this.btnOpen.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnOpen.Name = "btnOpen"; - this.btnOpen.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnOpen.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnOpen.Size = new System.Drawing.Size(38, 32); - this.btnOpen.Text = "Open"; - this.btnOpen.Click += new System.EventHandler(this.Open_Click); + btnOpen.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnOpen.Image = Properties.Resources.doc_open; + btnOpen.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnOpen.Name = "btnOpen"; + btnOpen.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnOpen.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnOpen.Size = new System.Drawing.Size(38, 37); + btnOpen.Text = "Open"; + btnOpen.Click += Open_Click; // // btnSave // - this.btnSave.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnSave.Image = global::OpenNest.Properties.Resources.save; - this.btnSave.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnSave.Name = "btnSave"; - this.btnSave.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnSave.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnSave.Size = new System.Drawing.Size(38, 32); - this.btnSave.Text = "Save"; - this.btnSave.Click += new System.EventHandler(this.Save_Click); + btnSave.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnSave.Image = Properties.Resources.save; + btnSave.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnSave.Name = "btnSave"; + btnSave.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnSave.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnSave.Size = new System.Drawing.Size(38, 37); + btnSave.Text = "Save"; + btnSave.Click += Save_Click; // // btnSaveAs // - this.btnSaveAs.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnSaveAs.Image = global::OpenNest.Properties.Resources.save_as; - this.btnSaveAs.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnSaveAs.Name = "btnSaveAs"; - this.btnSaveAs.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnSaveAs.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnSaveAs.Size = new System.Drawing.Size(38, 32); - this.btnSaveAs.Text = "Save As"; - this.btnSaveAs.Click += new System.EventHandler(this.SaveAs_Click); + btnSaveAs.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnSaveAs.Image = Properties.Resources.save_as; + btnSaveAs.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnSaveAs.Name = "btnSaveAs"; + btnSaveAs.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnSaveAs.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnSaveAs.Size = new System.Drawing.Size(38, 37); + btnSaveAs.Text = "Save As"; + btnSaveAs.Click += SaveAs_Click; // // toolStripSeparator1 // - this.toolStripSeparator1.Name = "toolStripSeparator1"; - this.toolStripSeparator1.Size = new System.Drawing.Size(6, 35); + toolStripSeparator1.Name = "toolStripSeparator1"; + toolStripSeparator1.Size = new System.Drawing.Size(6, 40); // // btnFirstPlate // - this.btnFirstPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnFirstPlate.Image = global::OpenNest.Properties.Resources.move_first; - this.btnFirstPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnFirstPlate.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnFirstPlate.Name = "btnFirstPlate"; - this.btnFirstPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnFirstPlate.Size = new System.Drawing.Size(38, 32); - this.btnFirstPlate.Text = "Go to First Plate"; - this.btnFirstPlate.Click += new System.EventHandler(this.LoadFirstPlate_Click); + btnFirstPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnFirstPlate.Image = Properties.Resources.move_first; + btnFirstPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnFirstPlate.ImageTransparentColor = System.Drawing.Color.Magenta; + btnFirstPlate.Name = "btnFirstPlate"; + btnFirstPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnFirstPlate.Size = new System.Drawing.Size(38, 37); + btnFirstPlate.Text = "Go to First Plate"; + btnFirstPlate.Click += LoadFirstPlate_Click; // // btnPreviousPlate // - this.btnPreviousPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnPreviousPlate.Image = global::OpenNest.Properties.Resources.move_previous; - this.btnPreviousPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnPreviousPlate.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnPreviousPlate.Name = "btnPreviousPlate"; - this.btnPreviousPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnPreviousPlate.Size = new System.Drawing.Size(38, 32); - this.btnPreviousPlate.Text = "Go to Previous Plate"; - this.btnPreviousPlate.Click += new System.EventHandler(this.LoadPreviousPlate_Click); + btnPreviousPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnPreviousPlate.Image = Properties.Resources.move_previous; + btnPreviousPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnPreviousPlate.ImageTransparentColor = System.Drawing.Color.Magenta; + btnPreviousPlate.Name = "btnPreviousPlate"; + btnPreviousPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnPreviousPlate.Size = new System.Drawing.Size(38, 37); + btnPreviousPlate.Text = "Go to Previous Plate"; + btnPreviousPlate.Click += LoadPreviousPlate_Click; // // btnNextPlate // - this.btnNextPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnNextPlate.Image = global::OpenNest.Properties.Resources.move_next; - this.btnNextPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnNextPlate.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnNextPlate.Name = "btnNextPlate"; - this.btnNextPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnNextPlate.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnNextPlate.Size = new System.Drawing.Size(38, 32); - this.btnNextPlate.Text = "Go to Next Plate"; - this.btnNextPlate.Click += new System.EventHandler(this.LoadNextPlate_Click); + btnNextPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnNextPlate.Image = Properties.Resources.move_next; + btnNextPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnNextPlate.ImageTransparentColor = System.Drawing.Color.Magenta; + btnNextPlate.Name = "btnNextPlate"; + btnNextPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnNextPlate.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnNextPlate.Size = new System.Drawing.Size(38, 37); + btnNextPlate.Text = "Go to Next Plate"; + btnNextPlate.Click += LoadNextPlate_Click; // // btnLastPlate // - this.btnLastPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnLastPlate.Image = global::OpenNest.Properties.Resources.move_last; - this.btnLastPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnLastPlate.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnLastPlate.Name = "btnLastPlate"; - this.btnLastPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnLastPlate.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnLastPlate.Size = new System.Drawing.Size(38, 32); - this.btnLastPlate.Text = "Go to Last Plate"; - this.btnLastPlate.Click += new System.EventHandler(this.LoadLastPlate_Click); + btnLastPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnLastPlate.Image = Properties.Resources.move_last; + btnLastPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnLastPlate.ImageTransparentColor = System.Drawing.Color.Magenta; + btnLastPlate.Name = "btnLastPlate"; + btnLastPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnLastPlate.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnLastPlate.Size = new System.Drawing.Size(38, 37); + btnLastPlate.Text = "Go to Last Plate"; + btnLastPlate.Click += LoadLastPlate_Click; // // toolStripSeparator3 // - this.toolStripSeparator3.Name = "toolStripSeparator3"; - this.toolStripSeparator3.Size = new System.Drawing.Size(6, 35); + toolStripSeparator3.Name = "toolStripSeparator3"; + toolStripSeparator3.Size = new System.Drawing.Size(6, 40); // // btnZoomOut // - this.btnZoomOut.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnZoomOut.Image = global::OpenNest.Properties.Resources.zoom_out; - this.btnZoomOut.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnZoomOut.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnZoomOut.Name = "btnZoomOut"; - this.btnZoomOut.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnZoomOut.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnZoomOut.Size = new System.Drawing.Size(38, 32); - this.btnZoomOut.Text = "Zoom Out"; - this.btnZoomOut.Click += new System.EventHandler(this.ZoomOut_Click); + btnZoomOut.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnZoomOut.Image = Properties.Resources.zoom_out; + btnZoomOut.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnZoomOut.ImageTransparentColor = System.Drawing.Color.Magenta; + btnZoomOut.Name = "btnZoomOut"; + btnZoomOut.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnZoomOut.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnZoomOut.Size = new System.Drawing.Size(38, 37); + btnZoomOut.Text = "Zoom Out"; + btnZoomOut.Click += ZoomOut_Click; // // btnZoomIn // - this.btnZoomIn.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnZoomIn.Image = global::OpenNest.Properties.Resources.zoom_in; - this.btnZoomIn.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnZoomIn.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnZoomIn.Name = "btnZoomIn"; - this.btnZoomIn.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnZoomIn.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnZoomIn.Size = new System.Drawing.Size(38, 32); - this.btnZoomIn.Text = "Zoom In"; - this.btnZoomIn.Click += new System.EventHandler(this.ZoomIn_Click); + btnZoomIn.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnZoomIn.Image = Properties.Resources.zoom_in; + btnZoomIn.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnZoomIn.ImageTransparentColor = System.Drawing.Color.Magenta; + btnZoomIn.Name = "btnZoomIn"; + btnZoomIn.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnZoomIn.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnZoomIn.Size = new System.Drawing.Size(38, 37); + btnZoomIn.Text = "Zoom In"; + btnZoomIn.Click += ZoomIn_Click; // // btnZoomToFit // - this.btnZoomToFit.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnZoomToFit.Image = global::OpenNest.Properties.Resources.zoom_all; - this.btnZoomToFit.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnZoomToFit.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnZoomToFit.Name = "btnZoomToFit"; - this.btnZoomToFit.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnZoomToFit.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnZoomToFit.Size = new System.Drawing.Size(38, 32); - this.btnZoomToFit.Text = "Zoom To Fit"; - this.btnZoomToFit.Click += new System.EventHandler(this.ZoomToFit_Click); - // + btnZoomToFit.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnZoomToFit.Image = Properties.Resources.zoom_all; + btnZoomToFit.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnZoomToFit.ImageTransparentColor = System.Drawing.Color.Magenta; + btnZoomToFit.Name = "btnZoomToFit"; + btnZoomToFit.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnZoomToFit.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnZoomToFit.Size = new System.Drawing.Size(38, 37); + btnZoomToFit.Text = "Zoom To Fit"; + btnZoomToFit.Click += ZoomToFit_Click; + // // toolStripSeparator4 - // - this.toolStripSeparator4.Name = "toolStripSeparator4"; - this.toolStripSeparator4.Size = new System.Drawing.Size(6, 35); - // + // + toolStripSeparator4.Name = "toolStripSeparator4"; + toolStripSeparator4.Size = new System.Drawing.Size(6, 40); + // // engineLabel - // - this.engineLabel.Name = "engineLabel"; - this.engineLabel.Size = new System.Drawing.Size(46, 32); - this.engineLabel.Text = "Engine:"; - // + // + engineLabel.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, 0); + engineLabel.Name = "engineLabel"; + engineLabel.Size = new System.Drawing.Size(47, 37); + engineLabel.Text = "Engine:"; + // // engineComboBox - // - this.engineComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; - this.engineComboBox.Name = "engineComboBox"; - this.engineComboBox.Size = new System.Drawing.Size(100, 35); - // + // + engineComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + engineComboBox.Name = "engineComboBox"; + engineComboBox.Size = new System.Drawing.Size(116, 40); + // // btnAutoNest - // - this.btnAutoNest.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; - this.btnAutoNest.Name = "btnAutoNest"; - this.btnAutoNest.Size = new System.Drawing.Size(64, 32); - this.btnAutoNest.Text = "Auto Nest"; - this.btnAutoNest.Click += new System.EventHandler(this.RunAutoNest_Click); - // + // + btnAutoNest.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + btnAutoNest.Name = "btnAutoNest"; + btnAutoNest.Size = new System.Drawing.Size(64, 37); + btnAutoNest.Text = "Auto Nest"; + btnAutoNest.Click += RunAutoNest_Click; + // // pEPToolStripMenuItem // - this.pEPToolStripMenuItem.Name = "pEPToolStripMenuItem"; - this.pEPToolStripMenuItem.Size = new System.Drawing.Size(32, 19); + pEPToolStripMenuItem.Name = "pEPToolStripMenuItem"; + pEPToolStripMenuItem.Size = new System.Drawing.Size(32, 19); // // openNestToolStripMenuItem // - this.openNestToolStripMenuItem.Name = "openNestToolStripMenuItem"; - this.openNestToolStripMenuItem.Size = new System.Drawing.Size(32, 19); + openNestToolStripMenuItem.Name = "openNestToolStripMenuItem"; + openNestToolStripMenuItem.Size = new System.Drawing.Size(32, 19); // // MainForm // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.BackgroundImage = global::OpenNest.Properties.Resources.watermark; - this.BackgroundImageLayout = System.Windows.Forms.ImageLayout.Center; - this.ClientSize = new System.Drawing.Size(1098, 567); - this.Controls.Add(this.toolStrip1); - this.Controls.Add(this.statusStrip1); - this.Controls.Add(this.menuStrip1); - this.DoubleBuffered = true; - this.IsMdiContainer = true; - this.MainMenuStrip = this.menuStrip1; - this.MinimumSize = new System.Drawing.Size(506, 335); - this.Name = "MainForm"; - this.Text = "OpenNest"; - this.WindowState = System.Windows.Forms.FormWindowState.Maximized; - this.menuStrip1.ResumeLayout(false); - this.menuStrip1.PerformLayout(); - this.statusStrip1.ResumeLayout(false); - this.statusStrip1.PerformLayout(); - this.toolStrip1.ResumeLayout(false); - this.toolStrip1.PerformLayout(); - this.ResumeLayout(false); - this.PerformLayout(); + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + BackgroundImage = Properties.Resources.watermark; + BackgroundImageLayout = System.Windows.Forms.ImageLayout.Center; + ClientSize = new System.Drawing.Size(1281, 654); + Controls.Add(toolStrip1); + Controls.Add(statusStrip1); + Controls.Add(menuStrip1); + DoubleBuffered = true; + IsMdiContainer = true; + MainMenuStrip = menuStrip1; + Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + MinimumSize = new System.Drawing.Size(588, 381); + Name = "MainForm"; + Text = "OpenNest"; + WindowState = System.Windows.Forms.FormWindowState.Maximized; + menuStrip1.ResumeLayout(false); + menuStrip1.PerformLayout(); + statusStrip1.ResumeLayout(false); + statusStrip1.PerformLayout(); + toolStrip1.ResumeLayout(false); + toolStrip1.PerformLayout(); + ResumeLayout(false); + PerformLayout(); } diff --git a/OpenNest/Forms/MainForm.resx b/OpenNest/Forms/MainForm.resx index 24c4237..9178246 100644 --- a/OpenNest/Forms/MainForm.resx +++ b/OpenNest/Forms/MainForm.resx @@ -1,17 +1,17 @@  - From 026227848b9ec2b7718f1003d61dc7c6cfaf2031 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 23:06:12 -0400 Subject: [PATCH 092/116] docs: add plans for ML angle pruning, fill-exact, and helper decomposition Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-14-ml-angle-pruning.md | 1003 +++++++++++++++++ .../plans/2026-03-15-fill-exact.md | 462 ++++++++ .../plans/2026-03-15-helper-decomposition.md | 350 ++++++ .../specs/2026-03-15-fill-exact-design.md | 96 ++ 4 files changed, 1911 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-14-ml-angle-pruning.md create mode 100644 docs/superpowers/plans/2026-03-15-fill-exact.md create mode 100644 docs/superpowers/plans/2026-03-15-helper-decomposition.md create mode 100644 docs/superpowers/specs/2026-03-15-fill-exact-design.md diff --git a/docs/superpowers/plans/2026-03-14-ml-angle-pruning.md b/docs/superpowers/plans/2026-03-14-ml-angle-pruning.md new file mode 100644 index 0000000..b80e3f2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-14-ml-angle-pruning.md @@ -0,0 +1,1003 @@ +# ML Angle Pruning Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Instrument the nesting engine to collect per-angle results during training runs, store them in the database, and add a `Description` field to the progress window for real-time visibility. + +**Architecture:** The engine gains a `ForceFullAngleSweep` flag and an `AngleResults` collection populated during `FindBestFill`. `BruteForceResult` passes these through. The training project stores them in a new `AngleResults` SQLite table. The WinForms progress form gains a description row. ONNX inference (`AnglePredictor`) is a separate future task — it requires trained model data that doesn't exist yet. + +**Tech Stack:** C# / .NET 8, EF Core SQLite, WinForms + +**Spec:** `docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md` + +--- + +## Chunk 1: Engine Instrumentation + +### Task 1: Add `AngleResult` class and `Description` to `NestProgress` + +**Files:** +- Modify: `OpenNest.Engine/NestProgress.cs` + +- [ ] **Step 1: Add `AngleResult` class and `Description` property** + +Add to `OpenNest.Engine/NestProgress.cs`, after the `PhaseResult` class: + +```csharp +public class AngleResult +{ + public double AngleDeg { get; set; } + public NestDirection Direction { get; set; } + public int PartCount { get; set; } +} +``` + +Add to `NestProgress`: + +```csharp +public string Description { get; set; } +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestProgress.cs +git commit -m "feat(engine): add AngleResult class and Description to NestProgress" +``` + +--- + +### Task 2: Add `ForceFullAngleSweep` and `AngleResults` to `NestEngine` + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +- [ ] **Step 1: Add properties to `NestEngine`** + +Add after the existing `PhaseResults` property (line 29): + +```csharp +public bool ForceFullAngleSweep { get; set; } +public List AngleResults { get; } = new(); +``` + +- [ ] **Step 2: Clear `AngleResults` at the start of `Fill`** + +In `Fill(NestItem item, Box workArea, IProgress progress, CancellationToken token)` (line 52), add `AngleResults.Clear();` right after `PhaseResults.Clear();` (line 55). + +- [ ] **Step 3: Force full angle sweep when flag is set** + +In `FindBestFill(NestItem item, Box workArea, IProgress progress, CancellationToken token)` (line 163), after the existing narrow-work-area angle expansion block (lines 182-190), add a second block: + +```csharp +if (ForceFullAngleSweep) +{ + var step = Angle.ToRadians(5); + for (var a = 0.0; a < System.Math.PI; a += step) + { + if (!angles.Any(existing => existing.IsEqualTo(a))) + angles.Add(a); + } +} +``` + +Also apply the same pattern to the non-progress overload `FindBestFill(NestItem item, Box workArea)` (line 84) — add the same `ForceFullAngleSweep` block after line 115. + +- [ ] **Step 4: Collect per-angle results in the progress overload** + +In `FindBestFill` (progress overload, line 163), inside the `Parallel.ForEach` over angles (lines 209-221), replace the parallel body to also collect angle results. Use a `ConcurrentBag` alongside the existing `linearBag`: + +Before the `Parallel.ForEach` (after line 207), add: +```csharp +var angleBag = new System.Collections.Concurrent.ConcurrentBag(); +``` + +Inside the parallel body, after computing `h` and `v`, add: +```csharp +var angleDeg = Angle.ToDegrees(angle); +if (h != null && h.Count > 0) + angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count }); +if (v != null && v.Count > 0) + angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count }); +``` + +After the `Parallel.ForEach` completes and `linearSw.Stop()` (line 222), add: +```csharp +AngleResults.AddRange(angleBag); +``` + +- [ ] **Step 5: Add `ForceFullAngleSweep` to non-progress overload (angle sweep only, no result collection)** + +The non-progress `FindBestFill` at line 84 is only called from `TryStripRefill` for sub-strip fills. Do NOT collect angle results there — sub-strip data would contaminate the main plate's results. Only add the `ForceFullAngleSweep` block (same as Step 3) after line 115. + +Do NOT add `AngleResults.Clear()` to `Fill(NestItem, Box)` at line 41 — it delegates to the progress overload at line 52 which already clears `AngleResults`. + +- [ ] **Step 6: Report per-angle progress descriptions** + +In the progress overload's `Parallel.ForEach` body, after computing h and v for an angle, report progress with a description. Since we're inside a parallel loop, use a simple approach — report after computing each angle: + +```csharp +var bestDir = (h?.Count ?? 0) >= (v?.Count ?? 0) ? "H" : "V"; +var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0); +progress?.Report(new NestProgress +{ + Phase = NestPhase.Linear, + PlateNumber = PlateNumber, + Description = $"Linear: {angleDeg:F0}\u00b0 {bestDir} - {bestCount} parts" +}); +``` + +- [ ] **Step 7: Report per-candidate progress in Pairs phase** + +In `FillWithPairs(NestItem item, Box workArea, CancellationToken token)` (line 409), inside the `Parallel.For` body (line 424), add progress reporting. Since `FillWithPairs` doesn't have access to the `progress` parameter, this requires adding a progress parameter. + +Change the signature of the cancellation-token overload at line 409 to: + +```csharp +private List FillWithPairs(NestItem item, Box workArea, CancellationToken token, IProgress progress = null) +``` + +Update the call site at line 194 (`FindBestFill` progress overload) to pass `progress`: + +```csharp +var pairResult = FillWithPairs(item, workArea, token, progress); +``` + +Inside the `Parallel.For` body (line 424), after computing `filled`, add: + +```csharp +progress?.Report(new NestProgress +{ + Phase = NestPhase.Pairs, + PlateNumber = PlateNumber, + Description = $"Pairs: candidate {i + 1}/{candidates.Count} - {filled?.Count ?? 0} parts" +}); +``` + +- [ ] **Step 8: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 9: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): add ForceFullAngleSweep flag and per-angle result collection" +``` + +--- + +### Task 3: Add `AngleResults` to `BruteForceResult` and `BruteForceRunner` + +**Files:** +- Modify: `OpenNest.Engine/ML/BruteForceRunner.cs` + +- [ ] **Step 1: Add `AngleResults` property to `BruteForceResult`** + +Add to `BruteForceResult` class (after `ThirdPlaceTimeMs`): + +```csharp +public List AngleResults { get; set; } = new(); +``` + +- [ ] **Step 2: Populate `AngleResults` in `BruteForceRunner.Run`** + +In the `return new BruteForceResult` block (line 47), add: + +```csharp +AngleResults = engine.AngleResults.ToList(), +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/ML/BruteForceRunner.cs +git commit -m "feat(engine): pass per-angle results through BruteForceResult" +``` + +--- + +## Chunk 2: Training Database & Runner + +### Task 4: Add `TrainingAngleResult` EF Core entity + +**Files:** +- Create: `OpenNest.Training/Data/TrainingAngleResult.cs` +- Modify: `OpenNest.Training/Data/TrainingDbContext.cs` + +- [ ] **Step 1: Create the entity class** + +Create `OpenNest.Training/Data/TrainingAngleResult.cs`: + +```csharp +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenNest.Training.Data +{ + [Table("AngleResults")] + public class TrainingAngleResult + { + [Key] + public long Id { get; set; } + + public long RunId { get; set; } + public double AngleDeg { get; set; } + public string Direction { get; set; } + public int PartCount { get; set; } + + [ForeignKey(nameof(RunId))] + public TrainingRun Run { get; set; } + } +} +``` + +- [ ] **Step 2: Add navigation property to `TrainingRun`** + +In `OpenNest.Training/Data/TrainingRun.cs`, add at the end of the class (after the `Part` navigation property): + +```csharp +public List AngleResults { get; set; } = new(); +``` + +Add `using System.Collections.Generic;` to the top if not already present. + +- [ ] **Step 3: Register `DbSet` and configure in `TrainingDbContext`** + +In `OpenNest.Training/Data/TrainingDbContext.cs`: + +Add DbSet: +```csharp +public DbSet AngleResults { get; set; } +``` + +Add configuration in `OnModelCreating`: +```csharp +modelBuilder.Entity(e => +{ + e.HasIndex(a => a.RunId).HasDatabaseName("idx_angleresults_runid"); + e.HasOne(a => a.Run) + .WithMany(r => r.AngleResults) + .HasForeignKey(a => a.RunId); +}); +``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest.Training` +Expected: Build succeeded + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Training/Data/TrainingAngleResult.cs OpenNest.Training/Data/TrainingRun.cs OpenNest.Training/Data/TrainingDbContext.cs +git commit -m "feat(training): add TrainingAngleResult entity and DbSet" +``` + +--- + +### Task 5: Extend `TrainingDatabase` for angle result storage and migration + +**Files:** +- Modify: `OpenNest.Training/TrainingDatabase.cs` + +- [ ] **Step 1: Create `AngleResults` table in `MigrateSchema`** + +Add to the end of the `MigrateSchema` method, after the existing column migration loop: + +```csharp +try +{ + _db.Database.ExecuteSqlRaw(@" + CREATE TABLE IF NOT EXISTS AngleResults ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + RunId INTEGER NOT NULL, + AngleDeg REAL NOT NULL, + Direction TEXT NOT NULL, + PartCount INTEGER NOT NULL, + FOREIGN KEY (RunId) REFERENCES Runs(Id) + )"); + _db.Database.ExecuteSqlRaw( + "CREATE INDEX IF NOT EXISTS idx_angleresults_runid ON AngleResults (RunId)"); +} +catch +{ + // Table already exists or other non-fatal issue. +} +``` + +- [ ] **Step 2: Extend `AddRun` to accept and batch-insert angle results** + +Change the `AddRun` signature to accept angle results: + +```csharp +public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath, List angleResults = null) +``` + +Add `using OpenNest;` at the top if not already present (for `AngleResult` type). + +After `_db.Runs.Add(run);` and before `_db.SaveChanges();`, add: + +```csharp +if (angleResults != null && angleResults.Count > 0) +{ + foreach (var ar in angleResults) + { + _db.AngleResults.Add(new Data.TrainingAngleResult + { + Run = run, + AngleDeg = ar.AngleDeg, + Direction = ar.Direction.ToString(), + PartCount = ar.PartCount + }); + } +} +``` + +The single `SaveChanges()` call will batch-insert both the run and all angle results in one transaction. + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest.Training` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Training/TrainingDatabase.cs +git commit -m "feat(training): add AngleResults table migration and batch insert" +``` + +--- + +### Task 6: Wire up training runner to collect angle data + +**Files:** +- Modify: `OpenNest.Training/Program.cs` + +- [ ] **Step 1: Set `ForceFullAngleSweep` on the engine** + +In `Program.cs`, inside the `foreach (var size in sheetSuite)` loop, after creating the `BruteForceRunner.Run` call (line 203), we need to change the approach. Currently `BruteForceRunner.Run` creates the engine internally. We need to modify `BruteForceRunner.Run` to accept the `ForceFullAngleSweep` flag. + +Actually, looking at the code, `BruteForceRunner.Run` creates a `NestEngine` internally (line 29 of BruteForceRunner.cs). The cleanest approach: add an overload or optional parameter. + +In `OpenNest.Engine/ML/BruteForceRunner.cs`, change the `Run` method signature to: + +```csharp +public static BruteForceResult Run(Drawing drawing, Plate plate, bool forceFullAngleSweep = false) +``` + +And set it on the engine after creation: + +```csharp +var engine = new NestEngine(plate); +engine.ForceFullAngleSweep = forceFullAngleSweep; +``` + +- [ ] **Step 2: Pass `forceFullAngleSweep = true` from the training runner** + +In `OpenNest.Training/Program.cs`, change the `BruteForceRunner.Run` call (line 203) to: + +```csharp +var result = BruteForceRunner.Run(drawing, runPlate, forceFullAngleSweep: true); +``` + +- [ ] **Step 3: Pass angle results to `AddRun`** + +Change the `db.AddRun` call (line 266) to: + +```csharp +db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath, result.AngleResults); +``` + +- [ ] **Step 4: Add angle result count to console output** + +In the console output line (line 223), append angle result count. Change: + +```csharp +Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms [{engineInfo}]"); +``` + +To: + +```csharp +Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms [{engineInfo}] angles={result.AngleResults.Count}"); +``` + +- [ ] **Step 5: Build to verify** + +Run: `dotnet build OpenNest.Training` +Expected: Build succeeded + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Engine/ML/BruteForceRunner.cs OpenNest.Training/Program.cs +git commit -m "feat(training): enable forced full angle sweep and store per-angle results" +``` + +--- + +## Chunk 3: Progress Window Enhancement + +### Task 7: Add `Description` row to the progress form + +**Files:** +- Modify: `OpenNest/Forms/NestProgressForm.Designer.cs` +- Modify: `OpenNest/Forms/NestProgressForm.cs` + +- [ ] **Step 1: Add description label and value controls in Designer** + +In `NestProgressForm.Designer.cs`, add field declarations alongside the existing ones (after `elapsedValue` on line 231): + +```csharp +private System.Windows.Forms.Label descriptionLabel; +private System.Windows.Forms.Label descriptionValue; +``` + +In `InitializeComponent()`, add control creation (after the `elapsedValue` creation, around line 43): + +```csharp +this.descriptionLabel = new System.Windows.Forms.Label(); +this.descriptionValue = new System.Windows.Forms.Label(); +``` + +Add the description row to the table. Exact changes: +- Line 71: Change `this.table.RowCount = 6;` to `this.table.RowCount = 7;` +- After line 77 (last `RowStyles.Add`), add: `this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize));` +- After line 66 (elapsedValue table.Controls.Add), add the description controls to the table +- Line 197: Change `this.ClientSize = new System.Drawing.Size(264, 207);` to `this.ClientSize = new System.Drawing.Size(264, 230);` (taller to fit the new row) + +Add table controls (after the elapsed row controls): +```csharp +this.table.Controls.Add(this.descriptionLabel, 0, 6); +this.table.Controls.Add(this.descriptionValue, 1, 6); +``` + +Configure the labels (in the label configuration section, after elapsedValue config): +```csharp +// descriptionLabel +this.descriptionLabel.AutoSize = true; +this.descriptionLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); +this.descriptionLabel.Margin = new System.Windows.Forms.Padding(4); +this.descriptionLabel.Name = "descriptionLabel"; +this.descriptionLabel.Text = "Detail:"; +// descriptionValue +this.descriptionValue.AutoSize = true; +this.descriptionValue.Margin = new System.Windows.Forms.Padding(4); +this.descriptionValue.Name = "descriptionValue"; +this.descriptionValue.Text = "\u2014"; +``` + +Add field declarations after `elapsedValue` (line 230), before `stopButton` (line 231): + +- [ ] **Step 2: Display `Description` in `UpdateProgress`** + +In `NestProgressForm.cs`, in the `UpdateProgress` method (line 30), add after the existing updates: + +```csharp +if (!string.IsNullOrEmpty(progress.Description)) + descriptionValue.Text = progress.Description; +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest/Forms/NestProgressForm.cs OpenNest/Forms/NestProgressForm.Designer.cs +git commit -m "feat(ui): add description row to nest progress form" +``` + +--- + +## Chunk 4: ONNX Inference Scaffolding + +### Task 8: Add `Microsoft.ML.OnnxRuntime` NuGet package + +**Files:** +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +- [ ] **Step 1: Add the package reference** + +Add to `OpenNest.Engine/OpenNest.Engine.csproj` inside the existing `` (or create a new one for packages): + +```xml + + + +``` + +- [ ] **Step 2: Restore and build** + +Run: `dotnet restore OpenNest.Engine && dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/OpenNest.Engine.csproj +git commit -m "chore(engine): add Microsoft.ML.OnnxRuntime package" +``` + +--- + +### Task 9: Create `AnglePredictor` with ONNX inference + +**Files:** +- Create: `OpenNest.Engine/ML/AnglePredictor.cs` + +- [ ] **Step 1: Create the predictor class** + +Create `OpenNest.Engine/ML/AnglePredictor.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; +using OpenNest.Math; + +namespace OpenNest.Engine.ML +{ + public static class AnglePredictor + { + private static InferenceSession _session; + private static bool _loadAttempted; + private static readonly object _lock = new(); + + public static List PredictAngles( + PartFeatures features, double sheetWidth, double sheetHeight, + double threshold = 0.3) + { + var session = GetSession(); + if (session == null) + return null; + + try + { + var input = new float[11]; + input[0] = (float)features.Area; + input[1] = (float)features.Convexity; + input[2] = (float)features.AspectRatio; + input[3] = (float)features.BoundingBoxFill; + input[4] = (float)features.Circularity; + input[5] = (float)features.PerimeterToAreaRatio; + input[6] = features.VertexCount; + input[7] = (float)sheetWidth; + input[8] = (float)sheetHeight; + input[9] = (float)(sheetWidth / (sheetHeight > 0 ? sheetHeight : 1.0)); + input[10] = (float)(features.Area / (sheetWidth * sheetHeight)); + + var tensor = new DenseTensor(input, new[] { 1, 11 }); + var inputs = new List + { + NamedOnnxValue.CreateFromTensor("features", tensor) + }; + + using var results = session.Run(inputs); + var probabilities = results.First().AsEnumerable().ToArray(); + + var angles = new List<(double angleDeg, float prob)>(); + for (var i = 0; i < 36 && i < probabilities.Length; i++) + { + if (probabilities[i] >= threshold) + angles.Add((i * 5.0, probabilities[i])); + } + + // Minimum 3 angles — take top by probability if fewer pass threshold. + if (angles.Count < 3) + { + angles = probabilities + .Select((p, i) => (angleDeg: i * 5.0, prob: p)) + .OrderByDescending(x => x.prob) + .Take(3) + .ToList(); + } + + // Always include 0 and 90 as safety fallback. + var result = angles.Select(a => Angle.ToRadians(a.angleDeg)).ToList(); + + if (!result.Any(a => a.IsEqualTo(0))) + result.Add(0); + if (!result.Any(a => a.IsEqualTo(Angle.HalfPI))) + result.Add(Angle.HalfPI); + + return result; + } + catch (Exception ex) + { + Debug.WriteLine($"[AnglePredictor] Inference failed: {ex.Message}"); + return null; + } + } + + private static InferenceSession GetSession() + { + if (_loadAttempted) + return _session; + + lock (_lock) + { + if (_loadAttempted) + return _session; + + _loadAttempted = true; + + try + { + var dir = Path.GetDirectoryName(typeof(AnglePredictor).Assembly.Location); + var modelPath = Path.Combine(dir, "Models", "angle_predictor.onnx"); + + if (!File.Exists(modelPath)) + { + Debug.WriteLine($"[AnglePredictor] Model not found: {modelPath}"); + return null; + } + + _session = new InferenceSession(modelPath); + Debug.WriteLine("[AnglePredictor] Model loaded successfully"); + } + catch (Exception ex) + { + Debug.WriteLine($"[AnglePredictor] Failed to load model: {ex.Message}"); + } + + return _session; + } + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded (model file doesn't exist yet — that's expected, `GetSession` returns null gracefully) + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/ML/AnglePredictor.cs +git commit -m "feat(engine): add AnglePredictor ONNX inference class" +``` + +--- + +### Task 10: Wire `AnglePredictor` into `NestEngine.FindBestFill` + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +- [ ] **Step 1: Add ML angle prediction to the progress overload** + +In `FindBestFill` (progress overload), after the narrow-work-area angle expansion block and after the `ForceFullAngleSweep` block, add ML prediction logic. This replaces the full sweep when the model is available: + +```csharp +// When the work area triggers a full sweep (and we're not forcing it for training), +// try ML angle prediction to reduce the sweep. +if (!ForceFullAngleSweep && angles.Count > 2) +{ + var features = FeatureExtractor.Extract(item.Drawing); + if (features != null) + { + var predicted = AnglePredictor.PredictAngles( + features, workArea.Width, workArea.Length); + + if (predicted != null) + { + // Use predicted angles, but always keep bestRotation and bestRotation + 90. + var mlAngles = new List(predicted); + + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation))) + mlAngles.Add(bestRotation); + if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI))) + mlAngles.Add(bestRotation + Angle.HalfPI); + + Debug.WriteLine($"[FindBestFill] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); + angles = mlAngles; + } + } +} +``` + +Add `using OpenNest.Engine.ML;` at the top of the file if not already present. + +- [ ] **Step 2: Apply the same pattern to the non-progress overload** + +Add the identical ML prediction block to `FindBestFill(NestItem item, Box workArea)` after its `ForceFullAngleSweep` block. + +- [ ] **Step 3: Build the full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): integrate AnglePredictor into FindBestFill angle selection" +``` + +--- + +## Chunk 5: Training Notebook Scaffolding + +### Task 11: Create Python training notebook and requirements + +**Files:** +- Create: `OpenNest.Training/notebooks/requirements.txt` +- Create: `OpenNest.Training/notebooks/train_angle_model.ipynb` + +- [ ] **Step 1: Create requirements.txt** + +Create `OpenNest.Training/notebooks/requirements.txt`: + +``` +pandas>=2.0 +scikit-learn>=1.3 +xgboost>=2.0 +onnxmltools>=1.12 +skl2onnx>=1.16 +matplotlib>=3.7 +jupyter>=1.0 +``` + +- [ ] **Step 2: Create training notebook skeleton** + +Create `OpenNest.Training/notebooks/train_angle_model.ipynb` as a Jupyter notebook with the following cells: + +Cell 1 (markdown): +``` +# Angle Prediction Model Training +Trains an XGBoost multi-label classifier to predict which rotation angles are competitive for a given part geometry and sheet size. + +**Input:** SQLite database from OpenNest.Training data collection runs +**Output:** `angle_predictor.onnx` model file for `OpenNest.Engine/Models/` +``` + +Cell 2 (code): imports and setup +```python +import sqlite3 +import pandas as pd +import numpy as np +from pathlib import Path + +DB_PATH = "../OpenNestTraining.db" # Adjust to your database location +OUTPUT_PATH = "../../OpenNest.Engine/Models/angle_predictor.onnx" +COMPETITIVE_THRESHOLD = 0.95 # Angle is "competitive" if >= 95% of best +``` + +Cell 3 (code): data extraction +```python +# Extract training data from SQLite +conn = sqlite3.connect(DB_PATH) + +query = """ +SELECT + p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity, + p.PerimeterToAreaRatio, p.VertexCount, + r.SheetWidth, r.SheetHeight, r.Id as RunId, + a.AngleDeg, a.Direction, a.PartCount +FROM AngleResults a +JOIN Runs r ON a.RunId = r.Id +JOIN Parts p ON r.PartId = p.Id +WHERE a.PartCount > 0 +""" + +df = pd.read_sql_query(query, conn) +conn.close() + +print(f"Loaded {len(df)} angle result rows") +print(f"Unique runs: {df['RunId'].nunique()}") +print(f"Angle range: {df['AngleDeg'].min()}-{df['AngleDeg'].max()}") +``` + +Cell 4 (code): label generation +```python +# For each run, find best PartCount (max of H and V per angle), +# then label angles within 95% of best as positive. + +# Best count per angle per run (max of H and V) +angle_best = df.groupby(['RunId', 'AngleDeg'])['PartCount'].max().reset_index() +angle_best.columns = ['RunId', 'AngleDeg', 'BestCount'] + +# Best count per run (overall best angle) +run_best = angle_best.groupby('RunId')['BestCount'].max().reset_index() +run_best.columns = ['RunId', 'RunBest'] + +# Merge and compute labels +labels = angle_best.merge(run_best, on='RunId') +labels['IsCompetitive'] = (labels['BestCount'] >= labels['RunBest'] * COMPETITIVE_THRESHOLD).astype(int) + +# Pivot to 36-column binary label matrix +label_matrix = labels.pivot_table( + index='RunId', columns='AngleDeg', values='IsCompetitive', fill_value=0 +) + +# Ensure all 36 angle columns exist (0, 5, 10, ..., 175) +all_angles = [i * 5 for i in range(36)] +for a in all_angles: + if a not in label_matrix.columns: + label_matrix[a] = 0 +label_matrix = label_matrix[all_angles] + +print(f"Label matrix: {label_matrix.shape}") +print(f"Average competitive angles per run: {label_matrix.sum(axis=1).mean():.1f}") +``` + +Cell 5 (code): feature engineering +```python +# Build feature matrix — one row per run +features_query = """ +SELECT DISTINCT + r.Id as RunId, p.FileName, + p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity, + p.PerimeterToAreaRatio, p.VertexCount, + r.SheetWidth, r.SheetHeight +FROM Runs r +JOIN Parts p ON r.PartId = p.Id +WHERE r.Id IN ({}) +""".format(','.join(str(x) for x in label_matrix.index)) + +conn = sqlite3.connect(DB_PATH) +features_df = pd.read_sql_query(features_query, conn) +conn.close() + +features_df = features_df.set_index('RunId') + +# Derived features +features_df['SheetAspectRatio'] = features_df['SheetWidth'] / features_df['SheetHeight'] +features_df['PartToSheetAreaRatio'] = features_df['Area'] / (features_df['SheetWidth'] * features_df['SheetHeight']) + +# Filter outliers (title blocks, etc.) +mask = (features_df['BBFill'] >= 0.01) & (features_df['Area'] > 0.1) +print(f"Filtering: {(~mask).sum()} outlier runs removed") +features_df = features_df[mask] +label_matrix = label_matrix.loc[features_df.index] + +feature_cols = ['Area', 'Convexity', 'AspectRatio', 'BBFill', 'Circularity', + 'PerimeterToAreaRatio', 'VertexCount', + 'SheetWidth', 'SheetHeight', 'SheetAspectRatio', 'PartToSheetAreaRatio'] + +X = features_df[feature_cols].values +y = label_matrix.values + +print(f"Features: {X.shape}, Labels: {y.shape}") +``` + +Cell 6 (code): train/test split and training +```python +from sklearn.model_selection import GroupShuffleSplit +from sklearn.multioutput import MultiOutputClassifier +import xgboost as xgb + +# Split by part (all sheet sizes for a part stay in the same split) +groups = features_df['FileName'] +splitter = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) +train_idx, test_idx = next(splitter.split(X, y, groups)) + +X_train, X_test = X[train_idx], X[test_idx] +y_train, y_test = y[train_idx], y[test_idx] + +print(f"Train: {len(train_idx)}, Test: {len(test_idx)}") + +# Train XGBoost multi-label classifier +base_clf = xgb.XGBClassifier( + n_estimators=200, + max_depth=6, + learning_rate=0.1, + use_label_encoder=False, + eval_metric='logloss', + random_state=42 +) + +clf = MultiOutputClassifier(base_clf, n_jobs=-1) +clf.fit(X_train, y_train) +print("Training complete") +``` + +Cell 7 (code): evaluation +```python +from sklearn.metrics import recall_score, precision_score +import matplotlib.pyplot as plt + +y_pred = clf.predict(X_test) +y_prob = np.array([est.predict_proba(X_test)[:, 1] for est in clf.estimators_]).T + +# Per-angle metrics +recalls = [] +precisions = [] +for i in range(36): + if y_test[:, i].sum() > 0: + recalls.append(recall_score(y_test[:, i], y_pred[:, i], zero_division=0)) + precisions.append(precision_score(y_test[:, i], y_pred[:, i], zero_division=0)) + +print(f"Mean recall: {np.mean(recalls):.3f}") +print(f"Mean precision: {np.mean(precisions):.3f}") + +# Average angles predicted per run +avg_predicted = y_pred.sum(axis=1).mean() +print(f"Avg angles predicted per run: {avg_predicted:.1f}") + +# Plot +fig, axes = plt.subplots(1, 2, figsize=(12, 4)) +axes[0].bar(range(len(recalls)), recalls) +axes[0].set_title('Recall per Angle Bin') +axes[0].set_xlabel('Angle (5-deg bins)') +axes[0].axhline(y=0.95, color='r', linestyle='--', label='Target 95%') +axes[0].legend() + +axes[1].bar(range(len(precisions)), precisions) +axes[1].set_title('Precision per Angle Bin') +axes[1].set_xlabel('Angle (5-deg bins)') +axes[1].axhline(y=0.60, color='r', linestyle='--', label='Target 60%') +axes[1].legend() + +plt.tight_layout() +plt.show() +``` + +Cell 8 (code): export to ONNX +```python +from skl2onnx import convert_sklearn +from skl2onnx.common.data_types import FloatTensorType +from pathlib import Path + +initial_type = [('features', FloatTensorType([None, 11]))] +onnx_model = convert_sklearn(clf, initial_types=initial_type) + +output_path = Path(OUTPUT_PATH) +output_path.parent.mkdir(parents=True, exist_ok=True) + +with open(output_path, 'wb') as f: + f.write(onnx_model.SerializeToString()) + +print(f"Model saved to {output_path} ({output_path.stat().st_size / 1024:.0f} KB)") +``` + +- [ ] **Step 3: Create the `Models` directory placeholder** + +Run: `mkdir -p OpenNest.Engine/Models` + +Create `OpenNest.Engine/Models/.gitkeep` (empty file to track the directory). + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Training/notebooks/ OpenNest.Engine/Models/.gitkeep +git commit -m "feat(training): add training notebook skeleton and requirements" +``` + +--- + +## Summary + +| Chunk | Tasks | Purpose | +|-------|-------|---------| +| 1 | 1-3 | Engine instrumentation: `AngleResult`, `ForceFullAngleSweep`, per-angle collection | +| 2 | 4-6 | Training DB: `AngleResults` table, migration, runner wiring | +| 3 | 7 | Progress window: `Description` display | +| 4 | 8-10 | ONNX inference: `AnglePredictor` class, NuGet package, `FindBestFill` integration | +| 5 | 11 | Python notebook: training pipeline skeleton | + +**Dependency order:** Chunks 1-2 must be sequential (2 depends on 1). Chunks 3, 4, 5 are independent of each other and can be done in parallel after Chunk 1. + +**After this plan:** Run training data collection with `--force-sweep` (the existing training runner + new angle collection). Once data exists, run the notebook to train and export the ONNX model. The engine will automatically use it once `angle_predictor.onnx` is placed in the `Models/` directory. diff --git a/docs/superpowers/plans/2026-03-15-fill-exact.md b/docs/superpowers/plans/2026-03-15-fill-exact.md new file mode 100644 index 0000000..addf0b3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-fill-exact.md @@ -0,0 +1,462 @@ +# FillExact Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `FillExact` method to `NestEngine` that binary-searches for the smallest work area sub-region that fits an exact quantity of parts, then integrate it into AutoNest. + +**Architecture:** `FillExact` wraps the existing `Fill(NestItem, Box, IProgress, CancellationToken)` method. It calls Fill repeatedly with progressively smaller test boxes (binary search on one dimension, both orientations), picks the tightest fit, then re-runs the winner with progress reporting. Callers swap `Fill` for `FillExact` — no other engine changes needed. + +**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest (WinForms), OpenNest.Console, OpenNest.Mcp + +**Spec:** `docs/superpowers/specs/2026-03-15-fill-exact-design.md` + +--- + +## Chunk 1: Core Implementation + +### Task 1: Add `BinarySearchFill` helper to NestEngine + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` (add private method after the existing `Fill` overloads, around line 85) + +- [ ] **Step 1: Add the BinarySearchFill method** + +Add after the `Fill(NestItem, Box, IProgress, CancellationToken)` method (line 85): + +```csharp +/// +/// Binary-searches for the smallest sub-area (one dimension fixed) that fits +/// exactly item.Quantity parts. Returns the best parts list and the dimension +/// value that achieved it. +/// +private (List parts, double usedDim) BinarySearchFill( + NestItem item, Box workArea, bool shrinkWidth, + CancellationToken token) +{ + var quantity = item.Quantity; + var partBox = item.Drawing.Program.BoundingBox(); + var partArea = item.Drawing.Area; + + // Fixed and variable dimensions. + var fixedDim = shrinkWidth ? workArea.Length : workArea.Width; + var highDim = shrinkWidth ? workArea.Width : workArea.Length; + + // Estimate starting point: target area at 50% utilization. + var targetArea = partArea * quantity / 0.5; + var minPartDim = shrinkWidth + ? partBox.Width + Plate.PartSpacing + : partBox.Length + Plate.PartSpacing; + var estimatedDim = System.Math.Max(minPartDim, targetArea / fixedDim); + + var low = estimatedDim; + var high = highDim; + + List bestParts = null; + var bestDim = high; + + for (var iter = 0; iter < 8; iter++) + { + if (token.IsCancellationRequested) + break; + + if (high - low < Plate.PartSpacing) + break; + + var mid = (low + high) / 2.0; + + var testBox = shrinkWidth + ? new Box(workArea.X, workArea.Y, mid, workArea.Length) + : new Box(workArea.X, workArea.Y, workArea.Width, mid); + + var result = Fill(item, testBox, null, token); + + if (result.Count >= quantity) + { + bestParts = result.Count > quantity + ? result.Take(quantity).ToList() + : result; + bestDim = mid; + high = mid; + } + else + { + low = mid; + } + } + + return (bestParts, bestDim); +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): add BinarySearchFill helper for exact-quantity search" +``` + +--- + +### Task 2: Add `FillExact` public method to NestEngine + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` (add public method after the existing `Fill` overloads, before `BinarySearchFill`) + +- [ ] **Step 1: Add the FillExact method** + +Add between the `Fill(NestItem, Box, IProgress, CancellationToken)` method and `BinarySearchFill`: + +```csharp +/// +/// Finds the smallest sub-area of workArea that fits exactly item.Quantity parts. +/// Uses binary search on both orientations and picks the tightest fit. +/// Falls through to standard Fill for unlimited (0) or single (1) quantities. +/// +public List FillExact(NestItem item, Box workArea, + IProgress progress, CancellationToken token) +{ + // Early exits: unlimited or single quantity — no benefit from area search. + if (item.Quantity <= 1) + return Fill(item, workArea, progress, token); + + // Full fill to establish upper bound. + var fullResult = Fill(item, workArea, progress, token); + + if (fullResult.Count <= item.Quantity) + return fullResult; + + // Binary search: try shrinking each dimension. + var (lengthParts, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token); + var (widthParts, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token); + + // Pick winner by smallest test box area. Tie-break: prefer shrink-length. + List winner; + Box winnerBox; + + var lengthArea = lengthParts != null ? workArea.Width * lengthDim : double.MaxValue; + var widthArea = widthParts != null ? widthDim * workArea.Length : double.MaxValue; + + if (lengthParts != null && lengthArea <= widthArea) + { + winner = lengthParts; + winnerBox = new Box(workArea.X, workArea.Y, workArea.Width, lengthDim); + } + else if (widthParts != null) + { + winner = widthParts; + winnerBox = new Box(workArea.X, workArea.Y, widthDim, workArea.Length); + } + else + { + // Neither search found the exact quantity — return full fill truncated. + return fullResult.Take(item.Quantity).ToList(); + } + + // Re-run the winner with progress so PhaseResults/WinnerPhase are correct + // and the progress form shows the final result. + var finalResult = Fill(item, winnerBox, progress, token); + + if (finalResult.Count >= item.Quantity) + return finalResult.Count > item.Quantity + ? finalResult.Take(item.Quantity).ToList() + : finalResult; + + // Fallback: return the binary search result if the re-run produced fewer. + return winner; +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): add FillExact method for exact-quantity nesting" +``` + +--- + +### Task 3: Add Compactor class to Engine + +**Files:** +- Create: `OpenNest.Engine/Compactor.cs` + +- [ ] **Step 1: Create the Compactor class** + +Create `OpenNest.Engine/Compactor.cs`: + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// Pushes a group of parts left and down to close gaps after placement. + /// Uses the same directional-distance logic as PlateView.PushSelected + /// but operates on Part objects directly. + /// + public static class Compactor + { + private const double ChordTolerance = 0.001; + + /// + /// Compacts movingParts toward the bottom-left of the plate work area. + /// Everything already on the plate (excluding movingParts) is treated + /// as stationary obstacles. + /// + public static void Compact(List movingParts, Plate plate) + { + if (movingParts == null || movingParts.Count == 0) + return; + + Push(movingParts, plate, PushDirection.Left); + Push(movingParts, plate, PushDirection.Down); + } + + private static void Push(List movingParts, Plate plate, PushDirection direction) + { + var stationaryParts = plate.Parts + .Where(p => !movingParts.Contains(p)) + .ToList(); + + var stationaryBoxes = new Box[stationaryParts.Count]; + + for (var i = 0; i < stationaryParts.Count; i++) + stationaryBoxes[i] = stationaryParts[i].BoundingBox; + + var stationaryLines = new List[stationaryParts.Count]; + var opposite = Helper.OppositeDirection(direction); + var halfSpacing = plate.PartSpacing / 2; + var isHorizontal = Helper.IsHorizontalDirection(direction); + var workArea = plate.WorkArea(); + + foreach (var moving in movingParts) + { + var distance = double.MaxValue; + var movingBox = moving.BoundingBox; + + // Plate edge distance. + var edgeDist = Helper.EdgeDistance(movingBox, workArea, direction); + if (edgeDist > 0 && edgeDist < distance) + distance = edgeDist; + + List movingLines = null; + + for (var i = 0; i < stationaryBoxes.Length; i++) + { + var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction); + if (gap < 0 || gap >= distance) + continue; + + var perpOverlap = isHorizontal + ? movingBox.IsHorizontalTo(stationaryBoxes[i], out _) + : movingBox.IsVerticalTo(stationaryBoxes[i], out _); + + if (!perpOverlap) + continue; + + movingLines ??= halfSpacing > 0 + ? Helper.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance) + : Helper.GetPartLines(moving, direction, ChordTolerance); + + stationaryLines[i] ??= halfSpacing > 0 + ? Helper.GetOffsetPartLines(stationaryParts[i], halfSpacing, opposite, ChordTolerance) + : Helper.GetPartLines(stationaryParts[i], opposite, ChordTolerance); + + var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction); + if (d < distance) + distance = d; + } + + if (distance < double.MaxValue && distance > 0) + { + var offset = Helper.DirectionToOffset(direction, distance); + moving.Offset(offset); + + // Update this part's bounding box in the stationary set for + // subsequent moving parts to collide against correctly. + // (Parts already pushed become obstacles for the next part.) + } + } + } + } +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/Compactor.cs +git commit -m "feat(engine): add Compactor for post-fill gravity compaction" +``` + +--- + +## Chunk 2: Integration + +### Task 4: Integrate FillExact and Compactor into AutoNest (MainForm) + +**Files:** +- Modify: `OpenNest/Forms/MainForm.cs` (RunAutoNest_Click, around lines 797-815) + +- [ ] **Step 1: Replace Fill with FillExact and add Compactor call** + +In `RunAutoNest_Click`, change the Fill call and the block after it (around lines 799-815). Replace: + +```csharp + var parts = await Task.Run(() => + engine.Fill(item, workArea, progress, token)); +``` + +with: + +```csharp + var parts = await Task.Run(() => + engine.FillExact(item, workArea, progress, token)); +``` + +Then after `plate.Parts.AddRange(parts);` and before `ComputeRemainderStrip`, add the compaction call: + +```csharp + plate.Parts.AddRange(parts); + Compactor.Compact(parts, plate); + activeForm.PlateView.Invalidate(); +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.sln --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest/Forms/MainForm.cs +git commit -m "feat(ui): use FillExact + Compactor in AutoNest" +``` + +--- + +### Task 5: Integrate FillExact and Compactor into Console app + +**Files:** +- Modify: `OpenNest.Console/Program.cs` (around lines 346-360) + +- [ ] **Step 1: Replace Fill with FillExact and add Compactor call** + +Change the Fill call (around line 352) from: + +```csharp + var parts = engine.Fill(item, workArea, null, CancellationToken.None); +``` + +to: + +```csharp + var parts = engine.FillExact(item, workArea, null, CancellationToken.None); +``` + +Then after `plate.Parts.AddRange(parts);` add the compaction call: + +```csharp + plate.Parts.AddRange(parts); + Compactor.Compact(parts, plate); + item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.Console/OpenNest.Console.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Console/Program.cs +git commit -m "feat(console): use FillExact + Compactor in --autonest" +``` + +--- + +### Task 6: Integrate FillExact and Compactor into MCP server + +**Files:** +- Modify: `OpenNest.Mcp/Tools/NestingTools.cs` (around lines 255-264) + +- [ ] **Step 1: Replace Fill with FillExact and add Compactor call** + +Change the Fill call (around line 256) from: + +```csharp + var parts = engine.Fill(item, workArea, null, CancellationToken.None); +``` + +to: + +```csharp + var parts = engine.FillExact(item, workArea, null, CancellationToken.None); +``` + +Then after `plate.Parts.AddRange(parts);` add the compaction call: + +```csharp + plate.Parts.AddRange(parts); + Compactor.Compact(parts, plate); + item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Mcp/Tools/NestingTools.cs +git commit -m "feat(mcp): use FillExact in autonest_plate for tighter packing" +``` + +--- + +## Chunk 3: Verification + +### Task 7: End-to-end test via Console + +- [ ] **Step 1: Run AutoNest with qty > 1 and verify tighter packing** + +Run: `dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- --autonest --quantity 10 --no-save "C:\Users\AJ\Desktop\N0312-002.zip"` + +Verify: +- Completes without error +- Parts placed count is reasonable (not 0, not wildly over-placed) +- Utilization is reported + +- [ ] **Step 2: Run with qty=1 to verify fallback path** + +Run: `dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- --autonest --no-save "C:\Users\AJ\Desktop\N0312-002.zip"` + +Verify: +- Completes quickly (qty=1 goes through Pack, no binary search) +- Parts placed > 0 + +- [ ] **Step 3: Build full solution one final time** + +Run: `dotnet build OpenNest.sln --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` diff --git a/docs/superpowers/plans/2026-03-15-helper-decomposition.md b/docs/superpowers/plans/2026-03-15-helper-decomposition.md new file mode 100644 index 0000000..42d4acd --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-helper-decomposition.md @@ -0,0 +1,350 @@ +# Helper Class Decomposition + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Break the 1,464-line `Helper` catch-all class into focused, single-responsibility static classes. + +**Architecture:** Extract six logical groups from `Helper` into dedicated classes. Each extraction creates a new file, moves methods, updates all call sites, and verifies with `dotnet build`. The original `Helper.cs` is deleted once empty. No behavioral changes — pure mechanical refactoring. + +**Tech Stack:** .NET 8, C# 12 + +--- + +## File Structure + +| New File | Namespace | Responsibility | Methods Moved | +|----------|-----------|----------------|---------------| +| `OpenNest.Core/Math/Rounding.cs` | `OpenNest.Math` | Factor-based rounding | `RoundDownToNearest`, `RoundUpToNearest`, `RoundToNearest` | +| `OpenNest.Core/Geometry/GeometryOptimizer.cs` | `OpenNest.Geometry` | Merge collinear lines / coradial arcs | `Optimize(arcs)`, `Optimize(lines)`, `TryJoinLines`, `TryJoinArcs`, `GetCollinearLines`, `GetCoradialArs` | +| `OpenNest.Core/Geometry/ShapeBuilder.cs` | `OpenNest.Geometry` | Chain entities into shapes | `GetShapes`, `GetConnected` | +| `OpenNest.Core/Geometry/Intersect.cs` | `OpenNest.Geometry` | All intersection algorithms | 16 `Intersects` overloads | +| `OpenNest.Core/PartGeometry.cs` | `OpenNest` | Convert Parts to line geometry | `GetPartLines` (×2), `GetOffsetPartLines` (×2), `GetDirectionalLines` | +| `OpenNest.Core/Geometry/SpatialQuery.cs` | `OpenNest.Geometry` | Directional distance, ray casting, box queries | `RayEdgeDistance` (×2), `DirectionalDistance` (×3), `FlattenLines`, `OneWayDistance`, `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionToOffset`, `DirectionalGap`, `ClosestDistance*` (×4), `GetLargestBox*` (×2) | + +**Files modified (call-site updates):** + +| File | Methods Referenced | +|------|--------------------| +| `OpenNest.Core/Plate.cs` | `RoundUpToNearest` → `Rounding.RoundUpToNearest` | +| `OpenNest.IO/DxfImporter.cs` | `Optimize` → `GeometryOptimizer.Optimize` | +| `OpenNest.Core/Geometry/Shape.cs` | `Optimize` → `GeometryOptimizer.Optimize`, `Intersects` → `Intersect.Intersects` | +| `OpenNest.Core/Drawing.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Core/Timing.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Core/Converters/ConvertGeometry.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Core/Geometry/ShapeProfile.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Core/Geometry/Arc.cs` | `Intersects` → `Intersect.Intersects` | +| `OpenNest.Core/Geometry/Circle.cs` | `Intersects` → `Intersect.Intersects` | +| `OpenNest.Core/Geometry/Line.cs` | `Intersects` → `Intersect.Intersects` | +| `OpenNest.Core/Geometry/Polygon.cs` | `Intersects` → `Intersect.Intersects` | +| `OpenNest/LayoutPart.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest/Actions/ActionSetSequence.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest/Actions/ActionSelectArea.cs` | `GetLargestBox*` → `SpatialQuery.GetLargestBox*` | +| `OpenNest/Actions/ActionClone.cs` | `GetLargestBox*` → `SpatialQuery.GetLargestBox*` | +| `OpenNest.Gpu/PartBitmap.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Gpu/GpuPairEvaluator.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Engine/RotationAnalysis.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Engine/BestFit/BestFitFinder.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Engine/BestFit/PairEvaluator.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Engine/FillLinear.cs` | `DirectionalDistance`, `OppositeDirection` → `SpatialQuery.*` | +| `OpenNest.Engine/Compactor.cs` | Multiple `Helper.*` → `SpatialQuery.*` + `PartGeometry.*` | +| `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` | Multiple `Helper.*` → `SpatialQuery.*` + `PartGeometry.*` | + +--- + +## Chunk 1: Rounding + GeometryOptimizer + ShapeBuilder + +### Task 1: Extract Rounding to OpenNest.Math + +**Files:** +- Create: `OpenNest.Core/Math/Rounding.cs` +- Modify: `OpenNest.Core/Plate.cs:415-416` +- Delete from: `OpenNest.Core/Helper.cs` (lines 14–45) + +- [ ] **Step 1: Create `Rounding.cs`** + +```csharp +using OpenNest.Math; + +namespace OpenNest.Math +{ + public static class Rounding + { + public static double RoundDownToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor; + } + + public static double RoundUpToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor; + } + + public static double RoundToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor; + } + } +} +``` + +- [ ] **Step 2: Update call site in `Plate.cs`** + +Replace `Helper.RoundUpToNearest` with `Rounding.RoundUpToNearest`. Add `using OpenNest.Math;` if not present. + +- [ ] **Step 3: Remove three rounding methods from `Helper.cs`** + +Delete lines 14–45 (the three methods and their XML doc comments). + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 5: Commit** + +``` +refactor: extract Rounding from Helper to OpenNest.Math +``` + +--- + +### Task 2: Extract GeometryOptimizer + +**Files:** +- Create: `OpenNest.Core/Geometry/GeometryOptimizer.cs` +- Modify: `OpenNest.IO/DxfImporter.cs:59-60`, `OpenNest.Core/Geometry/Shape.cs:162-163` +- Delete from: `OpenNest.Core/Helper.cs` (lines 47–237) + +- [ ] **Step 1: Create `GeometryOptimizer.cs`** + +Move these 6 methods (preserving exact code): +- `Optimize(IList)` +- `Optimize(IList)` +- `TryJoinLines` +- `TryJoinArcs` +- `GetCollinearLines` (private extension method) +- `GetCoradialArs` (private extension method) + +Namespace: `OpenNest.Geometry`. Class: `public static class GeometryOptimizer`. + +Required usings: `System`, `System.Collections.Generic`, `System.Threading.Tasks`, `OpenNest.Math`. + +- [ ] **Step 2: Update call sites** + +- `DxfImporter.cs`: `Helper.Optimize(...)` → `GeometryOptimizer.Optimize(...)`. Add `using OpenNest.Geometry;`. +- `Shape.cs`: `Helper.Optimize(...)` → `GeometryOptimizer.Optimize(...)`. Already in `OpenNest.Geometry` namespace — no using needed. + +- [ ] **Step 3: Remove methods from `Helper.cs`** + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` + +- [ ] **Step 5: Commit** + +``` +refactor: extract GeometryOptimizer from Helper +``` + +--- + +### Task 3: Extract ShapeBuilder + +**Files:** +- Create: `OpenNest.Core/Geometry/ShapeBuilder.cs` +- Modify: 11 files (see call-site table above for `GetShapes` callers) +- Delete from: `OpenNest.Core/Helper.cs` (lines 239–378) + +- [ ] **Step 1: Create `ShapeBuilder.cs`** + +Move these 2 methods: +- `GetShapes(IEnumerable)` — public +- `GetConnected(Vector, IEnumerable)` — internal + +Namespace: `OpenNest.Geometry`. Class: `public static class ShapeBuilder`. + +Required usings: `System.Collections.Generic`, `System.Diagnostics`, `OpenNest.Math`. + +- [ ] **Step 2: Update all call sites** + +Replace `Helper.GetShapes` → `ShapeBuilder.GetShapes` in every file. Add `using OpenNest.Geometry;` where the file isn't already in that namespace. + +Files to update: +- `OpenNest.Core/Drawing.cs` +- `OpenNest.Core/Timing.cs` +- `OpenNest.Core/Converters/ConvertGeometry.cs` +- `OpenNest.Core/Geometry/ShapeProfile.cs` (already in namespace) +- `OpenNest/LayoutPart.cs` +- `OpenNest/Actions/ActionSetSequence.cs` +- `OpenNest.Gpu/PartBitmap.cs` +- `OpenNest.Gpu/GpuPairEvaluator.cs` +- `OpenNest.Engine/RotationAnalysis.cs` +- `OpenNest.Engine/BestFit/BestFitFinder.cs` +- `OpenNest.Engine/BestFit/PairEvaluator.cs` + +- [ ] **Step 3: Remove methods from `Helper.cs`** + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` + +- [ ] **Step 5: Commit** + +``` +refactor: extract ShapeBuilder from Helper +``` + +--- + +## Chunk 2: Intersect + PartGeometry + +### Task 4: Extract Intersect + +**Files:** +- Create: `OpenNest.Core/Geometry/Intersect.cs` +- Modify: `Arc.cs`, `Circle.cs`, `Line.cs`, `Shape.cs`, `Polygon.cs` (all in `OpenNest.Core/Geometry/`) +- Delete from: `OpenNest.Core/Helper.cs` (lines 380–742) + +- [ ] **Step 1: Create `Intersect.cs`** + +Move all 16 `Intersects` overloads. Namespace: `OpenNest.Geometry`. Class: `public static class Intersect`. + +All methods keep their existing access modifiers (`internal` for most, none are `public`). + +Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`. + +- [ ] **Step 2: Update call sites in geometry types** + +All callers are in the same namespace (`OpenNest.Geometry`) so no using changes needed. Replace `Helper.Intersects` → `Intersect.Intersects` in: +- `Arc.cs` (10 calls) +- `Circle.cs` (10 calls) +- `Line.cs` (8 calls) +- `Shape.cs` (12 calls, including the internal offset usage at line 537) +- `Polygon.cs` (10 calls) + +- [ ] **Step 3: Remove methods from `Helper.cs`** + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` + +- [ ] **Step 5: Commit** + +``` +refactor: extract Intersect from Helper +``` + +--- + +### Task 5: Extract PartGeometry + +**Files:** +- Create: `OpenNest.Core/PartGeometry.cs` +- Modify: `OpenNest.Engine/Compactor.cs`, `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` +- Delete from: `OpenNest.Core/Helper.cs` (lines 744–858) + +- [ ] **Step 1: Create `PartGeometry.cs`** + +Move these 5 methods: +- `GetPartLines(Part, double)` — public +- `GetPartLines(Part, PushDirection, double)` — public +- `GetOffsetPartLines(Part, double, double)` — public +- `GetOffsetPartLines(Part, double, PushDirection, double)` — public +- `GetDirectionalLines(Polygon, PushDirection)` — private + +Namespace: `OpenNest`. Class: `public static class PartGeometry`. + +Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Converters`, `OpenNest.Geometry`. + +- [ ] **Step 2: Update call sites** + +- `Compactor.cs`: `Helper.GetOffsetPartLines` / `Helper.GetPartLines` → `PartGeometry.*` +- `RotationSlideStrategy.cs`: `Helper.GetOffsetPartLines` → `PartGeometry.GetOffsetPartLines` + +- [ ] **Step 3: Remove methods from `Helper.cs`** + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` + +- [ ] **Step 5: Commit** + +``` +refactor: extract PartGeometry from Helper +``` + +--- + +## Chunk 3: SpatialQuery + Cleanup + +### Task 6: Extract SpatialQuery + +**Files:** +- Create: `OpenNest.Core/Geometry/SpatialQuery.cs` +- Modify: `Compactor.cs`, `FillLinear.cs`, `RotationSlideStrategy.cs`, `ActionClone.cs`, `ActionSelectArea.cs` +- Delete from: `OpenNest.Core/Helper.cs` (lines 860–1462, all remaining methods) + +- [ ] **Step 1: Create `SpatialQuery.cs`** + +Move all remaining methods (14 total): +- `RayEdgeDistance(Vector, Line, PushDirection)` — private +- `RayEdgeDistance(double, double, double, double, double, double, PushDirection)` — private, `[AggressiveInlining]` +- `DirectionalDistance(List, List, PushDirection)` — public +- `DirectionalDistance(List, double, double, List, PushDirection)` — public +- `DirectionalDistance((Vector,Vector)[], Vector, (Vector,Vector)[], Vector, PushDirection)` — public +- `FlattenLines(List)` — public +- `OneWayDistance(Vector, (Vector,Vector)[], Vector, PushDirection)` — public +- `OppositeDirection(PushDirection)` — public +- `IsHorizontalDirection(PushDirection)` — public +- `EdgeDistance(Box, Box, PushDirection)` — public +- `DirectionToOffset(PushDirection, double)` — public +- `DirectionalGap(Box, Box, PushDirection)` — public +- `ClosestDistanceLeft/Right/Up/Down` — public (4 methods) +- `GetLargestBoxVertically/Horizontally` — public (2 methods) + +Namespace: `OpenNest.Geometry`. Class: `public static class SpatialQuery`. + +Required usings: `System`, `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`. + +- [ ] **Step 2: Update call sites** + +Replace `Helper.*` → `SpatialQuery.*` and add `using OpenNest.Geometry;` where needed: +- `OpenNest.Engine/Compactor.cs` — `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionalGap`, `DirectionalDistance`, `DirectionToOffset` +- `OpenNest.Engine/FillLinear.cs` — `DirectionalDistance`, `OppositeDirection` +- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — `FlattenLines`, `OppositeDirection`, `OneWayDistance` +- `OpenNest/Actions/ActionClone.cs` — `GetLargestBoxVertically`, `GetLargestBoxHorizontally` +- `OpenNest/Actions/ActionSelectArea.cs` — `GetLargestBoxHorizontally`, `GetLargestBoxVertically` + +- [ ] **Step 3: Remove methods from `Helper.cs`** + +At this point `Helper.cs` should be empty (just the class wrapper and usings). + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` + +- [ ] **Step 5: Commit** + +``` +refactor: extract SpatialQuery from Helper +``` + +--- + +### Task 7: Delete Helper.cs + +**Files:** +- Delete: `OpenNest.Core/Helper.cs` + +- [ ] **Step 1: Delete the empty `Helper.cs` file** + +- [ ] **Step 2: Build and verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded with zero errors + +- [ ] **Step 3: Commit** + +``` +refactor: remove empty Helper class +``` diff --git a/docs/superpowers/specs/2026-03-15-fill-exact-design.md b/docs/superpowers/specs/2026-03-15-fill-exact-design.md new file mode 100644 index 0000000..d911f8e --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-fill-exact-design.md @@ -0,0 +1,96 @@ +# FillExact — Exact-Quantity Fill with Binary Search + +## Problem + +The current `NestEngine.Fill` fills an entire work area and truncates to `item.Quantity` with `.Take(n)`. This wastes plate space — parts are spread across the full area, leaving no usable remainder strip for subsequent drawings in AutoNest. + +## Solution + +Add a `FillExact` method that binary-searches for the smallest sub-area of the work area that fits exactly the requested quantity. This packs parts tightly against one edge, maximizing the remainder strip available for the next drawing. + +## Coordinate Conventions + +`Box.Width` is the X-axis extent. `Box.Length` is the Y-axis extent. The box is anchored at `(Box.X, Box.Y)` (bottom-left corner). + +- **Shrink width** means reducing `Box.Width` (X-axis), producing a narrower box anchored at the left edge. The remainder strip extends to the right. +- **Shrink length** means reducing `Box.Length` (Y-axis), producing a shorter box anchored at the bottom edge. The remainder strip extends upward. + +## Algorithm + +1. **Early exits:** + - Quantity is 0 (unlimited): delegate to `Fill` directly. + - Quantity is 1: delegate to `Fill` directly (a single part placement doesn't benefit from area search). +2. **Full fill** — Call `Fill(item, workArea, progress, token)` to establish the upper bound (max parts that fit). This call gets progress reporting so the user sees the phases running. +3. **Already exact or under** — If `fullCount <= quantity`, return the full fill result. The plate can't fit more than requested anyway. +4. **Estimate starting point** — Calculate an initial dimension estimate assuming 50% utilization: `estimatedDim = (partArea * quantity) / (0.5 * fixedDim)`, clamped to at least the part's bounding box dimension in that axis. +5. **Binary search** (max 8 iterations, or until `high - low < partSpacing`) — Keep one dimension of the work area fixed and binary-search on the other: + - `low = estimatedDim`, `high = workArea dimension` + - Each iteration: create a test box, call `Fill(item, testBox, null, token)` (no progress — search iterations are silent), check count. + - `count >= quantity` → record result, shrink: `high = mid` + - `count < quantity` → expand: `low = mid` + - Check cancellation token between iterations; if cancelled, return best found so far. +6. **Try both orientations** — Run the binary search twice: once shrinking length (fixed width) and once shrinking width (fixed length). +7. **Pick winner** — Compare by test box area (`testBox.Width * testBox.Length`). Return whichever orientation's result has a smaller test box area, leaving more remainder for subsequent drawings. Tie-break: prefer shrink-length (leaves horizontal remainder strip, generally more useful on wide plates). + +## Method Signature + +```csharp +// NestEngine.cs +public List FillExact(NestItem item, Box workArea, + IProgress progress, CancellationToken token) +``` + +Returns exactly `item.Quantity` parts packed into the smallest sub-area of `workArea`, or fewer if they don't all fit. + +## Internal Helper + +```csharp +private (List parts, double usedDim) BinarySearchFill( + NestItem item, Box workArea, bool shrinkWidth, + CancellationToken token) +``` + +Performs the binary search for one orientation. Returns the parts and the dimension value at which the exact quantity was achieved. Progress is not passed to inner Fill calls — the search iterations run silently. + +## Engine State + +Each inner `Fill` call clears `PhaseResults`, `AngleResults`, and overwrites `WinnerPhase`. After the winning Fill call is identified, `FillExact` runs the winner one final time with `progress` so: +- `PhaseResults` / `AngleResults` / `WinnerPhase` reflect the winning fill. +- The progress form shows the final result. + +## Integration + +### AutoNest (MainForm.RunAutoNest_Click) + +Replace `engine.Fill(item, workArea, progress, token)` with `engine.FillExact(item, workArea, progress, token)` for multi-quantity items. The tighter packing means `ComputeRemainderStrip` returns a larger box for subsequent drawings. + +### Single-drawing Fill + +`FillExact` works for single-drawing fills too. When `item.Quantity` is set, the caller gets a tight layout instead of parts scattered across the full plate. + +### Fallback + +When `item.Quantity` is 0 (unlimited), `FillExact` falls through to the standard `Fill` behavior — fill the entire work area. + +## Performance Notes + +The binary search converges in at most 8 iterations per orientation. Each iteration calls `Fill` internally, which runs the pairs/linear/best-fit phases. For a typical auto-nest scenario: + +- Full fill: 1 call (with progress) +- Shrink-length search: ~6-8 calls (silent) +- Shrink-width search: ~6-8 calls (silent) +- Final re-fill of winner: 1 call (with progress) +- Total: ~15-19 Fill calls per drawing + +The inner `Fill` calls for reduced work areas are faster than full-plate fills since the search space is smaller. The `BestFitCache` (used by the pairs phase) is keyed on the full plate size, so it stays warm across iterations — only the linear/rect phases re-run. + +Early termination (`high - low < partSpacing`) typically cuts 1-3 iterations, bringing the total closer to 12-15 calls. + +## Edge Cases + +- **Quantity 0 (unlimited):** Skip binary search, delegate to `Fill` directly. +- **Quantity 1:** Skip binary search, delegate to `Fill` directly. +- **Full fill already exact:** Return immediately without searching. +- **Part doesn't fit at all:** Return empty list. +- **Binary search can't hit exact count** (e.g., jumps from N-1 to N+2): Take the smallest test box where `count >= quantity` and truncate with `.Take(quantity)`. +- **Cancellation:** Check token between iterations. Return best result found so far. From c06758a2bd3e534051becd42d7e0f803a7765773 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 23:52:42 -0400 Subject: [PATCH 093/116] docs: add plate processor design spec for per-part lead-in assignment Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-15-plate-processor-design.md | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-15-plate-processor-design.md diff --git a/docs/superpowers/specs/2026-03-15-plate-processor-design.md b/docs/superpowers/specs/2026-03-15-plate-processor-design.md new file mode 100644 index 0000000..cfcd9eb --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-plate-processor-design.md @@ -0,0 +1,325 @@ +# Plate Processor Design — Per-Part Lead-In Assignment & Cut Sequencing + +## Overview + +Add a plate-level orchestrator (`PlateProcessor`) to `OpenNest.Engine` that sequences parts across a plate, assigns lead-ins per-part based on approach direction, and plans safe rapid paths between parts. This replaces the current `ContourCuttingStrategy` usage model where the exit point is derived from the plate corner alone — instead, each part's lead-in pierce point is computed from the actual approach direction (the previous part's last cut point). + +The motivation is laser head safety: on a CL-980 fiber laser, head-down rapids are significantly faster than raising the head, but traversing over already-cut areas risks collision with tipped-up slugs. The orchestrator must track cut areas and choose safe rapid paths. + +## Architecture + +Three pipeline stages, wired by a thin orchestrator: + +``` +IPartSequencer → ContourCuttingStrategy → IRapidPlanner + ↓ ↓ ↓ + ordered parts lead-ins applied safe rapid paths + └──────────── PlateProcessor ─────────────┘ +``` + +All new code lives in `OpenNest.Engine/` except the `ContourCuttingStrategy` signature change and `Part.HasManualLeadIns` flag which are in `OpenNest.Core`. + +## Model Changes + +### Part (OpenNest.Core) + +Add a flag to indicate the user has manually assigned lead-ins to this part: + +```csharp +public bool HasManualLeadIns { get; set; } +``` + +When `true`, the orchestrator skips `ContourCuttingStrategy.Apply()` for this part and uses the program as-is. + +### ContourCuttingStrategy (OpenNest.Core) + +Change the `Apply` signature to accept an approach point instead of a plate: + +```csharp +// Before +public Program Apply(Program partProgram, Plate plate) + +// After +public CuttingResult Apply(Program partProgram, Vector approachPoint) +``` + +Remove `GetExitPoint(Plate)` — the caller provides the approach point in part-local coordinates. + +### CuttingResult (OpenNest.Core, namespace OpenNest.CNC.CuttingStrategy) + +New readonly struct returned by `ContourCuttingStrategy.Apply()`. Lives in `CNC/CuttingStrategy/CuttingResult.cs`: + +```csharp +public readonly struct CuttingResult +{ + public Program Program { get; init; } + public Vector LastCutPoint { get; init; } +} +``` + +- `Program`: the program with lead-ins/lead-outs applied. +- `LastCutPoint`: where the last contour cut ends, in part-local coordinates. The orchestrator transforms this to plate coordinates to compute the approach point for the next part. + +## Stage 1: IPartSequencer + +### Interface + +```csharp +namespace OpenNest.Engine +{ + public interface IPartSequencer + { + List Sequence(IReadOnlyList parts, Plate plate); + } +} +``` + +### SequencedPart + +```csharp +public readonly struct SequencedPart +{ + public Part Part { get; init; } +} +``` + +The sequencer only determines cut order. Approach points are computed by the orchestrator as it loops, since each part's approach point depends on the previous part's `CuttingResult.LastCutPoint`. + +### Implementations + +One class per `SequenceMethod`. All live in `OpenNest.Engine/Sequencing/`. + +| Class | SequenceMethod | Algorithm | +|-------|---------------|-----------| +| `RightSideSequencer` | RightSide | Sort parts by X descending (rightmost first) | +| `LeftSideSequencer` | LeftSide | Sort parts by X ascending (leftmost first) | +| `BottomSideSequencer` | BottomSide | Sort parts by Y ascending (bottom first) | +| `LeastCodeSequencer` | LeastCode | Nearest-neighbor from exit point, then 2-opt improvement | +| `AdvancedSequencer` | Advanced | Nearest-neighbor with row/column grouping from `SequenceParameters` | +| `EdgeStartSequencer` | EdgeStart | Sort by distance from nearest plate edge, closest first | + +#### Directional sequencers (RightSide, LeftSide, BottomSide) + +Sort parts by their bounding box center along the relevant axis. Ties broken by the perpendicular axis. These are simple positional sorts — no TSP involved. + +#### LeastCodeSequencer + +1. Start from the plate exit point. +2. Nearest-neighbor greedy: pick the unvisited part whose bounding box center is closest to the current position. +3. 2-opt improvement: iterate over the sequence, try swapping pairs. If total travel distance decreases, keep the swap. Repeat until no improvement found (or max iterations). + +#### AdvancedSequencer + +Uses `SequenceParameters` to group parts into rows/columns based on `MinDistanceBetweenRowsColumns`, then sequences within each group. `AlternateRowsColumns` and `AlternateCutoutsWithinRowColumn` control serpentine vs. unidirectional ordering within rows. + +#### EdgeStartSequencer + +Sort parts by distance from the nearest plate edge (minimum of distances to all four edges). Parts closest to an edge cut first. Ties broken by nearest-neighbor. + +### Parameter Flow + +Sequencers that need configuration accept it through their constructor: +- `LeastCodeSequencer(int maxIterations = 100)` — max 2-opt iterations +- `AdvancedSequencer(SequenceParameters parameters)` — row/column grouping config +- Directional sequencers and `EdgeStartSequencer` need no configuration + +## Stage 2: ContourCuttingStrategy + +Already exists in `OpenNest.Core/CNC/CuttingStrategy/`. Only the signature and return type change: + +1. `Apply(Program partProgram, Plate plate)` → `Apply(Program partProgram, Vector approachPoint)` +2. Return `CuttingResult` instead of `Program` +3. Remove `GetExitPoint(Plate)` — replaced by the `approachPoint` parameter +4. Set `CuttingResult.LastCutPoint` to the end point of the last contour (perimeter), which is the same as the perimeter's reindexed start point for closed contours + +The internal logic (cutout sequencing, contour type detection, normal computation, lead-in/out selection) remains unchanged — only the source of the approach direction changes. + +## Stage 3: IRapidPlanner + +### Interface + +```csharp +namespace OpenNest.Engine +{ + public interface IRapidPlanner + { + RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas); + } +} +``` + +All coordinates are in plate space. + +### RapidPath + +```csharp +public struct RapidPath +{ + public bool HeadUp { get; init; } + public List Waypoints { get; init; } +} +``` + +- `HeadUp = true`: the post-processor should raise Z before traversing. `Waypoints` is empty (direct move). +- `HeadUp = false`: head-down rapid. `Waypoints` contains the path (may be empty for a direct move, or contain intermediate points for obstacle avoidance in future implementations). + +### Implementations + +Both live in `OpenNest.Engine/RapidPlanning/`. + +#### SafeHeightRapidPlanner + +Always returns `HeadUp = true` with empty waypoints. Guaranteed safe, simplest possible implementation. + +#### DirectRapidPlanner + +Checks if the straight line from `from` to `to` intersects any shape in `cutAreas`: +- If clear: returns `HeadUp = false`, empty waypoints (direct head-down rapid). +- If blocked: returns `HeadUp = true`, empty waypoints (fall back to safe height). + +Uses existing `Intersect` class from `OpenNest.Geometry` for line-shape intersection checks. + +Future enhancement: obstacle-avoidance pathfinding that routes around cut areas with head down. This is a 2D pathfinding problem (visibility graph or similar) and is out of scope for the initial implementation. + +## PlateProcessor (Orchestrator) + +Lives in `OpenNest.Engine/PlateProcessor.cs`. + +```csharp +public class PlateProcessor +{ + public IPartSequencer Sequencer { get; set; } + public ContourCuttingStrategy CuttingStrategy { get; set; } + public IRapidPlanner RapidPlanner { get; set; } + + public PlateResult Process(Plate plate) + { + // 1. Sequence parts + var ordered = Sequencer.Sequence(plate.Parts, plate); + + var results = new List(); + var cutAreas = new List(); + var currentPoint = GetExitPoint(plate); // plate-space starting point + + foreach (var sequenced in ordered) + { + var part = sequenced.Part; + + // 2. Transform approach point from plate space to part-local space + var localApproach = ToPartLocal(currentPoint, part); + + // 3. Apply lead-ins (or skip if manual) + CuttingResult cutResult; + if (!part.HasManualLeadIns && CuttingStrategy != null) + { + cutResult = CuttingStrategy.Apply(part.Program, localApproach); + } + else + { + cutResult = new CuttingResult + { + Program = part.Program, + LastCutPoint = GetProgramEndPoint(part.Program) + }; + } + + // 4. Get pierce point in plate space for rapid planning + var piercePoint = ToPlateSpace(GetProgramStartPoint(cutResult.Program), part); + + // 5. Plan rapid from current position to this part's pierce point + var rapid = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas); + + results.Add(new ProcessedPart + { + Part = part, + ProcessedProgram = cutResult.Program, + RapidPath = rapid + }); + + // 6. Track cut area (part perimeter in plate space) for future rapid planning + cutAreas.Add(GetPartPerimeter(part)); + + // 7. Update current position to this part's last cut point (plate space) + currentPoint = ToPlateSpace(cutResult.LastCutPoint, part); + } + + return new PlateResult { Parts = results }; + } +} +``` + +### Coordinate Transforms + +Part programs already have rotation baked in (the `Part` constructor calls `Program.Rotate()`). `Part.Location` is a pure translation offset. Therefore, coordinate transforms are simple vector addition/subtraction — no rotation involved: + +- `ToPartLocal(Vector platePoint, Part part)`: `platePoint - part.Location` +- `ToPlateSpace(Vector localPoint, Part part)`: `localPoint + part.Location` + +This matches how `Part.Intersects` converts to plate space (offset by `Location` only). + +### Helper Methods + +- `GetExitPoint(Plate)`: moved from `ContourCuttingStrategy` — returns the plate corner opposite the quadrant origin. +- `GetProgramStartPoint(Program)`: first `RapidMove` position in the program (the pierce point). +- `GetProgramEndPoint(Program)`: last move's end position in the program. +- `GetPartPerimeter(Part)`: converts the part's program to geometry, builds `ShapeProfile`, returns the perimeter `Shape` offset by `part.Location` (translation only — rotation is already baked in). + +### PlateResult + +```csharp +public class PlateResult +{ + public List Parts { get; init; } +} + +public struct ProcessedPart +{ + public Part Part { get; init; } + public Program ProcessedProgram { get; init; } // with lead-ins applied (original Part.Program unchanged) + public RapidPath RapidPath { get; init; } +} +``` + +The orchestrator is non-destructive — it does not mutate `Part.Program` (which has a `private set`). Instead, the processed program with lead-ins is stored in `ProcessedPart.ProcessedProgram`. The post-processor consumes `PlateResult` to generate machine-specific G-code, using `ProcessedProgram` for cut paths and `RapidPath.HeadUp` for Z-axis commands. + +Note: the caller is responsible for configuring `CuttingStrategy.Parameters` (the `CuttingParameters` instance with lead-in/lead-out settings) before calling `Process()`. Parameters typically vary by material/thickness. + +## File Structure + +``` +OpenNest.Core/ +├── Part.cs # add HasManualLeadIns property +└── CNC/CuttingStrategy/ + ├── ContourCuttingStrategy.cs # signature change + CuttingResult return + └── CuttingResult.cs # new struct + +OpenNest.Engine/ +├── PlateProcessor.cs # orchestrator +├── Sequencing/ +│ ├── IPartSequencer.cs +│ ├── SequencedPart.cs # removed ApproachPoint (orchestrator tracks it) +│ ├── RightSideSequencer.cs +│ ├── LeftSideSequencer.cs +│ ├── BottomSideSequencer.cs +│ ├── LeastCodeSequencer.cs +│ ├── AdvancedSequencer.cs +│ └── EdgeStartSequencer.cs +└── RapidPlanning/ + ├── IRapidPlanner.cs + ├── RapidPath.cs + ├── SafeHeightRapidPlanner.cs + └── DirectRapidPlanner.cs +``` + +## Known Limitations + +- `DirectRapidPlanner` checks edge intersection only — a rapid that passes entirely through the interior of a concave cut part without crossing a perimeter edge would not be detected. Unlikely in practice (parts have material around them) but worth noting. +- `LeastCodeSequencer` uses bounding box centers for nearest-neighbor distance. For highly irregular parts, closest-point-on-perimeter could yield better results, but the simpler approach is sufficient for the initial implementation. + +## Out of Scope + +- Obstacle-avoidance pathfinding for head-down rapids (future enhancement to `DirectRapidPlanner`) +- UI integration (selecting sequencing method, configuring rapid planner) +- Post-processor changes to consume `PlateResult` — interim state: `PlateResult` is returned from `Process()` and the caller bridges it to the existing `IPostProcessor` interface +- `RightSideAlt` sequencer (unclear how it differs from `RightSide` — defer until behavior is defined; `PlateProcessor` should throw `NotSupportedException` if selected) +- Serialization of `PlateResult` From 37c76a720d89f2411a01fae28a70d7655ce41e30 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 23:55:16 -0400 Subject: [PATCH 094/116] docs: address spec review feedback for plate processor design Fix coordinate transforms (translate-only, no rotation), make orchestrator non-destructive (ProcessedPart holds result instead of mutating Part.Program), use readonly structs consistently, add factory mapping and known limitations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-15-plate-processor-design.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-plate-processor-design.md b/docs/superpowers/specs/2026-03-15-plate-processor-design.md index cfcd9eb..2987bf4 100644 --- a/docs/superpowers/specs/2026-03-15-plate-processor-design.md +++ b/docs/superpowers/specs/2026-03-15-plate-processor-design.md @@ -123,6 +123,10 @@ Sequencers that need configuration accept it through their constructor: - `AdvancedSequencer(SequenceParameters parameters)` — row/column grouping config - Directional sequencers and `EdgeStartSequencer` need no configuration +### Factory + +A static `PartSequencerFactory.Create(SequenceParameters parameters)` method in `OpenNest.Engine/Sequencing/` maps `parameters.Method` to the correct `IPartSequencer` implementation, passing constructor args as needed. Throws `NotSupportedException` for `RightSideAlt`. + ## Stage 2: ContourCuttingStrategy Already exists in `OpenNest.Core/CNC/CuttingStrategy/`. Only the signature and return type change: @@ -153,7 +157,7 @@ All coordinates are in plate space. ### RapidPath ```csharp -public struct RapidPath +public readonly struct RapidPath { public bool HeadUp { get; init; } public List Waypoints { get; init; } @@ -272,7 +276,7 @@ public class PlateResult public List Parts { get; init; } } -public struct ProcessedPart +public readonly struct ProcessedPart { public Part Part { get; init; } public Program ProcessedProgram { get; init; } // with lead-ins applied (original Part.Program unchanged) From 79c6ec340c714dc7cc530840179fa5a7b5357146 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:17:37 -0400 Subject: [PATCH 095/116] docs: add plate processor implementation plan 16 tasks covering test infrastructure, core model changes, part sequencing (6 strategies + factory), rapid planning (2 strategies), and the PlateProcessor orchestrator. TDD approach with xUnit tests for each component. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-16-plate-processor.md | 1760 +++++++++++++++++ 1 file changed, 1760 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-16-plate-processor.md diff --git a/docs/superpowers/plans/2026-03-16-plate-processor.md b/docs/superpowers/plans/2026-03-16-plate-processor.md new file mode 100644 index 0000000..13c4c38 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-plate-processor.md @@ -0,0 +1,1760 @@ +# Plate Processor Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a plate-level orchestrator that sequences parts, assigns per-part lead-ins based on approach direction, and plans safe rapid paths between parts. + +**Architecture:** Three-stage pipeline in OpenNest.Engine — IPartSequencer (cut order) → ContourCuttingStrategy (lead-ins) → IRapidPlanner (safe rapids) — wired by PlateProcessor. Non-destructive: results stored in PlateResult, original Part.Program untouched. + +**Tech Stack:** .NET 8, xUnit (new test project), OpenNest.Core, OpenNest.Engine + +**Spec:** `docs/superpowers/specs/2026-03-15-plate-processor-design.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj` | xUnit test project | +| Create | `OpenNest.Engine.Tests/TestHelpers.cs` | Shared test helpers (MakePartAt, MakePlate) | +| Modify | `OpenNest.sln` | Add test project to solution | +| Modify | `OpenNest.Core/Part.cs` | Add `HasManualLeadIns` property | +| Create | `OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs` | Return type for ContourCuttingStrategy.Apply | +| Modify | `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Change Apply signature to accept Vector approachPoint | +| Create | `OpenNest.Engine/Sequencing/IPartSequencer.cs` | Interface + SequencedPart struct | +| Create | `OpenNest.Engine/Sequencing/PartSequencerFactory.cs` | Maps SequenceMethod to IPartSequencer | +| Create | `OpenNest.Engine/Sequencing/RightSideSequencer.cs` | Sort by X descending | +| Create | `OpenNest.Engine/Sequencing/LeftSideSequencer.cs` | Sort by X ascending | +| Create | `OpenNest.Engine/Sequencing/BottomSideSequencer.cs` | Sort by Y ascending | +| Create | `OpenNest.Engine/Sequencing/EdgeStartSequencer.cs` | Sort by distance from nearest plate edge | +| Create | `OpenNest.Engine/Sequencing/PlateHelper.cs` | Shared exit point calculation | +| Create | `OpenNest.Engine/Sequencing/LeastCodeSequencer.cs` | Nearest-neighbor + 2-opt | +| Create | `OpenNest.Engine/Sequencing/AdvancedSequencer.cs` | Row/column grouping with serpentine ordering | +| Create | `OpenNest.Engine/RapidPlanning/IRapidPlanner.cs` | Interface | +| Create | `OpenNest.Engine/RapidPlanning/RapidPath.cs` | Result struct | +| Create | `OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs` | Always head-up | +| Create | `OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs` | Head-down if clear, head-up if blocked | +| Create | `OpenNest.Engine/PlateProcessor.cs` | Orchestrator | +| Create | `OpenNest.Engine/PlateResult.cs` | Result types (PlateResult, ProcessedPart) | + +--- + +## Chunk 1: Foundation + +### Task 1: Create xUnit test project + +**Files:** +- Create: `OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj` +- Modify: `OpenNest.sln` + +- [ ] **Step 1: Create the test project** + +```bash +cd C:/Users/AJ/Desktop/Projects/OpenNest +dotnet new xunit -n OpenNest.Engine.Tests --framework net8.0-windows +dotnet sln add OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj +dotnet add OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj reference OpenNest.Core/OpenNest.Core.csproj +dotnet add OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj reference OpenNest.Engine/OpenNest.Engine.csproj +``` + +- [ ] **Step 2: Verify the project builds** + +Run: `dotnet build OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Delete the generated UnitTest1.cs** + +Delete `OpenNest.Engine.Tests/UnitTest1.cs` — we'll create our own test files. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine.Tests/ OpenNest.sln +git commit -m "chore: add OpenNest.Engine.Tests xUnit project" +``` + +--- + +### Task 2: Shared test helper + +**Files:** +- Create: `OpenNest.Engine.Tests/TestHelpers.cs` + +- [ ] **Step 1: Create TestHelpers.cs** + +This helper is used by nearly every test in the plan. Creates simple 1x1 or 2x2 square parts at known positions. + +```csharp +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests; + +internal static class TestHelpers +{ + public static Part MakePartAt(double x, double y, double size = 1) + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(size, 0))); + pgm.Codes.Add(new LinearMove(new Vector(size, size))); + pgm.Codes.Add(new LinearMove(new Vector(0, size))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + var drawing = new Drawing("test", pgm); + return new Part(drawing, new Vector(x, y)); + } + + public static Plate MakePlate(double width = 60, double length = 120, params Part[] parts) + { + var plate = new Plate(width, length); + foreach (var p in parts) + plate.Parts.Add(p); + return plate; + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine.Tests/TestHelpers.cs +git commit -m "chore: add shared test helpers for Engine tests" +``` + +--- + +### Task 3: CuttingResult struct + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs` +- Test: `OpenNest.Engine.Tests/CuttingResultTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests; + +public class CuttingResultTests +{ + [Fact] + public void CuttingResult_StoresValues() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(1, 2))); + var point = new Vector(3, 4); + + var result = new CuttingResult { Program = pgm, LastCutPoint = point }; + + Assert.Same(pgm, result.Program); + Assert.Equal(3, result.LastCutPoint.X); + Assert.Equal(4, result.LastCutPoint.Y); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Engine.Tests --filter CuttingResult_StoresValues -v n` +Expected: FAIL — `CuttingResult` type does not exist + +- [ ] **Step 3: Create CuttingResult.cs** + +```csharp +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public readonly struct CuttingResult + { + public Program Program { get; init; } + public Vector LastCutPoint { get; init; } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Engine.Tests --filter CuttingResult_StoresValues -v n` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs OpenNest.Engine.Tests/CuttingResultTests.cs +git commit -m "feat: add CuttingResult struct" +``` + +--- + +### Task 4: Part.HasManualLeadIns flag + +**Files:** +- Modify: `OpenNest.Core/Part.cs` +- Test: `OpenNest.Engine.Tests/PartFlagTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +using OpenNest.CNC; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests; + +public class PartFlagTests +{ + [Fact] + public void HasManualLeadIns_DefaultsFalse() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + var drawing = new Drawing("test", pgm); + var part = new Part(drawing); + + Assert.False(part.HasManualLeadIns); + } + + [Fact] + public void HasManualLeadIns_CanBeSet() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + var drawing = new Drawing("test", pgm); + var part = new Part(drawing); + + part.HasManualLeadIns = true; + + Assert.True(part.HasManualLeadIns); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Engine.Tests --filter HasManualLeadIns -v n` +Expected: FAIL — `HasManualLeadIns` property does not exist + +- [ ] **Step 3: Add the property to Part.cs** + +In `OpenNest.Core/Part.cs`, after the `Program` property (line 52), add: + +```csharp +public bool HasManualLeadIns { get; set; } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Engine.Tests --filter HasManualLeadIns -v n` +Expected: 2 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Core/Part.cs OpenNest.Engine.Tests/PartFlagTests.cs +git commit -m "feat: add Part.HasManualLeadIns flag" +``` + +--- + +### Task 5: ContourCuttingStrategy signature change + +**Files:** +- Modify: `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` + +This changes the existing `Apply` method signature and return type. No new tests needed — this modifies an existing class that currently has no callers (it was recently built). + +- [ ] **Step 1: Change the Apply signature** + +In `ContourCuttingStrategy.cs`, change line 10: + +```csharp +// Before: +public Program Apply(Program partProgram, Plate plate) +// After: +public CuttingResult Apply(Program partProgram, Vector approachPoint) +``` + +- [ ] **Step 2: Replace GetExitPoint usage with approachPoint** + +Replace line 12: +```csharp +// Before: +var exitPoint = GetExitPoint(plate); +// After: +var exitPoint = approachPoint; +``` + +- [ ] **Step 3: Delete the GetExitPoint method** + +Delete lines 66-79 (the `GetExitPoint(Plate plate)` method). This logic moves to `PlateProcessor` later. + +- [ ] **Step 4: Track the last cut point and return CuttingResult** + +`perimeterPt` is declared inside a bare block (lines 48-61) and goes out of scope at the closing brace. Declare a `lastCutPoint` variable before the block and assign it inside. + +Before the `// Perimeter last` block (before line 48), add: +```csharp +var lastCutPoint = exitPoint; +``` + +Inside the `// Perimeter last` block, after the perimeter point is computed (after line 49), add: +```csharp +lastCutPoint = perimeterPt; +``` + +Replace line 63 (`return result;`): +```csharp +return new CuttingResult +{ + Program = result, + LastCutPoint = lastCutPoint +}; +``` + +- [ ] **Step 5: Remove the unused Plate using directive if needed** + +Check if `Plate` is still referenced. The `using OpenNest.Geometry` is still needed for `Vector`. + +- [ ] **Step 6: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +git commit -m "refactor: change ContourCuttingStrategy.Apply to accept approachPoint" +``` + +--- + +## Chunk 2: Part Sequencing + +### Task 6: IPartSequencer interface and SequencedPart + +**Files:** +- Create: `OpenNest.Engine/Sequencing/IPartSequencer.cs` + +- [ ] **Step 1: Create the interface file** + +```csharp +using System.Collections.Generic; + +namespace OpenNest.Engine.Sequencing +{ + public readonly struct SequencedPart + { + public Part Part { get; init; } + } + + public interface IPartSequencer + { + List Sequence(IReadOnlyList parts, Plate plate); + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/Sequencing/ +git commit -m "feat: add IPartSequencer interface and SequencedPart" +``` + +--- + +### Task 7: Directional sequencers (RightSide, LeftSide, BottomSide) + +**Files:** +- Create: `OpenNest.Engine/Sequencing/RightSideSequencer.cs` +- Create: `OpenNest.Engine/Sequencing/LeftSideSequencer.cs` +- Create: `OpenNest.Engine/Sequencing/BottomSideSequencer.cs` +- Test: `OpenNest.Engine.Tests/Sequencing/DirectionalSequencerTests.cs` + +- [ ] **Step 1: Write the tests** + +Create a helper method to build simple parts at known positions, then test all three directional sequencers. + +```csharp +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Sequencing; + +public class DirectionalSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + private static Plate MakePlate(params Part[] parts) => TestHelpers.MakePlate(60, 120, parts); + + [Fact] + public void RightSide_SortsXDescending() + { + var a = MakePartAt(10, 5); + var b = MakePartAt(30, 5); + var c = MakePartAt(20, 5); + var plate = MakePlate(a, b, c); + + var sequencer = new RightSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(b, result[0].Part); + Assert.Same(c, result[1].Part); + Assert.Same(a, result[2].Part); + } + + [Fact] + public void LeftSide_SortsXAscending() + { + var a = MakePartAt(10, 5); + var b = MakePartAt(30, 5); + var c = MakePartAt(20, 5); + var plate = MakePlate(a, b, c); + + var sequencer = new LeftSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(a, result[0].Part); + Assert.Same(c, result[1].Part); + Assert.Same(b, result[2].Part); + } + + [Fact] + public void BottomSide_SortsYAscending() + { + var a = MakePartAt(5, 20); + var b = MakePartAt(5, 5); + var c = MakePartAt(5, 10); + var plate = MakePlate(a, b, c); + + var sequencer = new BottomSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(b, result[0].Part); + Assert.Same(c, result[1].Part); + Assert.Same(a, result[2].Part); + } + + [Fact] + public void RightSide_TiesBrokenByPerpendicularAxis() + { + var a = MakePartAt(10, 20); + var b = MakePartAt(10, 5); + var plate = MakePlate(a, b); + + var sequencer = new RightSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + // Same X, tie broken by Y — lower Y first for RightSide + Assert.Same(b, result[0].Part); + Assert.Same(a, result[1].Part); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter DirectionalSequencerTests -v n` +Expected: FAIL — sequencer classes don't exist + +- [ ] **Step 3: Implement RightSideSequencer** + +```csharp +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class RightSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderByDescending(p => p.BoundingBox.Center.X) + .ThenBy(p => p.BoundingBox.Center.Y) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} +``` + +- [ ] **Step 4: Implement LeftSideSequencer** + +```csharp +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class LeftSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderBy(p => p.BoundingBox.Center.X) + .ThenBy(p => p.BoundingBox.Center.Y) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} +``` + +- [ ] **Step 5: Implement BottomSideSequencer** + +```csharp +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class BottomSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderBy(p => p.BoundingBox.Center.Y) + .ThenBy(p => p.BoundingBox.Center.X) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter DirectionalSequencerTests -v n` +Expected: 4 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest.Engine/Sequencing/ OpenNest.Engine.Tests/Sequencing/ +git commit -m "feat: add directional part sequencers (RightSide, LeftSide, BottomSide)" +``` + +--- + +### Task 8: EdgeStartSequencer + +**Files:** +- Create: `OpenNest.Engine/Sequencing/EdgeStartSequencer.cs` +- Test: `OpenNest.Engine.Tests/Sequencing/EdgeStartSequencerTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Sequencing; + +public class EdgeStartSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void SortsByDistanceFromNearestEdge() + { + // Plate is 60x120, Q1 (origin at 0,0) + var plate = new Plate(60, 120); + var edgePart = MakePartAt(1, 1); // 1 unit from left and bottom edges + var centerPart = MakePartAt(25, 55); // ~25 from nearest edge + var midPart = MakePartAt(10, 10); // 10 from left and bottom edges + plate.Parts.Add(edgePart); + plate.Parts.Add(centerPart); + plate.Parts.Add(midPart); + + var sequencer = new EdgeStartSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(edgePart, result[0].Part); + Assert.Same(midPart, result[1].Part); + Assert.Same(centerPart, result[2].Part); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Engine.Tests --filter EdgeStartSequencerTests -v n` +Expected: FAIL + +- [ ] **Step 3: Implement EdgeStartSequencer** + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Sequencing +{ + public class EdgeStartSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + var plateBox = plate.BoundingBox(false); + + return parts + .OrderBy(p => MinEdgeDistance(p.BoundingBox, plateBox)) + .ThenBy(p => p.BoundingBox.Center.X) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + + private static double MinEdgeDistance(Box partBox, Box plateBox) + { + var center = partBox.Center; + var distLeft = System.Math.Abs(center.X - plateBox.Left); + var distRight = System.Math.Abs(center.X - plateBox.Right); + var distBottom = System.Math.Abs(center.Y - plateBox.Bottom); + var distTop = System.Math.Abs(center.Y - plateBox.Top); + return System.Math.Min(System.Math.Min(distLeft, distRight), + System.Math.Min(distBottom, distTop)); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Engine.Tests --filter EdgeStartSequencerTests -v n` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/Sequencing/EdgeStartSequencer.cs OpenNest.Engine.Tests/Sequencing/EdgeStartSequencerTests.cs +git commit -m "feat: add EdgeStartSequencer" +``` + +--- + +### Task 9: LeastCodeSequencer (nearest-neighbor + 2-opt) + +**Files:** +- Create: `OpenNest.Engine/Sequencing/LeastCodeSequencer.cs` +- Test: `OpenNest.Engine.Tests/Sequencing/LeastCodeSequencerTests.cs` + +- [ ] **Step 1: Write the tests** + +```csharp +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Sequencing; + +public class LeastCodeSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void NearestNeighbor_FromExitPoint() + { + // Q1 plate: exit point is (60, 120) (top-right corner) + var plate = new Plate(60, 120); + var farPart = MakePartAt(5, 5); + var nearPart = MakePartAt(55, 115); + plate.Parts.Add(farPart); + plate.Parts.Add(nearPart); + + var sequencer = new LeastCodeSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + // nearPart is closer to exit point (60,120), should come first + Assert.Same(nearPart, result[0].Part); + Assert.Same(farPart, result[1].Part); + } + + [Fact] + public void PreservesAllParts() + { + var plate = new Plate(60, 120); + for (var i = 0; i < 10; i++) + plate.Parts.Add(MakePartAt(i * 5, i * 10)); + + var sequencer = new LeastCodeSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Equal(10, result.Count); + } + + [Fact] + public void TwoOpt_ImprovesSolution() + { + // Create a scenario where nearest-neighbor produces a crossing + // that 2-opt should fix + var plate = new Plate(100, 100); + // Exit point at (100, 100) for Q1 + // Parts arranged so NN would cross but 2-opt should uncross + var a = MakePartAt(90, 90); // nearest to exit + var b = MakePartAt(10, 80); // NN picks this 2nd (closest to a) + var c = MakePartAt(80, 10); // NN picks this 3rd — crosses! + var d = MakePartAt(5, 5); // last + plate.Parts.Add(a); + plate.Parts.Add(b); + plate.Parts.Add(c); + plate.Parts.Add(d); + + var sequencer = new LeastCodeSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + // Just verify all parts present and result is deterministic + Assert.Equal(4, result.Count); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter LeastCodeSequencerTests -v n` +Expected: FAIL + +- [ ] **Step 3: Implement LeastCodeSequencer** + +The exit point logic (opposite corner from quadrant origin) is needed here and will also be used by `PlateProcessor`. Put it as a static helper. + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Sequencing +{ + public class LeastCodeSequencer : IPartSequencer + { + private readonly int _maxIterations; + + public LeastCodeSequencer(int maxIterations = 100) + { + _maxIterations = maxIterations; + } + + public List Sequence(IReadOnlyList parts, Plate plate) + { + if (parts.Count == 0) + return new List(); + + var exitPoint = PlateHelper.GetExitPoint(plate); + var order = NearestNeighbor(parts, exitPoint); + TwoOpt(order, exitPoint); + + return order.Select(p => new SequencedPart { Part = p }).ToList(); + } + + private static List NearestNeighbor(IReadOnlyList parts, Vector startPoint) + { + var remaining = new List(parts); + var ordered = new List(); + var currentPoint = startPoint; + + while (remaining.Count > 0) + { + var nearest = remaining[0]; + var nearestDist = nearest.BoundingBox.Center.DistanceTo(currentPoint); + + for (var i = 1; i < remaining.Count; i++) + { + var dist = remaining[i].BoundingBox.Center.DistanceTo(currentPoint); + if (dist < nearestDist) + { + nearest = remaining[i]; + nearestDist = dist; + } + } + + ordered.Add(nearest); + remaining.Remove(nearest); + currentPoint = nearest.BoundingBox.Center; + } + + return ordered; + } + + private void TwoOpt(List order, Vector startPoint) + { + var improved = true; + var iterations = 0; + + while (improved && iterations < _maxIterations) + { + improved = false; + iterations++; + + for (var i = 0; i < order.Count - 1; i++) + { + for (var j = i + 1; j < order.Count; j++) + { + var delta = TwoOptDelta(order, startPoint, i, j); + if (delta < -OpenNest.Math.Tolerance.Epsilon) + { + Reverse(order, i, j); + improved = true; + } + } + } + } + } + + private static double TwoOptDelta(List order, Vector startPoint, int i, int j) + { + var prevI = i == 0 ? startPoint : order[i - 1].BoundingBox.Center; + var ci = order[i].BoundingBox.Center; + var cj = order[j].BoundingBox.Center; + var nextJ = j + 1 < order.Count ? order[j + 1].BoundingBox.Center : (Vector?)null; + + var oldDist = prevI.DistanceTo(ci); + var newDist = prevI.DistanceTo(cj); + + if (nextJ.HasValue) + { + oldDist += cj.DistanceTo(nextJ.Value); + newDist += ci.DistanceTo(nextJ.Value); + } + + return newDist - oldDist; + } + + private static void Reverse(List list, int start, int end) + { + while (start < end) + { + (list[start], list[end]) = (list[end], list[start]); + start++; + end--; + } + } + } +} +``` + +- [ ] **Step 4: Create PlateHelper for shared exit point logic** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.Engine.Sequencing +{ + internal static class PlateHelper + { + public static Vector GetExitPoint(Plate plate) + { + var w = plate.Size.Width; + var l = plate.Size.Length; + + return plate.Quadrant switch + { + 1 => new Vector(w, l), + 2 => new Vector(0, l), + 3 => new Vector(0, 0), + 4 => new Vector(w, 0), + _ => new Vector(w, l) + }; + } + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter LeastCodeSequencerTests -v n` +Expected: 3 tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Engine/Sequencing/ OpenNest.Engine.Tests/Sequencing/ +git commit -m "feat: add LeastCodeSequencer with nearest-neighbor and 2-opt" +``` + +--- + +### Task 10: AdvancedSequencer + +**Files:** +- Create: `OpenNest.Engine/Sequencing/AdvancedSequencer.cs` +- Test: `OpenNest.Engine.Tests/Sequencing/AdvancedSequencerTests.cs` + +- [ ] **Step 1: Write the tests** + +```csharp +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Sequencing; + +public class AdvancedSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void GroupsIntoRows_NoAlternate() + { + // Two rows of parts with clear Y separation + // Q1 plate 100x100: exit point (100, 100), so leftToRight starts true + var plate = new Plate(100, 100); + var row1a = MakePartAt(10, 10); + var row1b = MakePartAt(30, 10); + var row2a = MakePartAt(10, 50); + var row2b = MakePartAt(30, 50); + plate.Parts.Add(row1a); + plate.Parts.Add(row1b); + plate.Parts.Add(row2a); + plate.Parts.Add(row2b); + + var parameters = new SequenceParameters + { + Method = SequenceMethod.Advanced, + MinDistanceBetweenRowsColumns = 5.0, + AlternateRowsColumns = false + }; + var sequencer = new AdvancedSequencer(parameters); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + // No alternation: both rows left-to-right (X ascending) + Assert.Same(row1a, result[0].Part); + Assert.Same(row1b, result[1].Part); + Assert.Same(row2a, result[2].Part); + Assert.Same(row2b, result[3].Part); + } + + [Fact] + public void SerpentineAlternatesDirection() + { + // Q1 plate 100x100: exit point (100, 100), so leftToRight starts true + var plate = new Plate(100, 100); + var r1Left = MakePartAt(10, 10); + var r1Right = MakePartAt(30, 10); + var r2Left = MakePartAt(10, 50); + var r2Right = MakePartAt(30, 50); + plate.Parts.Add(r1Left); + plate.Parts.Add(r1Right); + plate.Parts.Add(r2Left); + plate.Parts.Add(r2Right); + + var parameters = new SequenceParameters + { + Method = SequenceMethod.Advanced, + MinDistanceBetweenRowsColumns = 5.0, + AlternateRowsColumns = true + }; + var sequencer = new AdvancedSequencer(parameters); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + // Row 1: left-to-right (X ascending) + Assert.Same(r1Left, result[0].Part); + Assert.Same(r1Right, result[1].Part); + // Row 2: right-to-left (X descending, alternated) + Assert.Same(r2Right, result[2].Part); + Assert.Same(r2Left, result[3].Part); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter AdvancedSequencerTests -v n` +Expected: FAIL + +- [ ] **Step 3: Implement AdvancedSequencer** + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Sequencing +{ + public class AdvancedSequencer : IPartSequencer + { + private readonly SequenceParameters _parameters; + + public AdvancedSequencer(SequenceParameters parameters) + { + _parameters = parameters; + } + + public List Sequence(IReadOnlyList parts, Plate plate) + { + if (parts.Count == 0) + return new List(); + + var rows = GroupIntoRows(parts); + var exitPoint = PlateHelper.GetExitPoint(plate); + + // Sort rows by Y (bottom to top for Q1) + rows.Sort((a, b) => a[0].BoundingBox.Center.Y.CompareTo(b[0].BoundingBox.Center.Y)); + + var result = new List(); + var leftToRight = exitPoint.X > plate.Size.Width * 0.5; + + for (var r = 0; r < rows.Count; r++) + { + var row = rows[r]; + + if (leftToRight) + row.Sort((a, b) => a.BoundingBox.Center.X.CompareTo(b.BoundingBox.Center.X)); + else + row.Sort((a, b) => b.BoundingBox.Center.X.CompareTo(a.BoundingBox.Center.X)); + + foreach (var part in row) + result.Add(new SequencedPart { Part = part }); + + if (_parameters.AlternateRowsColumns) + leftToRight = !leftToRight; + } + + return result; + } + + private List> GroupIntoRows(IReadOnlyList parts) + { + var sorted = parts.OrderBy(p => p.BoundingBox.Center.Y).ToList(); + var rows = new List>(); + var currentRow = new List { sorted[0] }; + + for (var i = 1; i < sorted.Count; i++) + { + var prevY = sorted[i - 1].BoundingBox.Center.Y; + var currY = sorted[i].BoundingBox.Center.Y; + + if (currY - prevY > _parameters.MinDistanceBetweenRowsColumns) + { + rows.Add(currentRow); + currentRow = new List(); + } + + currentRow.Add(sorted[i]); + } + + rows.Add(currentRow); + return rows; + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter AdvancedSequencerTests -v n` +Expected: 2 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/Sequencing/AdvancedSequencer.cs OpenNest.Engine.Tests/Sequencing/AdvancedSequencerTests.cs +git commit -m "feat: add AdvancedSequencer with row grouping and serpentine" +``` + +--- + +### Task 11: PartSequencerFactory + +**Files:** +- Create: `OpenNest.Engine/Sequencing/PartSequencerFactory.cs` +- Test: `OpenNest.Engine.Tests/Sequencing/PartSequencerFactoryTests.cs` + +- [ ] **Step 1: Write the tests** + +```csharp +using System; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.Sequencing; +using Xunit; + +namespace OpenNest.Engine.Tests.Sequencing; + +public class PartSequencerFactoryTests +{ + [Theory] + [InlineData(SequenceMethod.RightSide, typeof(RightSideSequencer))] + [InlineData(SequenceMethod.LeftSide, typeof(LeftSideSequencer))] + [InlineData(SequenceMethod.BottomSide, typeof(BottomSideSequencer))] + [InlineData(SequenceMethod.EdgeStart, typeof(EdgeStartSequencer))] + [InlineData(SequenceMethod.LeastCode, typeof(LeastCodeSequencer))] + [InlineData(SequenceMethod.Advanced, typeof(AdvancedSequencer))] + public void Create_ReturnsCorrectType(SequenceMethod method, Type expectedType) + { + var parameters = new SequenceParameters { Method = method }; + var sequencer = PartSequencerFactory.Create(parameters); + Assert.IsType(expectedType, sequencer); + } + + [Fact] + public void Create_RightSideAlt_Throws() + { + var parameters = new SequenceParameters { Method = SequenceMethod.RightSideAlt }; + Assert.Throws(() => PartSequencerFactory.Create(parameters)); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter PartSequencerFactoryTests -v n` +Expected: FAIL + +- [ ] **Step 3: Implement PartSequencerFactory** + +```csharp +using System; +using OpenNest.CNC.CuttingStrategy; + +namespace OpenNest.Engine.Sequencing +{ + public static class PartSequencerFactory + { + public static IPartSequencer Create(SequenceParameters parameters) + { + return parameters.Method switch + { + SequenceMethod.RightSide => new RightSideSequencer(), + SequenceMethod.LeftSide => new LeftSideSequencer(), + SequenceMethod.BottomSide => new BottomSideSequencer(), + SequenceMethod.EdgeStart => new EdgeStartSequencer(), + SequenceMethod.LeastCode => new LeastCodeSequencer(), + SequenceMethod.Advanced => new AdvancedSequencer(parameters), + _ => throw new NotSupportedException($"SequenceMethod {parameters.Method} is not supported") + }; + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter PartSequencerFactoryTests -v n` +Expected: 7 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/Sequencing/PartSequencerFactory.cs OpenNest.Engine.Tests/Sequencing/PartSequencerFactoryTests.cs +git commit -m "feat: add PartSequencerFactory" +``` + +--- + +## Chunk 3: Rapid Planning and Orchestrator + +### Task 12: IRapidPlanner, RapidPath, and SafeHeightRapidPlanner + +**Files:** +- Create: `OpenNest.Engine/RapidPlanning/IRapidPlanner.cs` +- Create: `OpenNest.Engine/RapidPlanning/RapidPath.cs` +- Create: `OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs` +- Test: `OpenNest.Engine.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +using System.Collections.Generic; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.RapidPlanning; + +public class SafeHeightRapidPlannerTests +{ + [Fact] + public void AlwaysReturnsHeadUp() + { + var planner = new SafeHeightRapidPlanner(); + var from = new Vector(10, 10); + var to = new Vector(50, 50); + var cutAreas = new List(); + + var result = planner.Plan(from, to, cutAreas); + + Assert.True(result.HeadUp); + Assert.Empty(result.Waypoints); + } + + [Fact] + public void ReturnsHeadUp_EvenWithCutAreas() + { + var planner = new SafeHeightRapidPlanner(); + var from = new Vector(0, 0); + var to = new Vector(10, 10); + + // Add a cut area (doesn't matter — always head up) + var shape = new Shape(); + shape.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20))); + var cutAreas = new List { shape }; + + var result = planner.Plan(from, to, cutAreas); + + Assert.True(result.HeadUp); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter SafeHeightRapidPlannerTests -v n` +Expected: FAIL + +- [ ] **Step 3: Create IRapidPlanner.cs** + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public interface IRapidPlanner + { + RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas); + } +} +``` + +- [ ] **Step 4: Create RapidPath.cs** + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public readonly struct RapidPath + { + public bool HeadUp { get; init; } + public List Waypoints { get; init; } + } +} +``` + +- [ ] **Step 5: Create SafeHeightRapidPlanner.cs** + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public class SafeHeightRapidPlanner : IRapidPlanner + { + public RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas) + { + return new RapidPath + { + HeadUp = true, + Waypoints = new List() + }; + } + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter SafeHeightRapidPlannerTests -v n` +Expected: 2 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest.Engine/RapidPlanning/ OpenNest.Engine.Tests/RapidPlanning/ +git commit -m "feat: add IRapidPlanner, RapidPath, and SafeHeightRapidPlanner" +``` + +--- + +### Task 13: DirectRapidPlanner + +**Files:** +- Create: `OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs` +- Test: `OpenNest.Engine.Tests/RapidPlanning/DirectRapidPlannerTests.cs` + +- [ ] **Step 1: Write the tests** + +```csharp +using System.Collections.Generic; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.RapidPlanning; + +public class DirectRapidPlannerTests +{ + [Fact] + public void NoCutAreas_ReturnsHeadDown() + { + var planner = new DirectRapidPlanner(); + var result = planner.Plan(new Vector(0, 0), new Vector(10, 10), new List()); + + Assert.False(result.HeadUp); + Assert.Empty(result.Waypoints); + } + + [Fact] + public void ClearPath_ReturnsHeadDown() + { + var planner = new DirectRapidPlanner(); + + // Cut area is off to the side — path doesn't cross it + var cutArea = new Shape(); + cutArea.Entities.Add(new Line(new Vector(50, 0), new Vector(50, 10))); + cutArea.Entities.Add(new Line(new Vector(50, 10), new Vector(60, 10))); + cutArea.Entities.Add(new Line(new Vector(60, 10), new Vector(60, 0))); + cutArea.Entities.Add(new Line(new Vector(60, 0), new Vector(50, 0))); + + var result = planner.Plan( + new Vector(0, 0), new Vector(10, 10), + new List { cutArea }); + + Assert.False(result.HeadUp); + } + + [Fact] + public void BlockedPath_ReturnsHeadUp() + { + var planner = new DirectRapidPlanner(); + + // Cut area directly between from and to + var cutArea = new Shape(); + cutArea.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20))); + cutArea.Entities.Add(new Line(new Vector(5, 20), new Vector(6, 20))); + cutArea.Entities.Add(new Line(new Vector(6, 20), new Vector(6, 0))); + cutArea.Entities.Add(new Line(new Vector(6, 0), new Vector(5, 0))); + + var result = planner.Plan( + new Vector(0, 10), new Vector(10, 10), + new List { cutArea }); + + Assert.True(result.HeadUp); + Assert.Empty(result.Waypoints); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter DirectRapidPlannerTests -v n` +Expected: FAIL + +- [ ] **Step 3: Implement DirectRapidPlanner** + +Uses `Shape.Intersects(Line)` — a public method on `Shape` that delegates to `Intersect.Intersects(Line, Shape, out pts)`. + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public class DirectRapidPlanner : IRapidPlanner + { + public RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas) + { + var travelLine = new Line(from, to); + + foreach (var cutArea in cutAreas) + { + if (cutArea.Intersects(travelLine)) + { + return new RapidPath + { + HeadUp = true, + Waypoints = new List() + }; + } + } + + return new RapidPath + { + HeadUp = false, + Waypoints = new List() + }; + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter DirectRapidPlannerTests -v n` +Expected: 3 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs OpenNest.Engine.Tests/RapidPlanning/DirectRapidPlannerTests.cs +git commit -m "feat: add DirectRapidPlanner with line-shape intersection check" +``` + +--- + +### Task 14: PlateResult and ProcessedPart + +**Files:** +- Create: `OpenNest.Engine/PlateResult.cs` + +- [ ] **Step 1: Create PlateResult.cs** + +```csharp +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.RapidPlanning; + +namespace OpenNest.Engine +{ + public class PlateResult + { + public List Parts { get; init; } + } + + public readonly struct ProcessedPart + { + public Part Part { get; init; } + public Program ProcessedProgram { get; init; } + public RapidPath RapidPath { get; init; } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/PlateResult.cs +git commit -m "feat: add PlateResult and ProcessedPart" +``` + +--- + +### Task 15: PlateProcessor orchestrator + +**Files:** +- Create: `OpenNest.Engine/PlateProcessor.cs` +- Test: `OpenNest.Engine.Tests/PlateProcessorTests.cs` + +- [ ] **Step 1: Write the tests** + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests; + +public class PlateProcessorTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y, size: 2); + + [Fact] + public void Process_ReturnsAllParts() + { + var plate = new Plate(60, 120); + plate.Parts.Add(MakePartAt(10, 10)); + plate.Parts.Add(MakePartAt(30, 30)); + plate.Parts.Add(MakePartAt(50, 50)); + + var processor = new PlateProcessor + { + Sequencer = new RightSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Equal(3, result.Parts.Count); + } + + [Fact] + public void Process_PreservesSequenceOrder() + { + var plate = new Plate(60, 120); + var left = MakePartAt(5, 10); + var right = MakePartAt(50, 10); + plate.Parts.Add(left); + plate.Parts.Add(right); + + var processor = new PlateProcessor + { + Sequencer = new RightSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + // RightSide sorts X descending — right part first + Assert.Same(right, result.Parts[0].Part); + Assert.Same(left, result.Parts[1].Part); + } + + [Fact] + public void Process_SkipsCuttingStrategy_WhenManualLeadIns() + { + var plate = new Plate(60, 120); + var part = MakePartAt(10, 10); + part.HasManualLeadIns = true; + plate.Parts.Add(part); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + CuttingStrategy = new ContourCuttingStrategy + { + Parameters = new CuttingParameters() + }, + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + // Part program should be passed through unchanged + Assert.Same(part.Program, result.Parts[0].ProcessedProgram); + } + + [Fact] + public void Process_DoesNotMutatePart() + { + var plate = new Plate(60, 120); + var part = MakePartAt(10, 10); + var originalProgram = part.Program; + plate.Parts.Add(part); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + // Part.Program should be untouched + Assert.Same(originalProgram, part.Program); + } + + [Fact] + public void Process_NoCuttingStrategy_PassesProgramThrough() + { + var plate = new Plate(60, 120); + var part = MakePartAt(10, 10); + plate.Parts.Add(part); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + // No CuttingStrategy set + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Same(part.Program, result.Parts[0].ProcessedProgram); + } + + [Fact] + public void Process_EmptyPlate_ReturnsEmptyResult() + { + var plate = new Plate(60, 120); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Empty(result.Parts); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter PlateProcessorTests -v n` +Expected: FAIL — `PlateProcessor` class doesn't exist + +- [ ] **Step 3: Implement PlateProcessor** + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; + +namespace OpenNest.Engine +{ + public class PlateProcessor + { + public IPartSequencer Sequencer { get; set; } + public ContourCuttingStrategy CuttingStrategy { get; set; } + public IRapidPlanner RapidPlanner { get; set; } + + public PlateResult Process(Plate plate) + { + var ordered = Sequencer.Sequence(plate.Parts.ToList(), plate); + + var results = new List(); + var cutAreas = new List(); + var currentPoint = PlateHelper.GetExitPoint(plate); + + foreach (var sequenced in ordered) + { + var part = sequenced.Part; + var localApproach = ToPartLocal(currentPoint, part); + + CuttingResult cutResult; + if (!part.HasManualLeadIns && CuttingStrategy != null) + { + cutResult = CuttingStrategy.Apply(part.Program, localApproach); + } + else + { + cutResult = new CuttingResult + { + Program = part.Program, + LastCutPoint = GetProgramEndPoint(part.Program) + }; + } + + var piercePoint = ToPlateSpace(GetProgramStartPoint(cutResult.Program), part); + var rapid = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas); + + results.Add(new ProcessedPart + { + Part = part, + ProcessedProgram = cutResult.Program, + RapidPath = rapid + }); + + var perimeter = GetPartPerimeter(part); + if (perimeter != null) + cutAreas.Add(perimeter); + + currentPoint = ToPlateSpace(cutResult.LastCutPoint, part); + } + + return new PlateResult { Parts = results }; + } + + private static Vector ToPartLocal(Vector platePoint, Part part) + { + return platePoint - part.Location; + } + + private static Vector ToPlateSpace(Vector localPoint, Part part) + { + return localPoint + part.Location; + } + + private static Vector GetProgramStartPoint(Program program) + { + var firstMove = program.Codes.OfType().FirstOrDefault(); + return firstMove?.EndPoint ?? new Vector(); + } + + private static Vector GetProgramEndPoint(Program program) + { + var lastMove = program.Codes.OfType().LastOrDefault(); + return lastMove?.EndPoint ?? new Vector(); + } + + private static Shape GetPartPerimeter(Part part) + { + var entities = part.Program.ToGeometry(); + if (entities == null || entities.Count == 0) + return null; + + var profile = new ShapeProfile(entities); + if (profile.Perimeter == null) + return null; + + var perimeter = profile.Perimeter; + perimeter.Offset(part.Location); + return perimeter; + } + } +} +``` + +Note: `PlateHelper.GetExitPoint` is already defined in Task 8. If `PlateHelper` was created in the `Sequencing` namespace, make it `internal` and accessible via `using OpenNest.Engine.Sequencing`. Alternatively, move it to the `OpenNest.Engine` namespace root. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter PlateProcessorTests -v n` +Expected: 6 tests PASS + +- [ ] **Step 5: Run all tests** + +Run: `dotnet test OpenNest.Engine.Tests -v n` +Expected: All tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Engine/PlateProcessor.cs OpenNest.Engine.Tests/PlateProcessorTests.cs +git commit -m "feat: add PlateProcessor orchestrator" +``` + +--- + +## Chunk 4: Final verification + +### Task 16: Full build and test run + +- [ ] **Step 1: Build entire solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded with no errors + +- [ ] **Step 2: Run all tests** + +Run: `dotnet test OpenNest.Engine.Tests -v n` +Expected: All tests pass + +- [ ] **Step 3: Commit any remaining changes** + +If any files were missed, stage and commit them. From 1e093a8413f5cb74c67b46825a0679375e53a874 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:21:07 -0400 Subject: [PATCH 096/116] chore: add OpenNest.Engine.Tests xUnit project Co-Authored-By: Claude Sonnet 4.6 --- .../OpenNest.Engine.Tests.csproj | 28 +++++++++++++++++++ OpenNest.sln | 14 ++++++++++ 2 files changed, 42 insertions(+) create mode 100644 OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj diff --git a/OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj b/OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj new file mode 100644 index 0000000..2bf5057 --- /dev/null +++ b/OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0-windows + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/OpenNest.sln b/OpenNest.sln index 69cf786..2f2414f 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Console", "OpenNes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Training", "OpenNest.Training\OpenNest.Training.csproj", "{249BF728-25DD-4863-8266-207ACD26E964}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{B2BAB0D4-C389-4571-8A5B-0DEF8C933551}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -125,6 +127,18 @@ Global {249BF728-25DD-4863-8266-207ACD26E964}.Release|x64.Build.0 = Release|Any CPU {249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.ActiveCfg = Release|Any CPU {249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.Build.0 = Release|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|x64.Build.0 = Debug|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|x86.Build.0 = Debug|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|Any CPU.Build.0 = Release|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|x64.ActiveCfg = Release|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|x64.Build.0 = Release|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|x86.ActiveCfg = Release|Any CPU + {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From b49cdc3e557ae43b2a45abd334a2b33a029b1436 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:21:26 -0400 Subject: [PATCH 097/116] chore: add shared test helpers for Engine tests Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine.Tests/TestHelpers.cs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 OpenNest.Engine.Tests/TestHelpers.cs diff --git a/OpenNest.Engine.Tests/TestHelpers.cs b/OpenNest.Engine.Tests/TestHelpers.cs new file mode 100644 index 0000000..56fc882 --- /dev/null +++ b/OpenNest.Engine.Tests/TestHelpers.cs @@ -0,0 +1,27 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests; + +internal static class TestHelpers +{ + public static Part MakePartAt(double x, double y, double size = 1) + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(size, 0))); + pgm.Codes.Add(new LinearMove(new Vector(size, size))); + pgm.Codes.Add(new LinearMove(new Vector(0, size))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + var drawing = new Drawing("test", pgm); + return new Part(drawing, new Vector(x, y)); + } + + public static Plate MakePlate(double width = 60, double length = 120, params Part[] parts) + { + var plate = new Plate(width, length); + foreach (var p in parts) + plate.Parts.Add(p); + return plate; + } +} From ad877383ce3336d2e2d4e6032c48a3cd6adeb3ea Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:22:50 -0400 Subject: [PATCH 098/116] feat: add CuttingResult struct Co-Authored-By: Claude Sonnet 4.6 --- .../CNC/CuttingStrategy/CuttingResult.cs | 11 +++++++++ OpenNest.Engine.Tests/CuttingResultTests.cs | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs create mode 100644 OpenNest.Engine.Tests/CuttingResultTests.cs diff --git a/OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs b/OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs new file mode 100644 index 0000000..933db14 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs @@ -0,0 +1,11 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public readonly struct CuttingResult + { + public Program Program { get; init; } + public Vector LastCutPoint { get; init; } + } +} diff --git a/OpenNest.Engine.Tests/CuttingResultTests.cs b/OpenNest.Engine.Tests/CuttingResultTests.cs new file mode 100644 index 0000000..ab600dd --- /dev/null +++ b/OpenNest.Engine.Tests/CuttingResultTests.cs @@ -0,0 +1,23 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests; + +public class CuttingResultTests +{ + [Fact] + public void CuttingResult_StoresValues() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(1, 2))); + var point = new Vector(3, 4); + + var result = new CuttingResult { Program = pgm, LastCutPoint = point }; + + Assert.Same(pgm, result.Program); + Assert.Equal(3, result.LastCutPoint.X); + Assert.Equal(4, result.LastCutPoint.Y); + } +} From 62140789a7021b07c57e2ac4aed4718a5aea5e11 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:23:21 -0400 Subject: [PATCH 099/116] feat: add Part.HasManualLeadIns flag Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Core/Part.cs | 2 ++ OpenNest.Engine.Tests/PartFlagTests.cs | 32 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 OpenNest.Engine.Tests/PartFlagTests.cs diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index 56598f4..37b12f0 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -51,6 +51,8 @@ namespace OpenNest public Program Program { get; private set; } + public bool HasManualLeadIns { get; set; } + /// /// Gets the rotation of the part in radians. /// diff --git a/OpenNest.Engine.Tests/PartFlagTests.cs b/OpenNest.Engine.Tests/PartFlagTests.cs new file mode 100644 index 0000000..89b65e4 --- /dev/null +++ b/OpenNest.Engine.Tests/PartFlagTests.cs @@ -0,0 +1,32 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests; + +public class PartFlagTests +{ + [Fact] + public void HasManualLeadIns_DefaultsFalse() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + var drawing = new Drawing("test", pgm); + var part = new Part(drawing); + + Assert.False(part.HasManualLeadIns); + } + + [Fact] + public void HasManualLeadIns_CanBeSet() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + var drawing = new Drawing("test", pgm); + var part = new Part(drawing); + + part.HasManualLeadIns = true; + + Assert.True(part.HasManualLeadIns); + } +} From 1757e9e01d4298bc2f0df5c16d14c08fd52dda99 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:23:53 -0400 Subject: [PATCH 100/116] refactor: change ContourCuttingStrategy.Apply to accept approachPoint Co-Authored-By: Claude Sonnet 4.6 --- .../CuttingStrategy/ContourCuttingStrategy.cs | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 7ea880b..30455be 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -7,9 +7,9 @@ namespace OpenNest.CNC.CuttingStrategy { public CuttingParameters Parameters { get; set; } - public Program Apply(Program partProgram, Plate plate) + public CuttingResult Apply(Program partProgram, Vector approachPoint) { - var exitPoint = GetExitPoint(plate); + var exitPoint = approachPoint; var entities = partProgram.ToGeometry(); var profile = new ShapeProfile(entities); @@ -44,9 +44,12 @@ namespace OpenNest.CNC.CuttingStrategy currentPoint = closestPt; } + var lastCutPoint = exitPoint; + // Perimeter last { var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity); + lastCutPoint = perimeterPt; var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External); var winding = DetermineWinding(profile.Perimeter); @@ -60,21 +63,10 @@ namespace OpenNest.CNC.CuttingStrategy result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding)); } - return result; - } - - private Vector GetExitPoint(Plate plate) - { - var w = plate.Size.Width; - var l = plate.Size.Length; - - return plate.Quadrant switch + return new CuttingResult { - 1 => new Vector(w, l), // Q1 origin BottomLeft -> exit TopRight - 2 => new Vector(0, l), // Q2 origin BottomRight -> exit TopLeft - 3 => new Vector(0, 0), // Q3 origin TopRight -> exit BottomLeft - 4 => new Vector(w, 0), // Q4 origin TopLeft -> exit BottomRight - _ => new Vector(w, l) + Program = result, + LastCutPoint = lastCutPoint }; } From 00940d1b6e462bf621e404f6e7be6ce6e4faff15 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:25:22 -0400 Subject: [PATCH 101/116] chore: rename test project to OpenNest.Tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed OpenNest.Engine.Tests → OpenNest.Tests (directory, .csproj, namespaces in all .cs files). Added OpenNest.IO project reference. Co-Authored-By: Claude Sonnet 4.6 --- .../CuttingResultTests.cs | 2 +- .../OpenNest.Tests.csproj | 1 + .../PartFlagTests.cs | 2 +- .../TestHelpers.cs | 2 +- OpenNest.sln | 26 +++++++++---------- 5 files changed, 17 insertions(+), 16 deletions(-) rename {OpenNest.Engine.Tests => OpenNest.Tests}/CuttingResultTests.cs (94%) rename OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj => OpenNest.Tests/OpenNest.Tests.csproj (92%) rename {OpenNest.Engine.Tests => OpenNest.Tests}/PartFlagTests.cs (95%) rename {OpenNest.Engine.Tests => OpenNest.Tests}/TestHelpers.cs (96%) diff --git a/OpenNest.Engine.Tests/CuttingResultTests.cs b/OpenNest.Tests/CuttingResultTests.cs similarity index 94% rename from OpenNest.Engine.Tests/CuttingResultTests.cs rename to OpenNest.Tests/CuttingResultTests.cs index ab600dd..58cbefc 100644 --- a/OpenNest.Engine.Tests/CuttingResultTests.cs +++ b/OpenNest.Tests/CuttingResultTests.cs @@ -3,7 +3,7 @@ using OpenNest.CNC.CuttingStrategy; using OpenNest.Geometry; using Xunit; -namespace OpenNest.Engine.Tests; +namespace OpenNest.Tests; public class CuttingResultTests { diff --git a/OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj b/OpenNest.Tests/OpenNest.Tests.csproj similarity index 92% rename from OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj rename to OpenNest.Tests/OpenNest.Tests.csproj index 2bf5057..ec1b229 100644 --- a/OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj +++ b/OpenNest.Tests/OpenNest.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/OpenNest.Engine.Tests/PartFlagTests.cs b/OpenNest.Tests/PartFlagTests.cs similarity index 95% rename from OpenNest.Engine.Tests/PartFlagTests.cs rename to OpenNest.Tests/PartFlagTests.cs index 89b65e4..f140dd2 100644 --- a/OpenNest.Engine.Tests/PartFlagTests.cs +++ b/OpenNest.Tests/PartFlagTests.cs @@ -2,7 +2,7 @@ using OpenNest.CNC; using OpenNest.Geometry; using Xunit; -namespace OpenNest.Engine.Tests; +namespace OpenNest.Tests; public class PartFlagTests { diff --git a/OpenNest.Engine.Tests/TestHelpers.cs b/OpenNest.Tests/TestHelpers.cs similarity index 96% rename from OpenNest.Engine.Tests/TestHelpers.cs rename to OpenNest.Tests/TestHelpers.cs index 56fc882..8d68f0b 100644 --- a/OpenNest.Engine.Tests/TestHelpers.cs +++ b/OpenNest.Tests/TestHelpers.cs @@ -1,7 +1,7 @@ using OpenNest.CNC; using OpenNest.Geometry; -namespace OpenNest.Engine.Tests; +namespace OpenNest.Tests; internal static class TestHelpers { diff --git a/OpenNest.sln b/OpenNest.sln index 2f2414f..d0a8d89 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -19,7 +19,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Console", "OpenNes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Training", "OpenNest.Training\OpenNest.Training.csproj", "{249BF728-25DD-4863-8266-207ACD26E964}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{B2BAB0D4-C389-4571-8A5B-0DEF8C933551}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Tests", "OpenNest.Tests\OpenNest.Tests.csproj", "{03539EB7-9DB2-4634-A6FD-F094B9603596}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -127,18 +127,18 @@ Global {249BF728-25DD-4863-8266-207ACD26E964}.Release|x64.Build.0 = Release|Any CPU {249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.ActiveCfg = Release|Any CPU {249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.Build.0 = Release|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|x64.ActiveCfg = Debug|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|x64.Build.0 = Debug|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|x86.ActiveCfg = Debug|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Debug|x86.Build.0 = Debug|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|Any CPU.Build.0 = Release|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|x64.ActiveCfg = Release|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|x64.Build.0 = Release|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|x86.ActiveCfg = Release|Any CPU - {B2BAB0D4-C389-4571-8A5B-0DEF8C933551}.Release|x86.Build.0 = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x64.ActiveCfg = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x64.Build.0 = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x86.ActiveCfg = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x86.Build.0 = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|Any CPU.Build.0 = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x64.ActiveCfg = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x64.Build.0 = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.ActiveCfg = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 4f8febde23c2ff680131f643a56994276b7d26d8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:27:02 -0400 Subject: [PATCH 102/116] feat: add IPartSequencer interface and SequencedPart Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/Sequencing/IPartSequencer.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 OpenNest.Engine/Sequencing/IPartSequencer.cs diff --git a/OpenNest.Engine/Sequencing/IPartSequencer.cs b/OpenNest.Engine/Sequencing/IPartSequencer.cs new file mode 100644 index 0000000..79b0c3e --- /dev/null +++ b/OpenNest.Engine/Sequencing/IPartSequencer.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace OpenNest.Engine.Sequencing +{ + public readonly struct SequencedPart + { + public Part Part { get; init; } + } + + public interface IPartSequencer + { + List Sequence(IReadOnlyList parts, Plate plate); + } +} From d0351ab765102c7a406c289c900d959324234b0b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:27:57 -0400 Subject: [PATCH 103/116] feat: add directional part sequencers (RightSide, LeftSide, BottomSide) Co-Authored-By: Claude Sonnet 4.6 --- .../Sequencing/BottomSideSequencer.cs | 17 +++++ .../Sequencing/LeftSideSequencer.cs | 17 +++++ .../Sequencing/RightSideSequencer.cs | 17 +++++ .../Sequencing/DirectionalSequencerTests.cs | 75 +++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 OpenNest.Engine/Sequencing/BottomSideSequencer.cs create mode 100644 OpenNest.Engine/Sequencing/LeftSideSequencer.cs create mode 100644 OpenNest.Engine/Sequencing/RightSideSequencer.cs create mode 100644 OpenNest.Tests/Sequencing/DirectionalSequencerTests.cs diff --git a/OpenNest.Engine/Sequencing/BottomSideSequencer.cs b/OpenNest.Engine/Sequencing/BottomSideSequencer.cs new file mode 100644 index 0000000..de06053 --- /dev/null +++ b/OpenNest.Engine/Sequencing/BottomSideSequencer.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class BottomSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderBy(p => p.Location.Y) + .ThenBy(p => p.Location.X) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} diff --git a/OpenNest.Engine/Sequencing/LeftSideSequencer.cs b/OpenNest.Engine/Sequencing/LeftSideSequencer.cs new file mode 100644 index 0000000..ca0f20a --- /dev/null +++ b/OpenNest.Engine/Sequencing/LeftSideSequencer.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class LeftSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderBy(p => p.Location.X) + .ThenBy(p => p.Location.Y) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} diff --git a/OpenNest.Engine/Sequencing/RightSideSequencer.cs b/OpenNest.Engine/Sequencing/RightSideSequencer.cs new file mode 100644 index 0000000..f804a38 --- /dev/null +++ b/OpenNest.Engine/Sequencing/RightSideSequencer.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class RightSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderByDescending(p => p.Location.X) + .ThenBy(p => p.Location.Y) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} diff --git a/OpenNest.Tests/Sequencing/DirectionalSequencerTests.cs b/OpenNest.Tests/Sequencing/DirectionalSequencerTests.cs new file mode 100644 index 0000000..0a64a98 --- /dev/null +++ b/OpenNest.Tests/Sequencing/DirectionalSequencerTests.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Sequencing; + +public class DirectionalSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + private static Plate MakePlate(params Part[] parts) => TestHelpers.MakePlate(60, 120, parts); + + [Fact] + public void RightSide_SortsXDescending() + { + var a = MakePartAt(10, 5); + var b = MakePartAt(30, 5); + var c = MakePartAt(20, 5); + var plate = MakePlate(a, b, c); + + var sequencer = new RightSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(b, result[0].Part); + Assert.Same(c, result[1].Part); + Assert.Same(a, result[2].Part); + } + + [Fact] + public void LeftSide_SortsXAscending() + { + var a = MakePartAt(10, 5); + var b = MakePartAt(30, 5); + var c = MakePartAt(20, 5); + var plate = MakePlate(a, b, c); + + var sequencer = new LeftSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(a, result[0].Part); + Assert.Same(c, result[1].Part); + Assert.Same(b, result[2].Part); + } + + [Fact] + public void BottomSide_SortsYAscending() + { + var a = MakePartAt(5, 20); + var b = MakePartAt(5, 5); + var c = MakePartAt(5, 10); + var plate = MakePlate(a, b, c); + + var sequencer = new BottomSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(b, result[0].Part); + Assert.Same(c, result[1].Part); + Assert.Same(a, result[2].Part); + } + + [Fact] + public void RightSide_TiesBrokenByPerpendicularAxis() + { + var a = MakePartAt(10, 20); + var b = MakePartAt(10, 5); + var plate = MakePlate(a, b); + + var sequencer = new RightSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(b, result[0].Part); + Assert.Same(a, result[1].Part); + } +} From f568308d1a444b4bf3cf2b65a26ad0cad0d95799 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:32:35 -0400 Subject: [PATCH 104/116] feat: add EdgeStartSequencer Co-Authored-By: Claude Sonnet 4.6 --- .../Sequencing/EdgeStartSequencer.cs | 36 +++++++++++++++++++ .../Sequencing/EdgeStartSequencerTests.cs | 31 ++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 OpenNest.Engine/Sequencing/EdgeStartSequencer.cs create mode 100644 OpenNest.Tests/Sequencing/EdgeStartSequencerTests.cs diff --git a/OpenNest.Engine/Sequencing/EdgeStartSequencer.cs b/OpenNest.Engine/Sequencing/EdgeStartSequencer.cs new file mode 100644 index 0000000..187a599 --- /dev/null +++ b/OpenNest.Engine/Sequencing/EdgeStartSequencer.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class EdgeStartSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + // Plate(width, length) stores Size with Width/Length swapped internally. + // Reconstruct the logical plate box using the BoundingBox origin and the + // corrected extents: Size.Length = X-extent, Size.Width = Y-extent. + var origin = plate.BoundingBox(false); + var plateBox = new OpenNest.Geometry.Box( + origin.X, origin.Y, + plate.Size.Length, + plate.Size.Width); + + return parts + .OrderBy(p => MinEdgeDistance(p.BoundingBox.Center, plateBox)) + .ThenBy(p => p.Location.X) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + + private static double MinEdgeDistance(OpenNest.Geometry.Vector center, OpenNest.Geometry.Box plateBox) + { + var distLeft = center.X - plateBox.Left; + var distRight = plateBox.Right - center.X; + var distBottom = center.Y - plateBox.Bottom; + var distTop = plateBox.Top - center.Y; + + return System.Math.Min(System.Math.Min(distLeft, distRight), System.Math.Min(distBottom, distTop)); + } + } +} diff --git a/OpenNest.Tests/Sequencing/EdgeStartSequencerTests.cs b/OpenNest.Tests/Sequencing/EdgeStartSequencerTests.cs new file mode 100644 index 0000000..3f002de --- /dev/null +++ b/OpenNest.Tests/Sequencing/EdgeStartSequencerTests.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Sequencing; + +public class EdgeStartSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void SortsByDistanceFromNearestEdge() + { + var plate = new Plate(60, 120); + var edgePart = MakePartAt(1, 1); + var centerPart = MakePartAt(25, 55); + var midPart = MakePartAt(10, 10); + plate.Parts.Add(edgePart); + plate.Parts.Add(centerPart); + plate.Parts.Add(midPart); + + var sequencer = new EdgeStartSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(edgePart, result[0].Part); + Assert.Same(midPart, result[1].Part); + Assert.Same(centerPart, result[2].Part); + } +} From 7edf6ee8437714b43ba919e70eaa5801a74e4791 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:35:19 -0400 Subject: [PATCH 105/116] feat: add LeastCodeSequencer with nearest-neighbor and 2-opt Co-Authored-By: Claude Sonnet 4.6 --- .../Sequencing/LeastCodeSequencer.cs | 139 ++++++++++++++++++ OpenNest.Engine/Sequencing/PlateHelper.cs | 22 +++ .../Sequencing/LeastCodeSequencerTests.cs | 61 ++++++++ 3 files changed, 222 insertions(+) create mode 100644 OpenNest.Engine/Sequencing/LeastCodeSequencer.cs create mode 100644 OpenNest.Engine/Sequencing/PlateHelper.cs create mode 100644 OpenNest.Tests/Sequencing/LeastCodeSequencerTests.cs diff --git a/OpenNest.Engine/Sequencing/LeastCodeSequencer.cs b/OpenNest.Engine/Sequencing/LeastCodeSequencer.cs new file mode 100644 index 0000000..63b0e2a --- /dev/null +++ b/OpenNest.Engine/Sequencing/LeastCodeSequencer.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using OpenNest.Math; + +namespace OpenNest.Engine.Sequencing +{ + public class LeastCodeSequencer : IPartSequencer + { + private readonly int _maxIterations; + + public LeastCodeSequencer(int maxIterations = 100) + { + _maxIterations = maxIterations; + } + + public List Sequence(IReadOnlyList parts, Plate plate) + { + if (parts.Count == 0) + return new List(); + + var exit = PlateHelper.GetExitPoint(plate); + var ordered = NearestNeighbor(parts, exit); + TwoOpt(ordered, exit); + + var result = new List(ordered.Count); + foreach (var p in ordered) + result.Add(new SequencedPart { Part = p }); + return result; + } + + private static List NearestNeighbor(IReadOnlyList parts, OpenNest.Geometry.Vector exit) + { + var remaining = new List(parts); + var ordered = new List(parts.Count); + + var current = exit; + while (remaining.Count > 0) + { + var bestIdx = 0; + var bestDist = Distance(current, Center(remaining[0])); + + for (var i = 1; i < remaining.Count; i++) + { + var d = Distance(current, Center(remaining[i])); + if (d < bestDist - Tolerance.Epsilon) + { + bestDist = d; + bestIdx = i; + } + } + + var next = remaining[bestIdx]; + ordered.Add(next); + remaining.RemoveAt(bestIdx); + current = Center(next); + } + + return ordered; + } + + private void TwoOpt(List ordered, OpenNest.Geometry.Vector exit) + { + var n = ordered.Count; + if (n < 3) + return; + + for (var iter = 0; iter < _maxIterations; iter++) + { + var improved = false; + + for (var i = 0; i < n - 1; i++) + { + for (var j = i + 1; j < n; j++) + { + var before = RouteDistance(ordered, exit, i, j); + Reverse(ordered, i, j); + var after = RouteDistance(ordered, exit, i, j); + + if (after < before - Tolerance.Epsilon) + { + improved = true; + } + else + { + // Revert + Reverse(ordered, i, j); + } + } + } + + if (!improved) + break; + } + } + + /// + /// Computes the total distance of the route starting from exit through all parts. + /// Only the segment around the reversed segment [i..j] needs to be checked, + /// but here we compute the full route cost for correctness. + /// + private static double RouteDistance(List ordered, OpenNest.Geometry.Vector exit, int i, int j) + { + // Full route distance: exit -> ordered[0] -> ... -> ordered[n-1] + var total = 0.0; + var prev = exit; + foreach (var p in ordered) + { + var c = Center(p); + total += Distance(prev, c); + prev = c; + } + return total; + } + + private static void Reverse(List list, int i, int j) + { + while (i < j) + { + var tmp = list[i]; + list[i] = list[j]; + list[j] = tmp; + i++; + j--; + } + } + + private static OpenNest.Geometry.Vector Center(Part part) + { + return part.BoundingBox.Center; + } + + private static double Distance(OpenNest.Geometry.Vector a, OpenNest.Geometry.Vector b) + { + var dx = b.X - a.X; + var dy = b.Y - a.Y; + return System.Math.Sqrt(dx * dx + dy * dy); + } + } +} diff --git a/OpenNest.Engine/Sequencing/PlateHelper.cs b/OpenNest.Engine/Sequencing/PlateHelper.cs new file mode 100644 index 0000000..1a46327 --- /dev/null +++ b/OpenNest.Engine/Sequencing/PlateHelper.cs @@ -0,0 +1,22 @@ +using OpenNest.Geometry; + +namespace OpenNest.Engine.Sequencing +{ + internal static class PlateHelper + { + public static Vector GetExitPoint(Plate plate) + { + var w = plate.Size.Width; + var l = plate.Size.Length; + + return plate.Quadrant switch + { + 1 => new Vector(w, l), + 2 => new Vector(0, l), + 3 => new Vector(0, 0), + 4 => new Vector(w, 0), + _ => new Vector(w, l) + }; + } + } +} diff --git a/OpenNest.Tests/Sequencing/LeastCodeSequencerTests.cs b/OpenNest.Tests/Sequencing/LeastCodeSequencerTests.cs new file mode 100644 index 0000000..a5929c5 --- /dev/null +++ b/OpenNest.Tests/Sequencing/LeastCodeSequencerTests.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Sequencing; + +public class LeastCodeSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void NearestNeighbor_FromExitPoint() + { + var plate = new Plate(60, 120); + var farPart = MakePartAt(5, 5); + var nearPart = MakePartAt(55, 115); + plate.Parts.Add(farPart); + plate.Parts.Add(nearPart); + + var sequencer = new LeastCodeSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + // nearPart is closer to exit point, should come first + Assert.Same(nearPart, result[0].Part); + Assert.Same(farPart, result[1].Part); + } + + [Fact] + public void PreservesAllParts() + { + var plate = new Plate(60, 120); + for (var i = 0; i < 10; i++) + plate.Parts.Add(MakePartAt(i * 5, i * 10)); + + var sequencer = new LeastCodeSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Equal(10, result.Count); + } + + [Fact] + public void TwoOpt_ImprovesSolution() + { + var plate = new Plate(100, 100); + var a = MakePartAt(90, 90); + var b = MakePartAt(10, 80); + var c = MakePartAt(80, 10); + var d = MakePartAt(5, 5); + plate.Parts.Add(a); + plate.Parts.Add(b); + plate.Parts.Add(c); + plate.Parts.Add(d); + + var sequencer = new LeastCodeSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Equal(4, result.Count); + } +} From edc81ae45ec71b7627b97d1b1209ef6deb108194 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:36:07 -0400 Subject: [PATCH 106/116] feat: add AdvancedSequencer with row grouping and serpentine Co-Authored-By: Claude Sonnet 4.6 --- .../Sequencing/AdvancedSequencer.cs | 96 +++++++++++++++++++ .../Sequencing/AdvancedSequencerTests.cs | 69 +++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 OpenNest.Engine/Sequencing/AdvancedSequencer.cs create mode 100644 OpenNest.Tests/Sequencing/AdvancedSequencerTests.cs diff --git a/OpenNest.Engine/Sequencing/AdvancedSequencer.cs b/OpenNest.Engine/Sequencing/AdvancedSequencer.cs new file mode 100644 index 0000000..fa285aa --- /dev/null +++ b/OpenNest.Engine/Sequencing/AdvancedSequencer.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Math; + +namespace OpenNest.Engine.Sequencing +{ + public class AdvancedSequencer : IPartSequencer + { + private readonly SequenceParameters _parameters; + + public AdvancedSequencer(SequenceParameters parameters) + { + _parameters = parameters; + } + + public List Sequence(IReadOnlyList parts, Plate plate) + { + if (parts.Count == 0) + return new List(); + + var exit = PlateHelper.GetExitPoint(plate); + + // Group parts into rows by Y proximity + var rows = GroupIntoRows(parts, _parameters.MinDistanceBetweenRowsColumns); + + // Sort rows bottom-to-top (ascending Y) + rows.Sort((a, b) => a.RowY.CompareTo(b.RowY)); + + // Determine initial direction based on exit point + var leftToRight = exit.X > plate.Size.Width * 0.5; + + var result = new List(parts.Count); + foreach (var row in rows) + { + var sorted = leftToRight + ? row.Parts.OrderBy(p => p.BoundingBox.Center.X).ToList() + : row.Parts.OrderByDescending(p => p.BoundingBox.Center.X).ToList(); + + foreach (var p in sorted) + result.Add(new SequencedPart { Part = p }); + + if (_parameters.AlternateRowsColumns) + leftToRight = !leftToRight; + } + + return result; + } + + private static List GroupIntoRows(IReadOnlyList parts, double minDistance) + { + // Sort parts by Y center + var sorted = parts + .OrderBy(p => p.BoundingBox.Center.Y) + .ToList(); + + var rows = new List(); + + foreach (var part in sorted) + { + var y = part.BoundingBox.Center.Y; + var placed = false; + + foreach (var row in rows) + { + if (System.Math.Abs(y - row.RowY) <= minDistance + Tolerance.Epsilon) + { + row.Parts.Add(part); + placed = true; + break; + } + } + + if (!placed) + { + var row = new PartRow(y); + row.Parts.Add(part); + rows.Add(row); + } + } + + return rows; + } + + private class PartRow + { + public double RowY { get; } + public List Parts { get; } = new List(); + + public PartRow(double rowY) + { + RowY = rowY; + } + } + } +} diff --git a/OpenNest.Tests/Sequencing/AdvancedSequencerTests.cs b/OpenNest.Tests/Sequencing/AdvancedSequencerTests.cs new file mode 100644 index 0000000..69f540f --- /dev/null +++ b/OpenNest.Tests/Sequencing/AdvancedSequencerTests.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Sequencing; + +public class AdvancedSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void GroupsIntoRows_NoAlternate() + { + var plate = new Plate(100, 100); + var row1a = MakePartAt(10, 10); + var row1b = MakePartAt(30, 10); + var row2a = MakePartAt(10, 50); + var row2b = MakePartAt(30, 50); + plate.Parts.Add(row1a); + plate.Parts.Add(row1b); + plate.Parts.Add(row2a); + plate.Parts.Add(row2b); + + var parameters = new SequenceParameters + { + Method = SequenceMethod.Advanced, + MinDistanceBetweenRowsColumns = 5.0, + AlternateRowsColumns = false + }; + var sequencer = new AdvancedSequencer(parameters); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(row1a, result[0].Part); + Assert.Same(row1b, result[1].Part); + Assert.Same(row2a, result[2].Part); + Assert.Same(row2b, result[3].Part); + } + + [Fact] + public void SerpentineAlternatesDirection() + { + var plate = new Plate(100, 100); + var r1Left = MakePartAt(10, 10); + var r1Right = MakePartAt(30, 10); + var r2Left = MakePartAt(10, 50); + var r2Right = MakePartAt(30, 50); + plate.Parts.Add(r1Left); + plate.Parts.Add(r1Right); + plate.Parts.Add(r2Left); + plate.Parts.Add(r2Right); + + var parameters = new SequenceParameters + { + Method = SequenceMethod.Advanced, + MinDistanceBetweenRowsColumns = 5.0, + AlternateRowsColumns = true + }; + var sequencer = new AdvancedSequencer(parameters); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(r1Left, result[0].Part); + Assert.Same(r1Right, result[1].Part); + Assert.Same(r2Right, result[2].Part); + Assert.Same(r2Left, result[3].Part); + } +} From c1e21abd45714a8da9d4ffdd296a1d44d7534c6f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:36:41 -0400 Subject: [PATCH 107/116] feat: add PartSequencerFactory Co-Authored-By: Claude Sonnet 4.6 --- .../Sequencing/PartSequencerFactory.cs | 23 ++++++++++++++ .../Sequencing/PartSequencerFactoryTests.cs | 30 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 OpenNest.Engine/Sequencing/PartSequencerFactory.cs create mode 100644 OpenNest.Tests/Sequencing/PartSequencerFactoryTests.cs diff --git a/OpenNest.Engine/Sequencing/PartSequencerFactory.cs b/OpenNest.Engine/Sequencing/PartSequencerFactory.cs new file mode 100644 index 0000000..0e29d1e --- /dev/null +++ b/OpenNest.Engine/Sequencing/PartSequencerFactory.cs @@ -0,0 +1,23 @@ +using System; +using OpenNest.CNC.CuttingStrategy; + +namespace OpenNest.Engine.Sequencing +{ + public static class PartSequencerFactory + { + public static IPartSequencer Create(SequenceParameters parameters) + { + return parameters.Method switch + { + SequenceMethod.RightSide => new RightSideSequencer(), + SequenceMethod.LeftSide => new LeftSideSequencer(), + SequenceMethod.BottomSide => new BottomSideSequencer(), + SequenceMethod.EdgeStart => new EdgeStartSequencer(), + SequenceMethod.LeastCode => new LeastCodeSequencer(), + SequenceMethod.Advanced => new AdvancedSequencer(parameters), + _ => throw new NotSupportedException( + $"Sequence method '{parameters.Method}' is not supported.") + }; + } + } +} diff --git a/OpenNest.Tests/Sequencing/PartSequencerFactoryTests.cs b/OpenNest.Tests/Sequencing/PartSequencerFactoryTests.cs new file mode 100644 index 0000000..1c6a22e --- /dev/null +++ b/OpenNest.Tests/Sequencing/PartSequencerFactoryTests.cs @@ -0,0 +1,30 @@ +using System; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.Sequencing; +using Xunit; + +namespace OpenNest.Tests.Sequencing; + +public class PartSequencerFactoryTests +{ + [Theory] + [InlineData(SequenceMethod.RightSide, typeof(RightSideSequencer))] + [InlineData(SequenceMethod.LeftSide, typeof(LeftSideSequencer))] + [InlineData(SequenceMethod.BottomSide, typeof(BottomSideSequencer))] + [InlineData(SequenceMethod.EdgeStart, typeof(EdgeStartSequencer))] + [InlineData(SequenceMethod.LeastCode, typeof(LeastCodeSequencer))] + [InlineData(SequenceMethod.Advanced, typeof(AdvancedSequencer))] + public void Create_ReturnsCorrectType(SequenceMethod method, Type expectedType) + { + var parameters = new SequenceParameters { Method = method }; + var sequencer = PartSequencerFactory.Create(parameters); + Assert.IsType(expectedType, sequencer); + } + + [Fact] + public void Create_RightSideAlt_Throws() + { + var parameters = new SequenceParameters { Method = SequenceMethod.RightSideAlt }; + Assert.Throws(() => PartSequencerFactory.Create(parameters)); + } +} From 29b2572f9ad552a68b8651f67fe36a761d3f0bed Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:39:34 -0400 Subject: [PATCH 108/116] feat: add IRapidPlanner, RapidPath, and SafeHeightRapidPlanner --- .../RapidPlanning/IRapidPlanner.cs | 10 +++++ OpenNest.Engine/RapidPlanning/RapidPath.cs | 11 ++++++ .../RapidPlanning/SafeHeightRapidPlanner.cs | 17 ++++++++ .../SafeHeightRapidPlannerTests.cs | 39 +++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 OpenNest.Engine/RapidPlanning/IRapidPlanner.cs create mode 100644 OpenNest.Engine/RapidPlanning/RapidPath.cs create mode 100644 OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs create mode 100644 OpenNest.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs diff --git a/OpenNest.Engine/RapidPlanning/IRapidPlanner.cs b/OpenNest.Engine/RapidPlanning/IRapidPlanner.cs new file mode 100644 index 0000000..edae37c --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/IRapidPlanner.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public interface IRapidPlanner + { + RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas); + } +} diff --git a/OpenNest.Engine/RapidPlanning/RapidPath.cs b/OpenNest.Engine/RapidPlanning/RapidPath.cs new file mode 100644 index 0000000..8ff6eb4 --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/RapidPath.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public readonly struct RapidPath + { + public bool HeadUp { get; init; } + public List Waypoints { get; init; } + } +} diff --git a/OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs b/OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs new file mode 100644 index 0000000..6de4db6 --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public class SafeHeightRapidPlanner : IRapidPlanner + { + public RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas) + { + return new RapidPath + { + HeadUp = true, + Waypoints = new List() + }; + } + } +} diff --git a/OpenNest.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs b/OpenNest.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs new file mode 100644 index 0000000..3db408f --- /dev/null +++ b/OpenNest.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.RapidPlanning; + +public class SafeHeightRapidPlannerTests +{ + [Fact] + public void AlwaysReturnsHeadUp() + { + var planner = new SafeHeightRapidPlanner(); + var from = new Vector(10, 10); + var to = new Vector(50, 50); + var cutAreas = new List(); + + var result = planner.Plan(from, to, cutAreas); + + Assert.True(result.HeadUp); + Assert.Empty(result.Waypoints); + } + + [Fact] + public void ReturnsHeadUp_EvenWithCutAreas() + { + var planner = new SafeHeightRapidPlanner(); + var from = new Vector(0, 0); + var to = new Vector(10, 10); + + var shape = new Shape(); + shape.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20))); + var cutAreas = new List { shape }; + + var result = planner.Plan(from, to, cutAreas); + + Assert.True(result.HeadUp); + } +} From 6dffd8f5ad6e91e80f131cc6bda4d8e8851e65e8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:43:06 -0400 Subject: [PATCH 109/116] feat: add DirectRapidPlanner with line-shape intersection check --- .../RapidPlanning/DirectRapidPlanner.cs | 44 +++++++++++++++ .../RapidPlanning/DirectRapidPlannerTests.cs | 56 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs create mode 100644 OpenNest.Tests/RapidPlanning/DirectRapidPlannerTests.cs diff --git a/OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs b/OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs new file mode 100644 index 0000000..154e525 --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public class DirectRapidPlanner : IRapidPlanner + { + public RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas) + { + var travelLine = new Line(from, to); + + foreach (var cutArea in cutAreas) + { + if (TravelLineIntersectsShape(travelLine, cutArea)) + { + return new RapidPath + { + HeadUp = true, + Waypoints = new List() + }; + } + } + + return new RapidPath + { + HeadUp = false, + Waypoints = new List() + }; + } + + private static bool TravelLineIntersectsShape(Line travelLine, Shape shape) + { + foreach (var entity in shape.Entities) + { + if (entity is Line edge) + { + if (travelLine.Intersects(edge, out _)) + return true; + } + } + return false; + } + } +} diff --git a/OpenNest.Tests/RapidPlanning/DirectRapidPlannerTests.cs b/OpenNest.Tests/RapidPlanning/DirectRapidPlannerTests.cs new file mode 100644 index 0000000..ca6a00b --- /dev/null +++ b/OpenNest.Tests/RapidPlanning/DirectRapidPlannerTests.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.RapidPlanning; + +public class DirectRapidPlannerTests +{ + [Fact] + public void NoCutAreas_ReturnsHeadDown() + { + var planner = new DirectRapidPlanner(); + var result = planner.Plan(new Vector(0, 0), new Vector(10, 10), new List()); + + Assert.False(result.HeadUp); + Assert.Empty(result.Waypoints); + } + + [Fact] + public void ClearPath_ReturnsHeadDown() + { + var planner = new DirectRapidPlanner(); + + var cutArea = new Shape(); + cutArea.Entities.Add(new Line(new Vector(50, 0), new Vector(50, 10))); + cutArea.Entities.Add(new Line(new Vector(50, 10), new Vector(60, 10))); + cutArea.Entities.Add(new Line(new Vector(60, 10), new Vector(60, 0))); + cutArea.Entities.Add(new Line(new Vector(60, 0), new Vector(50, 0))); + + var result = planner.Plan( + new Vector(0, 0), new Vector(10, 10), + new List { cutArea }); + + Assert.False(result.HeadUp); + } + + [Fact] + public void BlockedPath_ReturnsHeadUp() + { + var planner = new DirectRapidPlanner(); + + var cutArea = new Shape(); + cutArea.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20))); + cutArea.Entities.Add(new Line(new Vector(5, 20), new Vector(6, 20))); + cutArea.Entities.Add(new Line(new Vector(6, 20), new Vector(6, 0))); + cutArea.Entities.Add(new Line(new Vector(6, 0), new Vector(5, 0))); + + var result = planner.Plan( + new Vector(0, 10), new Vector(10, 10), + new List { cutArea }); + + Assert.True(result.HeadUp); + Assert.Empty(result.Waypoints); + } +} From 5948dc9cae3129191cb404f6f81af2b0ec10a5f3 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:43:17 -0400 Subject: [PATCH 110/116] feat: add PlateResult and ProcessedPart --- OpenNest.Engine/PlateResult.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 OpenNest.Engine/PlateResult.cs diff --git a/OpenNest.Engine/PlateResult.cs b/OpenNest.Engine/PlateResult.cs new file mode 100644 index 0000000..7209be7 --- /dev/null +++ b/OpenNest.Engine/PlateResult.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.RapidPlanning; + +namespace OpenNest.Engine +{ + public class PlateResult + { + public List Parts { get; init; } + } + + public readonly struct ProcessedPart + { + public Part Part { get; init; } + public Program ProcessedProgram { get; init; } + public RapidPath RapidPath { get; init; } + } +} From 7d198f837c81d8930abd442fa2d6701500659813 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:44:25 -0400 Subject: [PATCH 111/116] feat: add PlateProcessor orchestrator --- OpenNest.Engine/PlateProcessor.cs | 120 +++++++++++++++++++++++ OpenNest.Tests/PlateProcessorTests.cs | 132 ++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 OpenNest.Engine/PlateProcessor.cs create mode 100644 OpenNest.Tests/PlateProcessorTests.cs diff --git a/OpenNest.Engine/PlateProcessor.cs b/OpenNest.Engine/PlateProcessor.cs new file mode 100644 index 0000000..9601933 --- /dev/null +++ b/OpenNest.Engine/PlateProcessor.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; + +namespace OpenNest.Engine +{ + public class PlateProcessor + { + public IPartSequencer Sequencer { get; set; } + public ContourCuttingStrategy CuttingStrategy { get; set; } + public IRapidPlanner RapidPlanner { get; set; } + + public PlateResult Process(Plate plate) + { + var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate); + var results = new List(sequenced.Count); + var cutAreas = new List(); + var currentPoint = PlateHelper.GetExitPoint(plate); + + foreach (var sp in sequenced) + { + var part = sp.Part; + + // Compute approach point in part-local space + var localApproach = ToPartLocal(currentPoint, part); + + Program processedProgram; + Vector lastCutLocal; + + if (!part.HasManualLeadIns && CuttingStrategy != null) + { + var cuttingResult = CuttingStrategy.Apply(part.Program, localApproach); + processedProgram = cuttingResult.Program; + lastCutLocal = cuttingResult.LastCutPoint; + } + else + { + processedProgram = part.Program; + lastCutLocal = GetProgramEndPoint(part.Program); + } + + // Pierce point: program start point in plate space + var pierceLocal = GetProgramStartPoint(part.Program); + var piercePoint = ToPlateSpace(pierceLocal, part); + + // Plan rapid from currentPoint to pierce point + var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas); + + results.Add(new ProcessedPart + { + Part = part, + ProcessedProgram = processedProgram, + RapidPath = rapidPath + }); + + // Update cut areas with part perimeter + var perimeter = GetPartPerimeter(part); + if (perimeter != null) + cutAreas.Add(perimeter); + + // Update current point to last cut point in plate space + currentPoint = ToPlateSpace(lastCutLocal, part); + } + + return new PlateResult { Parts = results }; + } + + private static Vector ToPartLocal(Vector platePoint, Part part) + { + return platePoint - part.Location; + } + + private static Vector ToPlateSpace(Vector localPoint, Part part) + { + return localPoint + part.Location; + } + + private static Vector GetProgramStartPoint(Program program) + { + if (program.Codes.Count == 0) + return Vector.Zero; + + var first = program.Codes[0]; + if (first is Motion motion) + return motion.EndPoint; + + return Vector.Zero; + } + + private static Vector GetProgramEndPoint(Program program) + { + for (var i = program.Codes.Count - 1; i >= 0; i--) + { + if (program.Codes[i] is Motion motion) + return motion.EndPoint; + } + + return Vector.Zero; + } + + private static Shape GetPartPerimeter(Part part) + { + var entities = part.Program.ToGeometry(); + if (entities == null || entities.Count == 0) + return null; + + var profile = new ShapeProfile(entities); + var perimeter = profile.Perimeter; + if (perimeter == null || perimeter.Entities.Count == 0) + return null; + + perimeter.Offset(part.Location); + return perimeter; + } + } +} diff --git a/OpenNest.Tests/PlateProcessorTests.cs b/OpenNest.Tests/PlateProcessorTests.cs new file mode 100644 index 0000000..73d5a98 --- /dev/null +++ b/OpenNest.Tests/PlateProcessorTests.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests; + +public class PlateProcessorTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y, size: 2); + + [Fact] + public void Process_ReturnsAllParts() + { + var plate = new Plate(60, 120); + plate.Parts.Add(MakePartAt(10, 10)); + plate.Parts.Add(MakePartAt(30, 30)); + plate.Parts.Add(MakePartAt(50, 50)); + + var processor = new PlateProcessor + { + Sequencer = new RightSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Equal(3, result.Parts.Count); + } + + [Fact] + public void Process_PreservesSequenceOrder() + { + var plate = new Plate(60, 120); + var left = MakePartAt(5, 10); + var right = MakePartAt(50, 10); + plate.Parts.Add(left); + plate.Parts.Add(right); + + var processor = new PlateProcessor + { + Sequencer = new RightSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Same(right, result.Parts[0].Part); + Assert.Same(left, result.Parts[1].Part); + } + + [Fact] + public void Process_SkipsCuttingStrategy_WhenManualLeadIns() + { + var plate = new Plate(60, 120); + var part = MakePartAt(10, 10); + part.HasManualLeadIns = true; + plate.Parts.Add(part); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + CuttingStrategy = new ContourCuttingStrategy + { + Parameters = new CuttingParameters() + }, + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Same(part.Program, result.Parts[0].ProcessedProgram); + } + + [Fact] + public void Process_DoesNotMutatePart() + { + var plate = new Plate(60, 120); + var part = MakePartAt(10, 10); + var originalProgram = part.Program; + plate.Parts.Add(part); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Same(originalProgram, part.Program); + } + + [Fact] + public void Process_NoCuttingStrategy_PassesProgramThrough() + { + var plate = new Plate(60, 120); + var part = MakePartAt(10, 10); + plate.Parts.Add(part); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Same(part.Program, result.Parts[0].ProcessedProgram); + } + + [Fact] + public void Process_EmptyPlate_ReturnsEmptyResult() + { + var plate = new Plate(60, 120); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Empty(result.Parts); + } +} From a1f32eda79e677df3d4d45bc7ba560f9b151b59c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:50:03 -0400 Subject: [PATCH 112/116] =?UTF-8?q?feat(ui):=20wire=20Plate=E2=86=92Sequen?= =?UTF-8?q?ce=20menu=20to=20PartSequencerFactory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace old SequenceByNearest with PartSequencerFactory using default SequenceParameters (Advanced method with serpentine row grouping). Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Forms/EditNestForm.cs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index e838871..34d180e 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -6,8 +6,12 @@ using System.IO; using System.Linq; using System.Windows.Forms; using OpenNest.Actions; +using OpenNest.CNC.CuttingStrategy; using OpenNest.Collections; using OpenNest.Controls; +using OpenNest.Engine; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Engine.Sequencing; using OpenNest.IO; using OpenNest.Math; using OpenNest.Properties; @@ -438,27 +442,28 @@ namespace OpenNest.Forms public void AutoSequenceCurrentPlate() { - var seq = new SequenceByNearest(); - var parts = seq.SequenceParts(PlateView.Plate.Parts); - - PlateView.Plate.Parts.Clear(); - PlateView.Plate.Parts.AddRange(parts); + SequencePlate(PlateView.Plate); + PlateView.Invalidate(); } public void AutoSequenceAllPlates() { - var seq = new SequenceByNearest(); - foreach (var plate in Nest.Plates) - { - var parts = seq.SequenceParts(plate.Parts); - plate.Parts.Clear(); - plate.Parts.AddRange(parts); - } + SequencePlate(plate); PlateView.Invalidate(); } + private static void SequencePlate(Plate plate) + { + var parameters = new SequenceParameters(); + var sequencer = PartSequencerFactory.Create(parameters); + var ordered = sequencer.Sequence(plate.Parts.ToList(), plate); + + plate.Parts.Clear(); + plate.Parts.AddRange(ordered.Select(s => s.Part)); + } + public void CalculateCurrentPlateCutTime() { var cutParamsForm = new CutParametersForm(); From 507dff95b848e9fc27476e57d0575028e7f2f25f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:54:35 -0400 Subject: [PATCH 113/116] fix(ui): use LeastCode sequencer and fix AddRange double-enumeration Advanced sequencer with default 0.25 MinDistanceBetweenRowsColumns puts every part in its own row, degenerating to a Y-sort. Switch to LeastCode (nearest-neighbor + 2-opt) for visible results. Also replace AddRange(linq) with foreach+Add to avoid ObservableList AddRange re-enumerating a deferred LINQ query for event firing. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Forms/EditNestForm.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 34d180e..26223a5 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -456,12 +456,13 @@ namespace OpenNest.Forms private static void SequencePlate(Plate plate) { - var parameters = new SequenceParameters(); + var parameters = new SequenceParameters { Method = SequenceMethod.LeastCode }; var sequencer = PartSequencerFactory.Create(parameters); var ordered = sequencer.Sequence(plate.Parts.ToList(), plate); plate.Parts.Clear(); - plate.Parts.AddRange(ordered.Select(s => s.Part)); + foreach (var s in ordered) + plate.Parts.Add(s.Part); } public void CalculateCurrentPlateCutTime() From 1a3e18795b409cbb9d6318404c29d5670a32248f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 00:58:35 -0400 Subject: [PATCH 114/116] fix(ui): reverse sequence order so cutting starts near origin The sequencer returns parts ordered from exit point inward. Reverse so part 1 is nearest the origin and cutting works outward. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Forms/EditNestForm.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 26223a5..1bcc52e 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -461,8 +461,8 @@ namespace OpenNest.Forms var ordered = sequencer.Sequence(plate.Parts.ToList(), plate); plate.Parts.Clear(); - foreach (var s in ordered) - plate.Parts.Add(s.Part); + for (var i = ordered.Count - 1; i >= 0; i--) + plate.Parts.Add(ordered[i].Part); } public void CalculateCurrentPlateCutTime() From ff496e4efefdff26a4a8c8e472670943f4c43bb8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 01:28:25 -0400 Subject: [PATCH 115/116] fix(engine): track multiple free rectangles in strip remnant filling ComputeRemainderWithin only returned the larger of two possible free rectangles, permanently losing usable area on the other axis after each remainder item was placed. Replace the single shrinking box with a list of free rectangles using guillotine cuts so both sub-areas remain available for subsequent items. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/StripNestEngine.cs | 82 ++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs index 8e08bcc..87a6b4a 100644 --- a/OpenNest.Engine/StripNestEngine.cs +++ b/OpenNest.Engine/StripNestEngine.cs @@ -248,35 +248,48 @@ namespace OpenNest }) .ToList(); - // Fill remnant with remainder items, shrinking the available area after each. - // Wrap progress so remnant fills include the strip parts already found. + // Fill remnant with remainder items using free-rectangle tracking. + // After each fill, the consumed box is split into two non-overlapping + // sub-rectangles (guillotine cut) so no usable area is lost. if (remnantBox.Width > 0 && remnantBox.Length > 0) { - var currentRemnant = remnantBox; + var freeBoxes = new List { remnantBox }; var remnantProgress = progress != null ? new AccumulatingProgress(progress, allParts) : null; foreach (var item in effectiveRemainder) { - if (token.IsCancellationRequested) + if (token.IsCancellationRequested || freeBoxes.Count == 0) break; - if (currentRemnant.Width <= 0 || currentRemnant.Length <= 0) - break; + var itemBbox = item.Drawing.Program.BoundingBox(); + var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length); - var remnantInner = new DefaultNestEngine(Plate); - var remnantParts = remnantInner.Fill( - new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, - currentRemnant, remnantProgress, token); + // Try free boxes from largest to smallest. + freeBoxes.Sort((a, b) => b.Area().CompareTo(a.Area())); - if (remnantParts != null && remnantParts.Count > 0) + for (var i = 0; i < freeBoxes.Count; i++) { - allParts.AddRange(remnantParts); + var box = freeBoxes[i]; - // Shrink remnant to avoid overlap with next item. - var usedBox = remnantParts.Cast().GetBoundingBox(); - currentRemnant = ComputeRemainderWithin(currentRemnant, usedBox, spacing); + if (System.Math.Min(box.Width, box.Length) < minItemDim) + continue; + + var remnantInner = new DefaultNestEngine(Plate); + var remnantParts = remnantInner.Fill( + new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, + box, remnantProgress, token); + + if (remnantParts != null && remnantParts.Count > 0) + { + allParts.AddRange(remnantParts); + freeBoxes.RemoveAt(i); + + var usedBox = remnantParts.Cast().GetBoundingBox(); + SplitFreeBox(box, usedBox, spacing, freeBoxes); + break; + } } } } @@ -291,7 +304,44 @@ namespace OpenNest return result; } - // ComputeRemainderWithin inherited from NestEngineBase + private static void SplitFreeBox(Box parent, Box used, double spacing, List freeBoxes) + { + var hWidth = parent.Right - used.Right - spacing; + var vHeight = parent.Top - used.Top - spacing; + + if (hWidth > spacing && vHeight > spacing) + { + // Guillotine split: give the overlapping corner to the larger strip. + var hFullArea = hWidth * parent.Length; + var vFullArea = parent.Width * vHeight; + + if (hFullArea >= vFullArea) + { + // hStrip gets full height; vStrip truncated to left of split line. + freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, parent.Length)); + var vWidth = used.Right + spacing - parent.X; + if (vWidth > spacing) + freeBoxes.Add(new Box(parent.X, used.Top + spacing, vWidth, vHeight)); + } + else + { + // vStrip gets full width; hStrip truncated below split line. + freeBoxes.Add(new Box(parent.X, used.Top + spacing, parent.Width, vHeight)); + var hHeight = used.Top + spacing - parent.Y; + if (hHeight > spacing) + freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, hHeight)); + } + } + else if (hWidth > spacing) + { + freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, parent.Length)); + } + else if (vHeight > spacing) + { + freeBoxes.Add(new Box(parent.X, used.Top + spacing, parent.Width, vHeight)); + } + } + /// /// Wraps an IProgress to prepend previously placed parts to each report, /// so the UI shows the full picture (strip + remnant) during remnant fills. From 48d42201993badeac9449115f7275d544df52335 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 01:29:32 -0400 Subject: [PATCH 116/116] docs: add lead-in assignment UI design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-16-leadin-ui-design.md | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-16-leadin-ui-design.md diff --git a/docs/superpowers/specs/2026-03-16-leadin-ui-design.md b/docs/superpowers/specs/2026-03-16-leadin-ui-design.md new file mode 100644 index 0000000..28fec65 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-leadin-ui-design.md @@ -0,0 +1,206 @@ +# Lead-In Assignment UI Design + +## Overview + +Add a dialog and menu item for assigning lead-ins to parts on a plate. The dialog provides two parameter sets — tabbed (V lead-in/out) and standard (straight lead-in with overtravel) — and applies them per-part based on a new `Part.IsTabbed` flag. The `ContourCuttingStrategy` auto-detects corner vs mid-entity pierce points to determine lead-out behavior. + +This is the "manual override" path — when the user assigns lead-ins via this dialog, each part gets `HasManualLeadIns = true` so the automated `PlateProcessor` pipeline skips it. + +## Model Changes + +### Part (OpenNest.Core) + +Add two properties: + +```csharp +public bool IsTabbed { get; set; } +``` + +Indicates the part uses tabbed lead-in parameters (V lead-in/out). Defaults to `false` — all parts use standard parameters until a tab assignment UI is built. + +```csharp +public void ApplyLeadIns(Program processedProgram) +{ + Program = processedProgram; + HasManualLeadIns = true; +} +``` + +Atomically sets the processed program and marks lead-ins as manually assigned. The original drawing program is preserved on `Part.BaseDrawing.Program`. This is the intentional "manual" path — `PlateProcessor` (the automated path) stores results non-destructively in `ProcessedPart.ProcessedProgram` and does not call this method. + +### PlateHelper (OpenNest.Engine) + +Change `PlateHelper` from `internal static` to `public static` so the UI project can access `GetExitPoint`. + +## Lead-In Dialog (`LeadInForm`) + +A WinForms dialog in `OpenNest/Forms/LeadInForm.cs` with two groups of numeric inputs. + +### Tabbed Group (V lead-in/lead-out) +- Lead-in angle (degrees) — default 60 +- Lead-in length (inches) — default 0.15 +- Lead-out angle (degrees) — default 60 +- Lead-out length (inches) — default 0.08 + +These form a V shape at the pierce point where the breaking point lands on the part edge, leaving a less noticeable tab spot. + +### Standard Group +- Lead-in angle (degrees) — default 90 +- Lead-in length (inches) — default 0.125 +- Overtravel distance (inches) — default 0.03 + +The lead-out behavior for standard parts depends on pierce point location (auto-detected by `ContourCuttingStrategy`): +- **Corner pierce:** straight `LineLeadOut` extending past the corner for the overtravel distance +- **Mid-entity pierce:** handled at the `ContourCuttingStrategy` level (not via `LeadOut.Generate`) — the strategy appends overcut moves that follow the contour path for the overtravel distance after the shape's closing segment + +### Dialog Result + +```csharp +public class LeadInSettings +{ + // Tabbed parameters (V lead-in/out) + public double TabbedLeadInAngle { get; set; } = 60; + public double TabbedLeadInLength { get; set; } = 0.15; + public double TabbedLeadOutAngle { get; set; } = 60; + public double TabbedLeadOutLength { get; set; } = 0.08; + + // Standard parameters + public double StandardLeadInAngle { get; set; } = 90; + public double StandardLeadInLength { get; set; } = 0.125; + public double StandardOvertravel { get; set; } = 0.03; +} +``` + +Note: `LineLeadIn.ApproachAngle` and `LineLeadOut.ApproachAngle` store degrees (not radians), converting internally via `Angle.ToRadians()`. The `LeadInSettings` values are degrees and can be passed directly. + +## LeadInSettings to CuttingParameters Mapping + +The caller builds two `CuttingParameters` instances up front — one for tabbed parts, one for standard — rather than swapping parameters per iteration: + +**Tabbed:** +``` +ExternalLeadIn = new LineLeadIn { ApproachAngle = settings.TabbedLeadInAngle, Length = settings.TabbedLeadInLength } +ExternalLeadOut = new LineLeadOut { ApproachAngle = settings.TabbedLeadOutAngle, Length = settings.TabbedLeadOutLength } +InternalLeadIn = (same) +InternalLeadOut = (same) +ArcCircleLeadIn = (same) +ArcCircleLeadOut = (same) +``` + +**Standard:** +``` +ExternalLeadIn = new LineLeadIn { ApproachAngle = settings.StandardLeadInAngle, Length = settings.StandardLeadInLength } +ExternalLeadOut = new LineLeadOut { Length = settings.StandardOvertravel } +InternalLeadIn = (same) +InternalLeadOut = (same) +ArcCircleLeadIn = (same) +ArcCircleLeadOut = (same) +``` + +For standard parts, the `LineLeadOut` handles the corner case. The mid-entity contour-follow case is handled at the `ContourCuttingStrategy` level (see below). + +All three contour types (external, internal, arc/circle) get the same settings for this iteration. + +## Menu Integration + +Add "Assign Lead-Ins" to the Plate menu in `MainForm`, after "Sequence Parts" and before "Calculate Cut Time". + +Click handler in `MainForm` delegates to `EditNestForm.AssignLeadIns()`. + +## AssignLeadIns Flow (EditNestForm) + +``` +1. Open LeadInForm dialog +2. If user clicks OK: + a. Get LeadInSettings from dialog + b. Build two ContourCuttingStrategy instances: + - tabbedStrategy with tabbed CuttingParameters + - standardStrategy with standard CuttingParameters + c. Get exit point: PlateHelper.GetExitPoint(plate) [now public] + d. Set currentPoint = exitPoint + e. For each part on the current plate (in list order): + - Skip if part.HasManualLeadIns is true + - Compute localApproach = currentPoint - part.Location + - Pick strategy = part.IsTabbed ? tabbedStrategy : standardStrategy + - Call strategy.Apply(part.Program, localApproach) → CuttingResult + - Call part.ApplyLeadIns(cutResult.Program) + (this sets Program AND HasManualLeadIns = true atomically) + - Update currentPoint = cutResult.LastCutPoint + part.Location + f. Invalidate PlateView to show updated geometry +``` + +Note: `ContourCuttingStrategy.Apply` builds a new `Program` from scratch — it reads `part.Program` but does not modify it. The returned `CuttingResult.Program` is a fresh instance with lead-ins baked in. + +## ContourCuttingStrategy Changes + +### Corner vs Mid-Entity Auto-Detection + +When generating the lead-out for standard (non-tabbed) parts, the strategy detects whether the pierce point landed on a corner or mid-entity. Detection uses the `out Entity` from `ClosestPointTo` with type-specific endpoint checks: + +```csharp +bool isCorner; +if (entity is Line line) + isCorner = closestPt.DistanceTo(line.StartPoint) < Tolerance.Epsilon + || closestPt.DistanceTo(line.EndPoint) < Tolerance.Epsilon; +else if (entity is Arc arc) + isCorner = closestPt.DistanceTo(arc.StartPoint()) < Tolerance.Epsilon + || closestPt.DistanceTo(arc.EndPoint()) < Tolerance.Epsilon; +else + isCorner = false; +``` + +Note: `Entity` has no polymorphic `StartPoint`/`EndPoint` — `Line` has properties, `Arc` has methods, `Circle` has neither. + +### Corner Lead-Out + +Delegates to `LeadOut.Generate()` as normal — `LineLeadOut` extends past the corner along the contour normal. + +### Mid-Entity Lead-Out (Contour-Follow Overtravel) + +Handled at the `ContourCuttingStrategy` level, NOT via `LeadOut.Generate()` (which lacks access to the contour shape). After the reindexed shape's moves are emitted, the strategy appends additional moves that retrace the beginning of the contour for the overtravel distance. This is done by: + +1. Walking the reindexed shape's entities from the start +2. Accumulating distance until `overtravel` is reached +3. Emitting `LinearMove`/`ArcMove` codes for those segments (splitting the last segment if needed) + +This produces a clean overcut that ensures the contour fully closes. + +### Tabbed Lead-Out + +For tabbed parts, the lead-out is always a `LineLeadOut` at the specified angle and length, regardless of corner/mid-entity. This creates the V shape. + +## File Structure + +``` +OpenNest.Core/ +├── Part.cs # add IsTabbed, ApplyLeadIns +└── CNC/CuttingStrategy/ + └── ContourCuttingStrategy.cs # corner vs mid-entity lead-out detection + +OpenNest.Engine/ +└── Sequencing/ + └── PlateHelper.cs # change internal → public + +OpenNest/ +├── Forms/ +│ ├── LeadInForm.cs # new dialog +│ ├── LeadInForm.Designer.cs # new dialog designer +│ ├── MainForm.Designer.cs # add menu item +│ ├── MainForm.cs # add click handler +│ └── EditNestForm.cs # add AssignLeadIns method +└── LeadInSettings.cs # settings DTO +``` + +## Known Limitations + +- `Part.IsTabbed` defaults to `false` with no UI to set it yet. All parts use standard parameters until a tab assignment UI is built. The tabbed code path is present but exercised only programmatically or via MCP tools for now. +- `IsTabbed` is not yet serialized through the nest file format (NestWriter/NestReader). Will need serialization support when the tab assignment UI is added. + +## Out of Scope + +- Per-contour-type lead-in configuration (deferred to database/datagrid UI) +- Lead-in visualization in PlateView (separate enhancement) +- Database storage of lead-in presets by material/thickness +- Tab assignment UI (setting `Part.IsTabbed`) +- MicrotabLeadOut integration +- Nest file serialization of `IsTabbed`