Compare commits

...

18 Commits

Author SHA1 Message Date
aj de527cd668 feat: add plate utilization to UI status bar
Display current plate utilization percentage in the status bar,
updating live when parts are added or removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:10:42 -04:00
aj 9887cb1aa3 fix: swap BestFitCell dimension display to height x width
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:04:23 -04:00
aj cdf8e4e40e refactor: use IDistanceComputer and rename Type to StrategyIndex
Wire IDistanceComputer into RotationSlideStrategy, replacing inline
CPU/GPU branching. BestFitFinder constructs the appropriate implementation.
Replace PushDirection enum with direction vectors in BuildOffsets.
Rename IBestFitStrategy.Type and PairCandidate.StrategyType to StrategyIndex
for clarity (JSON field name unchanged for backward compatibility).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:04:19 -04:00
aj 4f21fb91a1 refactor: extract IDistanceComputer with CPU and GPU implementations
Extract distance computation from RotationSlideStrategy into a pluggable
IDistanceComputer interface. CpuDistanceComputer adds leading-face vertex
culling (~50% fewer rays per direction) with early exit on overlap.
GpuDistanceComputer wraps ISlideComputer with Line-to-flat-array conversion.
SlideOffset struct uses direction vectors (DirX/DirY) instead of PushDirection.
SpatialQuery.RayEdgeDistance(dirX,dirY) made public for CPU path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:04:12 -04:00
aj 7f96d632f3 fix: correct NFP polygon computation and inflation direction
Three bugs fixed in NfpSlideStrategy pipeline:

1. NoFitPolygon.Reflect() incorrectly reversed vertex order. Point
   reflection (negating both axes) is a 180° rotation that preserves
   winding — the Reverse() call was converting CCW to CW, producing
   self-intersecting bowtie NFPs.

2. PolygonHelper inflation used OffsetSide.Left which is inward for
   CCW perimeters. Changed to OffsetSide.Right for outward inflation
   so NFP boundary positions give properly-spaced part placements.

3. Removed incorrect correction vector — same-drawing pairs have
   identical polygon-to-part offsets that cancel out in the NFP
   displacement.

Also refactored NfpSlideStrategy to be immutable (removed mutable
cache fields, single constructor with required data, added Create
factory method). BestFitFinder remains on RotationSlideStrategy
as default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:24:04 -04:00
aj 38dcaf16d3 revert: switch BestFitFinder back to RotationSlideStrategy
NFP strategy has coordinate correction issues causing overlaps.
The slide-based approach is fast and accurate — keeping it as default.
NfpSlideStrategy and PolygonHelper remain in the codebase for future use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:12:16 -04:00
aj 8c57e43221 fix: use NoFitPolygon.Compute with hull inputs instead of direct ConvexMinkowskiSum
Calling ConvexMinkowskiSum directly with manual reflection produced
wrong winding/reference-point handling, causing all pairs to overlap.
Route through Compute which handles reflection correctly. Hull inputs
keep it fast — few triangles means trivial Clipper union.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:59:53 -04:00
aj bc78ddc49c perf: use convex hull NFP to avoid Clipper2 union bottleneck
ConvexMinkowskiSum is O(n+m) with no boolean geometry ops.
The concave Minkowski path was doing triangulation + pairwise
sums + Clipper2 Union, which hung at 100% CPU for complex parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:54:19 -04:00
aj c88cec2beb perf: remove no-op AutoNester.Optimize calls from fill pipelines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:11:49 -04:00
aj b7c7cecd75 feat: wire NfpSlideStrategy into BestFitFinder pipeline
Replace RotationSlideStrategy with NfpSlideStrategy in BuildStrategies,
and add integration tests covering the end-to-end FindBestFits pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:09:48 -04:00
aj 4d0d8c453b fix: guard stepSize <= 0 in NfpSlideStrategy to prevent infinite loop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:07:43 -04:00
aj 5f4288a786 feat: add NfpSlideStrategy for NFP-based best-fit candidate generation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:03:52 -04:00
aj 707ddb80d9 style: fix var rule violation in PolygonHelper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:01:22 -04:00
aj 71f28600d1 refactor: extract PolygonHelper from AutoNester for shared polygon operations
Creates PolygonHelper.cs in OpenNest.Engine.BestFit with ExtractPerimeterPolygon
(returning PolygonExtractionResult with polygon + correction vector) and RotatePolygon.
AutoNester.ExtractPerimeterPolygon and RotatePolygon become thin delegates.
Adds MakeSquareDrawing/MakeLShapeDrawing to TestHelpers and 6 PolygonHelperTests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:56:20 -04:00
aj d39b0ae540 docs: add NFP best-fit strategy implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:45:50 -04:00
aj ee5c77c645 docs: address spec review — coordinate correction, edge cases
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:32:12 -04:00
aj 4615bcb40d docs: add NFP best-fit strategy design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:28:40 -04:00
aj 7843de145b fix: swap bounding box dimensions in BestFitViewerForm
Size(width, length) maps Width to vertical and Length to horizontal in
PlateView, but BoundingWidth (the longer dimension) was being passed as
Width (vertical) instead of Length (horizontal), causing the bounding
box to appear portrait instead of landscape.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:19:16 -04:00
29 changed files with 1727 additions and 261 deletions
+24 -6
View File
@@ -1,4 +1,5 @@
using Clipper2Lib;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Geometry
@@ -22,8 +23,20 @@ namespace OpenNest.Geometry
return MinkowskiSum(stationary, reflected);
}
/// <summary>
/// Optimized version of Compute for polygons known to be convex.
/// Bypasses expensive triangulation and Clipper unions.
/// </summary>
public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting)
{
var reflected = Reflect(orbiting);
return ConvexMinkowskiSum(stationary, reflected);
}
/// <summary>
/// Reflects a polygon through the origin (negates all vertex coordinates).
/// Point reflection (negating both axes) is equivalent to 180° rotation,
/// which preserves winding order. No reversal needed.
/// </summary>
private static Polygon Reflect(Polygon polygon)
{
@@ -32,8 +45,6 @@ namespace OpenNest.Geometry
foreach (var v in polygon.Vertices)
result.Vertices.Add(new Vector(-v.X, -v.Y));
// Reflecting reverses winding order — reverse to maintain CCW.
result.Vertices.Reverse();
return result;
}
@@ -78,19 +89,24 @@ namespace OpenNest.Geometry
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
/// Both polygons must have CCW winding.
/// </summary>
internal static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
public static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
{
var edgesA = GetEdgeVectors(a);
var edgesB = GetEdgeVectors(b);
// Find bottom-most (then left-most) vertex for each polygon as starting point.
// Find indices of bottom-left vertices for both.
var startA = FindBottomLeft(a);
var startB = FindBottomLeft(b);
var result = new Polygon();
// The starting point of the Minkowski sum A + B is the sum of the
// starting points of A and B. For NFP = A + (-B), this is
// startA + startReflectedB.
var current = new Vector(
a.Vertices[startA].X + b.Vertices[startB].X,
a.Vertices[startA].Y + b.Vertices[startB].Y);
result.Vertices.Add(current);
var ia = 0;
@@ -98,7 +114,6 @@ namespace OpenNest.Geometry
var na = edgesA.Count;
var nb = edgesB.Count;
// Reorder edges to start from the bottom-left vertex.
var orderedA = ReorderEdges(edgesA, startA);
var orderedB = ReorderEdges(edgesB, startB);
@@ -117,7 +132,10 @@ namespace OpenNest.Geometry
else
{
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
if (angleA < 0) angleA += Angle.TwoPI;
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
if (angleB < 0) angleB += Angle.TwoPI;
if (angleA < angleB)
{
@@ -129,7 +147,6 @@ namespace OpenNest.Geometry
}
else
{
// Same angle — merge both edges.
edge = new Vector(
orderedA[ia].X + orderedB[ib].X,
orderedA[ia].Y + orderedB[ib].Y);
@@ -143,6 +160,7 @@ namespace OpenNest.Geometry
}
result.Close();
result.UpdateBounds();
return result;
}
+1 -1
View File
@@ -76,7 +76,7 @@ namespace OpenNest.Geometry
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
private static double RayEdgeDistance(
public static double RayEdgeDistance(
double vx, double vy,
double p1x, double p1y, double p2x, double p2y,
double dirX, double dirY)
+9 -7
View File
@@ -12,14 +12,16 @@ namespace OpenNest.Engine.BestFit
public class BestFitFinder
{
private readonly IPairEvaluator _evaluator;
private readonly ISlideComputer _slideComputer;
private readonly IDistanceComputer _distanceComputer;
private readonly BestFitFilter _filter;
public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
{
_evaluator = evaluator ?? new PairEvaluator();
_slideComputer = slideComputer;
_distanceComputer = slideComputer != null
? (IDistanceComputer)new GpuDistanceComputer(slideComputer)
: new CpuDistanceComputer();
var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) /
System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
_filter = new BestFitFilter
@@ -36,7 +38,7 @@ namespace OpenNest.Engine.BestFit
double stepSize = 0.25,
BestFitSortField sortBy = BestFitSortField.Area)
{
var strategies = BuildStrategies(drawing);
var strategies = BuildStrategies(drawing, spacing);
var candidateBags = new ConcurrentBag<List<PairCandidate>>();
@@ -75,16 +77,16 @@ namespace OpenNest.Engine.BestFit
.ToList();
}
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
private List<IBestFitStrategy> BuildStrategies(Drawing drawing, double spacing)
{
var angles = GetRotationAngles(drawing);
var strategies = new List<IBestFitStrategy>();
var type = 1;
var index = 1;
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, _slideComputer));
strategies.Add(new RotationSlideStrategy(angle, index++, desc, _distanceComputer));
}
return strategies;
@@ -226,7 +228,7 @@ namespace OpenNest.Engine.BestFit
case BestFitSortField.ShortestSide:
return results.OrderBy(r => r.ShortestSide).ToList();
case BestFitSortField.Type:
return results.OrderBy(r => r.Candidate.StrategyType)
return results.OrderBy(r => r.Candidate.StrategyIndex)
.ThenBy(r => r.Candidate.TestNumber).ToList();
case BestFitSortField.OriginalSequence:
return results.OrderBy(r => r.Candidate.TestNumber).ToList();
@@ -0,0 +1,152 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.BestFit
{
public class CpuDistanceComputer : IDistanceComputer
{
public double[] ComputeDistances(
List<Line> stationaryLines,
List<Line> movingTemplateLines,
SlideOffset[] offsets)
{
var count = offsets.Length;
var results = new double[count];
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
// Pre-filter vertices per unique direction (typically 4 cardinal directions).
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
{
var key = (offset.DirX, offset.DirY);
if (vertexCache.ContainsKey(key))
continue;
var leading = FilterVerticesByProjection(allMovingVerts, offset.DirX, offset.DirY, keepHigh: true);
var facing = FilterVerticesByProjection(allStationaryVerts, offset.DirX, offset.DirY, keepHigh: false);
vertexCache[key] = (leading, facing);
}
System.Threading.Tasks.Parallel.For(0, count, i =>
{
var offset = offsets[i];
var dirX = offset.DirX;
var dirY = offset.DirY;
var oppX = -dirX;
var oppY = -dirY;
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary edges
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
var vy = leadingMoving[v].Y + offset.Dy;
for (var j = 0; j < stationaryLines.Count; j++)
{
var e = stationaryLines[j];
var d = SpatialQuery.RayEdgeDistance(
vx, vy,
e.StartPoint.X, e.StartPoint.Y,
e.EndPoint.X, e.EndPoint.Y,
dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
// Case 2: Facing stationary vertices → moving edges (opposite direction)
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
var svy = facingStationary[v].Y;
for (var j = 0; j < movingTemplateLines.Count; j++)
{
var e = movingTemplateLines[j];
var d = SpatialQuery.RayEdgeDistance(
svx, svy,
e.StartPoint.X + offset.Dx, e.StartPoint.Y + offset.Dy,
e.EndPoint.X + offset.Dx, e.EndPoint.Y + offset.Dy,
oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
results[i] = minDist;
});
return results;
}
private static Vector[] ExtractUniqueVertices(List<Line> lines)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < lines.Count; i++)
{
vertices.Add(lines[i].StartPoint);
vertices.Add(lines[i].EndPoint);
}
return vertices.ToArray();
}
/// <summary>
/// Filters vertices by their projection onto the push direction.
/// keepHigh=true returns the leading half (front face, closest to target).
/// keepHigh=false returns the facing half (side facing the approaching part).
/// </summary>
private static Vector[] FilterVerticesByProjection(
Vector[] vertices, double dirX, double dirY, bool keepHigh)
{
if (vertices.Length == 0)
return vertices;
var projections = new double[vertices.Length];
var min = double.MaxValue;
var max = double.MinValue;
for (var i = 0; i < vertices.Length; i++)
{
projections[i] = vertices[i].X * dirX + vertices[i].Y * dirY;
if (projections[i] < min) min = projections[i];
if (projections[i] > max) max = projections[i];
}
var midpoint = (min + max) / 2;
var count = 0;
for (var i = 0; i < vertices.Length; i++)
{
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
count++;
}
var result = new Vector[count];
var idx = 0;
for (var i = 0; i < vertices.Length; i++)
{
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
result[idx++] = vertices[i];
}
return result;
}
}
}
@@ -0,0 +1,51 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Engine.BestFit
{
public class GpuDistanceComputer : IDistanceComputer
{
private readonly ISlideComputer _slideComputer;
public GpuDistanceComputer(ISlideComputer slideComputer)
{
_slideComputer = slideComputer;
}
public double[] ComputeDistances(
List<Line> stationaryLines,
List<Line> movingTemplateLines,
SlideOffset[] offsets)
{
var stationarySegments = SpatialQuery.FlattenLines(stationaryLines);
var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines);
var count = offsets.Length;
var flatOffsets = new double[count * 2];
var directions = new int[count];
for (var i = 0; i < count; i++)
{
flatOffsets[i * 2] = offsets[i].Dx;
flatOffsets[i * 2 + 1] = offsets[i].Dy;
directions[i] = DirectionVectorToInt(offsets[i].DirX, offsets[i].DirY);
}
return _slideComputer.ComputeBatchMultiDir(
stationarySegments, stationaryLines.Count,
movingSegments, movingTemplateLines.Count,
flatOffsets, count, directions);
}
/// <summary>
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
/// Left=0, Down=1, Right=2, Up=3.
/// </summary>
private static int DirectionVectorToInt(double dirX, double dirY)
{
if (dirX < -0.5) return (int)PushDirection.Left;
if (dirX > 0.5) return (int)PushDirection.Right;
if (dirY < -0.5) return (int)PushDirection.Down;
return (int)PushDirection.Up;
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ namespace OpenNest.Engine.BestFit
{
public interface IBestFitStrategy
{
int Type { get; }
int StrategyIndex { get; }
string Description { get; }
List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize);
}
@@ -0,0 +1,13 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.Engine.BestFit
{
public interface IDistanceComputer
{
double[] ComputeDistances(
List<Line> stationaryLines,
List<Line> movingTemplateLines,
SlideOffset[] offsets);
}
}
+179
View File
@@ -0,0 +1,179 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.IO;
namespace OpenNest.Engine.BestFit
{
public class NfpSlideStrategy : IBestFitStrategy
{
private static readonly string LogPath = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
"nfp-slide-debug.log");
private static readonly object LogLock = new object();
private readonly double _part2Rotation;
private readonly Polygon _stationaryPerimeter;
private readonly Polygon _stationaryHull;
private readonly Vector _correction;
public NfpSlideStrategy(double part2Rotation, int type, string description,
Polygon stationaryPerimeter, Polygon stationaryHull, Vector correction)
{
_part2Rotation = part2Rotation;
StrategyIndex = type;
Description = description;
_stationaryPerimeter = stationaryPerimeter;
_stationaryHull = stationaryHull;
_correction = correction;
}
public int StrategyIndex { get; }
public string Description { get; }
/// <summary>
/// Creates an NfpSlideStrategy by extracting polygon data from a drawing.
/// Returns null if the drawing has no valid perimeter.
/// </summary>
public static NfpSlideStrategy Create(Drawing drawing, double part2Rotation,
int type, string description, double spacing)
{
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, spacing / 2);
if (result.Polygon == null)
return null;
var hull = ConvexHull.Compute(result.Polygon.Vertices);
Log($"=== Create: drawing={drawing.Name}, rotation={Angle.ToDegrees(part2Rotation):F1}deg ===");
Log($" Perimeter: {result.Polygon.Vertices.Count} verts, bounds={FormatBounds(result.Polygon)}");
Log($" Hull: {hull.Vertices.Count} verts, bounds={FormatBounds(hull)}");
Log($" Correction: ({result.Correction.X:F4}, {result.Correction.Y:F4})");
Log($" ProgramBBox: {drawing.Program.BoundingBox()}");
return new NfpSlideStrategy(part2Rotation, type, description,
result.Polygon, hull, result.Correction);
}
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
{
var candidates = new List<PairCandidate>();
if (stepSize <= 0)
return candidates;
Log($"--- GenerateCandidates: drawing={drawing.Name}, part2Rot={Angle.ToDegrees(_part2Rotation):F1}deg, spacing={spacing}, stepSize={stepSize} ---");
// Orbiting polygon: same shape rotated to Part2's angle.
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
Log($" Stationary hull: {_stationaryHull.Vertices.Count} verts, bounds={FormatBounds(_stationaryHull)}");
Log($" Orbiting perimeter (rotated): {orbitingPerimeter.Vertices.Count} verts, bounds={FormatBounds(orbitingPerimeter)}");
Log($" Orbiting hull: {orbitingPoly.Vertices.Count} verts, bounds={FormatBounds(orbitingPoly)}");
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
if (nfp == null || nfp.Vertices.Count < 3)
{
Log($" NFP failed or degenerate (verts={nfp?.Vertices.Count ?? 0})");
return candidates;
}
var verts = nfp.Vertices;
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
Log($" NFP: {verts.Count} verts (closed={nfp.IsClosed()}, walking {vertCount}), bounds={FormatBounds(nfp)}");
Log($" Correction: ({_correction.X:F4}, {_correction.Y:F4})");
// Log NFP vertices
for (var v = 0; v < vertCount; v++)
Log($" NFP vert[{v}]: ({verts[v].X:F4}, {verts[v].Y:F4}) -> corrected: ({verts[v].X - _correction.X:F4}, {verts[v].Y - _correction.Y:F4})");
// Compare with what RotationSlideStrategy would produce
var part1 = Part.CreateAtOrigin(drawing);
var part2 = Part.CreateAtOrigin(drawing, _part2Rotation);
Log($" Part1 (rot=0): loc=({part1.Location.X:F4}, {part1.Location.Y:F4}), bbox={part1.BoundingBox}");
Log($" Part2 (rot={Angle.ToDegrees(_part2Rotation):F1}): loc=({part2.Location.X:F4}, {part2.Location.Y:F4}), bbox={part2.BoundingBox}");
var testNumber = 0;
for (var i = 0; i < vertCount; i++)
{
var offset = ApplyCorrection(verts[i], _correction);
candidates.Add(MakeCandidate(drawing, offset, spacing, testNumber++));
// Add edge samples for long edges.
var next = (i + 1) % vertCount;
var dx = verts[next].X - verts[i].X;
var dy = verts[next].Y - verts[i].Y;
var edgeLength = System.Math.Sqrt(dx * dx + dy * dy);
if (edgeLength > stepSize)
{
var steps = (int)(edgeLength / stepSize);
for (var s = 1; s < steps; s++)
{
var t = (double)s / steps;
var sample = new Vector(
verts[i].X + dx * t,
verts[i].Y + dy * t);
var sampleOffset = ApplyCorrection(sample, _correction);
candidates.Add(MakeCandidate(drawing, sampleOffset, spacing, testNumber++));
}
}
}
// Log overlap check for vertex candidates (first few)
var checkCount = System.Math.Min(vertCount, 8);
for (var c = 0; c < checkCount; c++)
{
var cand = candidates[c];
var p2 = Part.CreateAtOrigin(drawing, cand.Part2Rotation);
p2.Location = cand.Part2Offset;
var overlaps = part1.Intersects(p2, out _);
Log($" Candidate[{c}]: offset=({cand.Part2Offset.X:F4}, {cand.Part2Offset.Y:F4}), overlaps={overlaps}");
}
Log($" Total candidates: {candidates.Count}");
Log("");
return candidates;
}
private static Vector ApplyCorrection(Vector nfpVertex, Vector correction)
{
return new Vector(nfpVertex.X - correction.X, nfpVertex.Y - correction.Y);
}
private PairCandidate MakeCandidate(Drawing drawing, Vector offset, double spacing, int testNumber)
{
return new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = _part2Rotation,
Part2Offset = offset,
StrategyIndex = StrategyIndex,
TestNumber = testNumber,
Spacing = spacing
};
}
private static string FormatBounds(Polygon polygon)
{
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
return $"[({bb.Left:F4}, {bb.Bottom:F4})-({bb.Right:F4}, {bb.Top:F4}), {bb.Width:F2}x{bb.Length:F2}]";
}
private static void Log(string message)
{
lock (LogLock)
{
File.AppendAllText(LogPath, message + "\n");
}
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ namespace OpenNest.Engine.BestFit
public double Part1Rotation { get; set; }
public double Part2Rotation { get; set; }
public Vector Part2Offset { get; set; }
public int StrategyType { get; set; }
public int StrategyIndex { get; set; }
public int TestNumber { get; set; }
public double Spacing { get; set; }
}
+77
View File
@@ -0,0 +1,77 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Linq;
namespace OpenNest.Engine.BestFit
{
public static class PolygonHelper
{
public static PolygonExtractionResult ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count == 0)
return new PolygonExtractionResult(null, Vector.Zero);
var definedShape = new ShapeProfile(entities);
var perimeter = definedShape.Perimeter;
if (perimeter == null)
return new PolygonExtractionResult(null, Vector.Zero);
// Inflate by half-spacing if spacing is non-zero.
// OffsetSide.Right = outward for CCW perimeters (standard for outer contours).
var inflated = halfSpacing > 0
? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Right) as Shape ?? perimeter)
: perimeter;
// Convert to polygon with circumscribed arcs for tight nesting.
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
if (polygon.Vertices.Count < 3)
return new PolygonExtractionResult(null, Vector.Zero);
// Normalize: move polygon to origin.
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
polygon.Offset(-bb.Left, -bb.Bottom);
// No correction needed: BestFitFinder always pairs the same drawing with
// itself, so the polygon-to-part offset is identical for both parts and
// cancels out in the NFP displacement.
return new PolygonExtractionResult(polygon, Vector.Zero);
}
public static Polygon RotatePolygon(Polygon polygon, double angle, bool reNormalize = true)
{
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));
}
if (reNormalize)
{
// Re-normalize to origin.
result.UpdateBounds();
var bb = result.BoundingBox;
result.Offset(-bb.Left, -bb.Bottom);
}
return result;
}
}
public record PolygonExtractionResult(Polygon Polygon, Vector Correction);
}
+43 -166
View File
@@ -1,29 +1,31 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.BestFit
{
public class RotationSlideStrategy : IBestFitStrategy
{
private readonly ISlideComputer _slideComputer;
private readonly IDistanceComputer _distanceComputer;
private static readonly PushDirection[] AllDirections =
private static readonly (double DirX, double DirY)[] PushDirections =
{
PushDirection.Left, PushDirection.Down, PushDirection.Right, PushDirection.Up
(-1, 0), // Left
(0, -1), // Down
(1, 0), // Right
(0, 1) // Up
};
public RotationSlideStrategy(double part2Rotation, int type, string description,
ISlideComputer slideComputer = null)
public RotationSlideStrategy(double part2Rotation, int strategyIndex, string description,
IDistanceComputer distanceComputer)
{
Part2Rotation = part2Rotation;
Type = type;
StrategyIndex = strategyIndex;
Description = description;
_slideComputer = slideComputer;
_distanceComputer = distanceComputer;
}
public double Part2Rotation { get; }
public int Type { get; }
public int StrategyIndex { get; }
public string Description { get; }
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
@@ -40,36 +42,25 @@ namespace OpenNest.Engine.BestFit
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
// Collect offsets and directions across all 4 axes
var allDx = new List<double>();
var allDy = new List<double>();
var allDirs = new List<PushDirection>();
var offsets = BuildOffsets(bbox1, bbox2, spacing, stepSize);
foreach (var pushDir in AllDirections)
BuildOffsets(bbox1, bbox2, spacing, stepSize, pushDir, allDx, allDy, allDirs);
if (allDx.Count == 0)
if (offsets.Length == 0)
return candidates;
// Compute all distances — single GPU dispatch or CPU loop
var distances = ComputeAllDistances(
part1Lines, part2TemplateLines, allDx, allDy, allDirs);
var distances = _distanceComputer.ComputeDistances(
part1Lines, part2TemplateLines, offsets);
// Create candidates from valid results
var testNumber = 0;
for (var i = 0; i < allDx.Count; i++)
for (var i = 0; i < offsets.Length; i++)
{
var slideDist = distances[i];
if (slideDist >= double.MaxValue || slideDist < 0)
continue;
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);
part2Template.Location.X + offsets[i].Dx + offsets[i].DirX * slideDist,
part2Template.Location.Y + offsets[i].Dy + offsets[i].DirY * slideDist);
candidates.Add(new PairCandidate
{
@@ -77,7 +68,7 @@ namespace OpenNest.Engine.BestFit
Part1Rotation = 0,
Part2Rotation = Part2Rotation,
Part2Offset = finalPosition,
StrategyType = Type,
StrategyIndex = StrategyIndex,
TestNumber = testNumber++,
Spacing = spacing
});
@@ -86,158 +77,44 @@ namespace OpenNest.Engine.BestFit
return candidates;
}
private static void BuildOffsets(
Box bbox1, Box bbox2, double spacing, double stepSize,
PushDirection pushDir, List<double> allDx, List<double> allDy,
List<PushDirection> allDirs)
private static SlideOffset[] BuildOffsets(Box bbox1, Box bbox2, double spacing, double stepSize)
{
var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right;
var offsets = new List<SlideOffset>();
double perpMin, perpMax, pushStartOffset;
if (isHorizontalPush)
foreach (var (dirX, dirY) in PushDirections)
{
perpMin = -(bbox2.Length + spacing);
perpMax = bbox1.Length + bbox2.Length + spacing;
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
}
else
{
perpMin = -(bbox2.Width + spacing);
perpMax = bbox1.Width + bbox2.Width + spacing;
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
}
var isHorizontalPush = System.Math.Abs(dirX) > System.Math.Abs(dirY);
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
double perpMin, perpMax, pushStartOffset;
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
{
allDx.Add(isHorizontalPush ? startPos : offset);
allDy.Add(isHorizontalPush ? offset : startPos);
allDirs.Add(pushDir);
}
}
private double[] ComputeAllDistances(
List<Line> part1Lines, List<Line> part2TemplateLines,
List<double> allDx, List<double> allDy, List<PushDirection> allDirs)
{
var count = allDx.Count;
if (_slideComputer != null)
{
var stationarySegments = SpatialQuery.FlattenLines(part1Lines);
var movingSegments = SpatialQuery.FlattenLines(part2TemplateLines);
var offsets = new double[count * 2];
var directions = new int[count];
for (var i = 0; i < count; i++)
if (isHorizontalPush)
{
offsets[i * 2] = allDx[i];
offsets[i * 2 + 1] = allDy[i];
directions[i] = (int)allDirs[i];
perpMin = -(bbox2.Length + spacing);
perpMax = bbox1.Length + bbox2.Length + spacing;
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
}
return _slideComputer.ComputeBatchMultiDir(
stationarySegments, part1Lines.Count,
movingSegments, part2TemplateLines.Count,
offsets, count, directions);
}
var results = new double[count];
// Pre-calculate moving vertices in local space.
var movingVerticesLocal = new HashSet<Vector>();
for (var i = 0; i < part2TemplateLines.Count; 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<Vector>();
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<PushDirection, (Vector start, Vector end)[]>();
var movingEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
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 = 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);
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 = SpatialQuery.OppositeDirection(dir);
var minDist = double.MaxValue;
// Case 1: Moving vertices -> Stationary edges
foreach (var mv in movingVerticesArray)
{
var d = SpatialQuery.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir);
if (d < minDist) minDist = d;
perpMin = -(bbox2.Width + spacing);
perpMax = bbox1.Width + bbox2.Width + spacing;
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
}
// Case 2: Stationary vertices -> Moving edges (translated)
foreach (var sv in stationaryVerticesArray)
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
// Start on the opposite side of the push direction.
var pushComponent = isHorizontalPush ? dirX : dirY;
var startPos = pushComponent < 0 ? pushStartOffset : -pushStartOffset;
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
{
var d = SpatialQuery.OneWayDistance(sv, mEdges, movingOffset, opposite);
if (d < minDist) minDist = d;
var dx = isHorizontalPush ? startPos : offset;
var dy = isHorizontalPush ? offset : startPos;
offsets.Add(new SlideOffset(dx, dy, dirX, dirY));
}
results[i] = minDist;
});
return results;
}
private static Vector GetPushVector(PushDirection direction, double distance)
{
switch (direction)
{
case PushDirection.Left: return new Vector(-distance, 0);
case PushDirection.Right: return new Vector(distance, 0);
case PushDirection.Down: return new Vector(0, -distance);
case PushDirection.Up: return new Vector(0, distance);
default: return Vector.Zero;
}
return offsets.ToArray();
}
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace OpenNest.Engine.BestFit
{
public readonly struct SlideOffset
{
public double Dx { get; }
public double Dy { get; }
public double DirX { get; }
public double DirY { get; }
public SlideOffset(double dx, double dy, double dirX, double dirY)
{
Dx = dx;
Dy = dy;
DirX = dirX;
DirY = dirY;
}
}
}
-4
View File
@@ -1,5 +1,4 @@
using OpenNest.Engine.Fill;
using OpenNest.Engine.Nfp;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
@@ -130,9 +129,6 @@ namespace OpenNest
// Compact placed parts toward the origin to close gaps.
Compactor.Settle(allParts, Plate.WorkArea(), Plate.PartSpacing);
// NFP optimization pass — re-place parts using geometry-aware BLF.
allParts = AutoNester.Optimize(allParts, Plate);
return allParts;
}
+2 -59
View File
@@ -1,4 +1,3 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
@@ -203,44 +202,7 @@ namespace OpenNest.Engine.Nfp
/// </summary>
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.Left);
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 BestFit.PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing).Polygon;
}
/// <summary>
@@ -320,26 +282,7 @@ namespace OpenNest.Engine.Nfp
/// </summary>
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;
return BestFit.PolygonHelper.RotatePolygon(polygon, angle);
}
}
}
-3
View File
@@ -49,9 +49,6 @@ namespace OpenNest
// Compact placed parts toward the origin to close gaps.
Compactor.Settle(parts, Plate.WorkArea(), Plate.PartSpacing);
// NFP optimization pass — re-place parts using geometry-aware BLF.
parts = AutoNester.Optimize(parts, Plate);
// Deduct placed quantities from original items.
foreach (var item in items)
{
-4
View File
@@ -1,5 +1,4 @@
using OpenNest.Engine.Fill;
using OpenNest.Engine.Nfp;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
@@ -123,9 +122,6 @@ namespace OpenNest
}
}
// NFP optimization pass — re-place parts using geometry-aware BLF.
allParts = AutoNester.Optimize(allParts, Plate);
// Deduct placed quantities from original items.
foreach (var item in items)
{
+1 -1
View File
@@ -129,7 +129,7 @@ namespace OpenNest.IO
Part1Rotation = r.Part1Rotation,
Part2Rotation = r.Part2Rotation,
Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY),
StrategyType = r.StrategyType,
StrategyIndex = r.StrategyType,
TestNumber = r.TestNumber,
Spacing = r.CandidateSpacing
},
+1 -1
View File
@@ -214,7 +214,7 @@ namespace OpenNest.IO
Part2Rotation = r.Candidate.Part2Rotation,
Part2OffsetX = r.Candidate.Part2Offset.X,
Part2OffsetY = r.Candidate.Part2Offset.Y,
StrategyType = r.Candidate.StrategyType,
StrategyType = r.Candidate.StrategyIndex,
TestNumber = r.Candidate.TestNumber,
CandidateSpacing = r.Candidate.Spacing,
RotatedArea = r.RotatedArea,
@@ -0,0 +1,57 @@
using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class NfpBestFitIntegrationTests
{
[Fact]
public void FindBestFits_ReturnsKeptResults_ForSquare()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeSquareDrawing();
var results = finder.FindBestFits(drawing);
Assert.NotEmpty(results);
Assert.NotEmpty(results.Where(r => r.Keep));
}
[Fact]
public void FindBestFits_ResultsHaveValidDimensions()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeSquareDrawing();
var results = finder.FindBestFits(drawing);
foreach (var result in results.Where(r => r.Keep))
{
Assert.True(result.BoundingWidth > 0);
Assert.True(result.BoundingHeight > 0);
Assert.True(result.RotatedArea > 0);
}
}
[Fact]
public void FindBestFits_LShape_HasBetterUtilization_ThanBoundingBox()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeLShapeDrawing();
var results = finder.FindBestFits(drawing);
var bestUtilization = results
.Where(r => r.Keep)
.Max(r => r.Utilization);
Assert.True(bestUtilization > 0.5);
}
[Fact]
public void FindBestFits_NoOverlaps_InKeptResults()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeSquareDrawing();
var results = finder.FindBestFits(drawing);
Assert.All(results.Where(r => r.Keep), r =>
Assert.Equal("Valid", r.Reason));
}
}
+124
View File
@@ -0,0 +1,124 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests;
public class NfpSlideStrategyTests
{
[Fact]
public void GenerateCandidates_ReturnsNonEmpty_ForSquare()
{
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.NotEmpty(candidates);
}
[Fact]
public void GenerateCandidates_AllCandidatesHaveCorrectDrawing()
{
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.All(candidates, c => Assert.Same(drawing, c.Drawing));
}
[Fact]
public void GenerateCandidates_Part1RotationIsAlwaysZero()
{
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, Angle.HalfPI, 1, "90 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.All(candidates, c => Assert.Equal(0, c.Part1Rotation));
}
[Fact]
public void GenerateCandidates_Part2RotationMatchesStrategy()
{
var rotation = Angle.HalfPI;
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, rotation, 1, "90 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.All(candidates, c => Assert.Equal(rotation, c.Part2Rotation));
}
[Fact]
public void GenerateCandidates_ProducesReasonableCandidateCount()
{
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
// Convex hull NFP for a square produces vertices + edge samples.
// Should have more than just vertices but not thousands.
Assert.True(candidates.Count >= 4);
Assert.True(candidates.Count < 1000);
}
[Fact]
public void GenerateCandidates_MoreCandidates_WithSmallerStepSize()
{
var drawing = TestHelpers.MakeSquareDrawing();
var largeStepStrategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
var smallStepStrategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
Assert.NotNull(largeStepStrategy);
Assert.NotNull(smallStepStrategy);
var largeStep = largeStepStrategy.GenerateCandidates(drawing, 0.25, 5.0);
var smallStep = smallStepStrategy.GenerateCandidates(drawing, 0.25, 0.5);
Assert.True(smallStep.Count >= largeStep.Count);
}
[Fact]
public void Create_ReturnsNull_ForEmptyDrawing()
{
var pgm = new Program();
var drawing = new Drawing("empty", pgm);
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
Assert.Null(strategy);
}
[Fact]
public void GenerateCandidates_LShape_ProducesCandidates()
{
var lshape = TestHelpers.MakeLShapeDrawing();
var strategy = NfpSlideStrategy.Create(lshape, 0, 1, "0 deg NFP", 0.25);
Assert.NotNull(strategy);
var candidates = strategy.GenerateCandidates(lshape, 0.25, 0.25);
Assert.NotEmpty(candidates);
}
[Fact]
public void GenerateCandidates_At180Degrees_ProducesAtLeastOneNonOverlappingCandidate()
{
var drawing = TestHelpers.MakeSquareDrawing();
var strategy = NfpSlideStrategy.Create(drawing, System.Math.PI, 1, "180 deg NFP", 1.0);
Assert.NotNull(strategy);
// Use a large spacing (1.0) and step size.
// This should make NFP much larger than the parts.
var candidates = strategy.GenerateCandidates(drawing, 1.0, 1.0);
Assert.NotEmpty(candidates);
var part1 = Part.CreateAtOrigin(drawing);
var validCount = 0;
foreach (var candidate in candidates)
{
var part2 = Part.CreateAtOrigin(drawing, candidate.Part2Rotation);
part2.Location = candidate.Part2Offset;
// With 1.0 spacing, parts should NOT intersect even with tiny precision errors.
if (!part1.Intersects(part2, out _))
validCount++;
}
Assert.True(validCount > 0, $"No non-overlapping candidates found out of {candidates.Count} total. Candidate 0 offset: {candidates[0].Part2Offset}");
}
}
+88
View File
@@ -0,0 +1,88 @@
using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests;
public class PolygonHelperTests
{
[Fact]
public void ExtractPerimeterPolygon_ReturnsPolygon_ForValidDrawing()
{
var drawing = TestHelpers.MakeSquareDrawing();
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
Assert.NotNull(result.Polygon);
Assert.True(result.Polygon.Vertices.Count >= 4);
}
[Fact]
public void ExtractPerimeterPolygon_InflatesPolygon_WhenSpacingNonZero()
{
var drawing = TestHelpers.MakeSquareDrawing(10);
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1);
noSpacing.Polygon.UpdateBounds();
withSpacing.Polygon.UpdateBounds();
// The offset polygon should differ in size from the non-offset polygon.
// OffsetSide.Left offsets outward or inward depending on winding,
// but either way the result must be a different size.
Assert.True(
System.Math.Abs(withSpacing.Polygon.BoundingBox.Width - noSpacing.Polygon.BoundingBox.Width) > 0.5,
$"Expected polygon width to differ by >0.5 with 1mm spacing. " +
$"No-spacing width: {noSpacing.Polygon.BoundingBox.Width:F3}, " +
$"With-spacing width: {withSpacing.Polygon.BoundingBox.Width:F3}");
}
[Fact]
public void ExtractPerimeterPolygon_ReturnsNull_ForEmptyDrawing()
{
var pgm = new Program();
var drawing = new Drawing("empty", pgm);
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
Assert.Null(result.Polygon);
}
[Fact]
public void ExtractPerimeterPolygon_CorrectionVector_ReflectsOriginDifference()
{
var drawing = TestHelpers.MakeSquareDrawing();
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
Assert.NotNull(result.Polygon);
Assert.True(System.Math.Abs(result.Correction.X) < 1);
Assert.True(System.Math.Abs(result.Correction.Y) < 1);
}
[Fact]
public void RotatePolygon_AtZero_ReturnsSamePolygon()
{
var polygon = new Polygon();
polygon.Vertices.Add(new Vector(0, 0));
polygon.Vertices.Add(new Vector(10, 0));
polygon.Vertices.Add(new Vector(10, 10));
polygon.Vertices.Add(new Vector(0, 10));
polygon.UpdateBounds();
var rotated = PolygonHelper.RotatePolygon(polygon, 0);
Assert.Same(polygon, rotated);
}
[Fact]
public void RotatePolygon_At90Degrees_SwapsDimensions()
{
var polygon = new Polygon();
polygon.Vertices.Add(new Vector(0, 0));
polygon.Vertices.Add(new Vector(20, 0));
polygon.Vertices.Add(new Vector(20, 10));
polygon.Vertices.Add(new Vector(0, 10));
polygon.UpdateBounds();
var rotated = PolygonHelper.RotatePolygon(polygon, Angle.HalfPI);
rotated.UpdateBounds();
Assert.True(System.Math.Abs(rotated.BoundingBox.Width - 10) < 0.1);
Assert.True(System.Math.Abs(rotated.BoundingBox.Length - 20) < 0.1);
}
}
+24
View File
@@ -24,4 +24,28 @@ internal static class TestHelpers
plate.Parts.Add(p);
return plate;
}
public static Drawing MakeSquareDrawing(double size = 10)
{
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)));
return new Drawing("square", pgm);
}
public static Drawing MakeLShapeDrawing()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 5)));
pgm.Codes.Add(new LinearMove(new Vector(5, 5)));
pgm.Codes.Add(new LinearMove(new Vector(5, 10)));
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("lshape", pgm);
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ namespace OpenNest.Controls
metadataLines = new[]
{
string.Format("#{0} {1:F1}x{2:F1} Area={3:F1}",
rank, result.BoundingWidth, result.BoundingHeight, result.RotatedArea),
rank, result.BoundingHeight, result.BoundingWidth, result.RotatedArea),
string.Format("Util={0:P1} Rot={1:F1}\u00b0",
result.Utilization,
Angle.ToDegrees(result.OptimalRotation)),
+1 -2
View File
@@ -2,7 +2,6 @@
using OpenNest.CNC;
using OpenNest.Collections;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Nfp;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -961,7 +960,7 @@ namespace OpenNest.Controls
{
var result = engine.Fill(groupParts, workArea, progress, cts.Token);
Compactor.Settle(result, workArea, spacing);
return AutoNester.Optimize(result, workArea, spacing);
return result;
});
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
+2 -2
View File
@@ -291,8 +291,8 @@ namespace OpenNest.Forms
cell.PartColor = partColor;
cell.Dock = DockStyle.Fill;
cell.Plate.Size = new Geometry.Size(
result.BoundingWidth,
result.BoundingHeight);
result.BoundingHeight,
result.BoundingWidth);
var parts = result.BuildParts(drawing);
+12 -2
View File
@@ -131,6 +131,7 @@
plateIndexStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
plateSizeStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
plateQtyStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
plateUtilStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
gpuStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
selectionStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
toolStrip1 = new System.Windows.Forms.ToolStrip();
@@ -829,7 +830,7 @@
//
// statusStrip1
//
statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { statusLabel1, locationStatusLabel, selectionStatusLabel, spacerLabel, plateIndexStatusLabel, plateSizeStatusLabel, plateQtyStatusLabel, gpuStatusLabel });
statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { statusLabel1, locationStatusLabel, selectionStatusLabel, spacerLabel, plateIndexStatusLabel, plateSizeStatusLabel, plateQtyStatusLabel, plateUtilStatusLabel, gpuStatusLabel });
statusStrip1.Location = new System.Drawing.Point(0, 630);
statusStrip1.Name = "statusStrip1";
statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0);
@@ -889,7 +890,15 @@
plateQtyStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
plateQtyStatusLabel.Size = new System.Drawing.Size(55, 19);
plateQtyStatusLabel.Text = "Qty : 0";
//
//
// plateUtilStatusLabel
//
plateUtilStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left;
plateUtilStatusLabel.Name = "plateUtilStatusLabel";
plateUtilStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
plateUtilStatusLabel.Size = new System.Drawing.Size(75, 19);
plateUtilStatusLabel.Text = "Util : 0.0%";
//
// gpuStatusLabel
//
gpuStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left;
@@ -1128,6 +1137,7 @@
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem10;
private System.Windows.Forms.ToolStripMenuItem mnuCloseAll;
private System.Windows.Forms.ToolStripStatusLabel plateQtyStatusLabel;
private System.Windows.Forms.ToolStripStatusLabel plateUtilStatusLabel;
private System.Windows.Forms.ToolStripMenuItem mnuFileExportAll;
private System.Windows.Forms.ToolStripMenuItem openNestToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem pEPToolStripMenuItem;
+12
View File
@@ -204,6 +204,7 @@ namespace OpenNest.Forms
plateIndexStatusLabel.Text = string.Empty;
plateSizeStatusLabel.Text = string.Empty;
plateQtyStatusLabel.Text = string.Empty;
plateUtilStatusLabel.Text = string.Empty;
return;
}
@@ -219,6 +220,10 @@ namespace OpenNest.Forms
plateQtyStatusLabel.Text = string.Format(
"Qty: {0}",
activeForm.PlateView.Plate.Quantity);
plateUtilStatusLabel.Text = string.Format(
"Util: {0:P1}",
activeForm.PlateView.Plate.Utilization());
}
private void UpdateSelectionStatus()
@@ -342,6 +347,8 @@ namespace OpenNest.Forms
activeForm.PlateView.MouseClick -= PlateView_MouseClick;
activeForm.PlateView.StatusChanged -= PlateView_StatusChanged;
activeForm.PlateView.SelectionChanged -= PlateView_SelectionChanged;
activeForm.PlateView.PartAdded -= PlateView_PartAdded;
activeForm.PlateView.PartRemoved -= PlateView_PartRemoved;
}
// If nesting is in progress and the active form changed, cancel nesting
@@ -367,6 +374,8 @@ namespace OpenNest.Forms
UpdateSelectionStatus();
activeForm.PlateView.StatusChanged += PlateView_StatusChanged;
activeForm.PlateView.SelectionChanged += PlateView_SelectionChanged;
activeForm.PlateView.PartAdded += PlateView_PartAdded;
activeForm.PlateView.PartRemoved += PlateView_PartRemoved;
mnuViewDrawRapids.Checked = activeForm.PlateView.DrawRapid;
mnuViewDrawBounds.Checked = activeForm.PlateView.DrawBounds;
statusLabel1.Text = activeForm.PlateView.Status;
@@ -1215,6 +1224,9 @@ namespace OpenNest.Forms
#region PlateView Events
private void PlateView_PartAdded(object sender, ItemAddedEventArgs<Part> e) => UpdatePlateStatus();
private void PlateView_PartRemoved(object sender, ItemRemovedEventArgs<Part> e) => UpdatePlateStatus();
private void PlateView_MouseMove(object sender, MouseEventArgs e)
{
UpdateLocationStatus();
@@ -0,0 +1,704 @@
# NFP Best-Fit Strategy Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the brute-force slide-based best-fit pair sampling with NFP-based candidate generation that produces mathematically exact interlocking positions.
**Architecture:** New `NfpSlideStrategy : IBestFitStrategy` generates `PairCandidate` offsets from NFP boundary vertices/edges. Shared polygon helper extracted from `AutoNester` to avoid duplication. `BestFitFinder.BuildStrategies` swaps to the new strategy. Everything downstream (evaluator, filter, tiling) stays unchanged.
**Tech Stack:** C# / .NET 8, xunit, existing `NoFitPolygon` (Minkowski sum via Clipper2), `ShapeProfile`, `ConvertProgram`
**Spec:** `docs/superpowers/specs/2026-03-20-nfp-bestfit-strategy-design.md`
---
### Task 1: Extract `PolygonHelper` from `AutoNester`
**Files:**
- Create: `OpenNest.Engine/BestFit/PolygonHelper.cs`
- Modify: `OpenNest.Engine/Nfp/AutoNester.cs:204-343`
- Test: `OpenNest.Tests/PolygonHelperTests.cs`
- [ ] **Step 1: Add shared test helpers and write tests for `PolygonHelper`**
Add `MakeSquareDrawing` and `MakeLShapeDrawing` to `OpenNest.Tests/TestHelpers.cs`:
```csharp
public static Drawing MakeSquareDrawing(double size = 10)
{
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)));
return new Drawing("square", pgm);
}
public static Drawing MakeLShapeDrawing()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 5)));
pgm.Codes.Add(new LinearMove(new Vector(5, 5)));
pgm.Codes.Add(new LinearMove(new Vector(5, 10)));
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("lshape", pgm);
}
```
Then create `OpenNest.Tests/PolygonHelperTests.cs`:
```csharp
using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests;
public class PolygonHelperTests
{
[Fact]
public void ExtractPerimeterPolygon_ReturnsPolygon_ForValidDrawing()
{
var drawing = TestHelpers.MakeSquareDrawing();
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
Assert.NotNull(result.Polygon);
Assert.True(result.Polygon.Vertices.Count >= 4);
}
[Fact]
public void ExtractPerimeterPolygon_InflatesPolygon_WhenSpacingNonZero()
{
var drawing = TestHelpers.MakeSquareDrawing(10);
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1);
// Inflated polygon should have a larger bounding box.
noSpacing.Polygon.UpdateBounds();
withSpacing.Polygon.UpdateBounds();
Assert.True(withSpacing.Polygon.BoundingBox.Width > noSpacing.Polygon.BoundingBox.Width);
}
[Fact]
public void ExtractPerimeterPolygon_ReturnsNull_ForEmptyDrawing()
{
var pgm = new Program();
var drawing = new Drawing("empty", pgm);
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
Assert.Null(result.Polygon);
}
[Fact]
public void ExtractPerimeterPolygon_CorrectionVector_ReflectsOriginDifference()
{
// Square drawing: program bbox starts at (0,0) due to rapid move,
// perimeter bbox also starts at (0,0) — correction should be near zero.
var drawing = TestHelpers.MakeSquareDrawing();
var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
Assert.NotNull(result.Polygon);
// For a simple square starting at origin, correction should be small.
Assert.True(System.Math.Abs(result.Correction.X) < 1);
Assert.True(System.Math.Abs(result.Correction.Y) < 1);
}
[Fact]
public void RotatePolygon_AtZero_ReturnsSamePolygon()
{
var polygon = new Polygon();
polygon.Vertices.Add(new Vector(0, 0));
polygon.Vertices.Add(new Vector(10, 0));
polygon.Vertices.Add(new Vector(10, 10));
polygon.Vertices.Add(new Vector(0, 10));
polygon.UpdateBounds();
var rotated = PolygonHelper.RotatePolygon(polygon, 0);
Assert.Same(polygon, rotated);
}
[Fact]
public void RotatePolygon_At90Degrees_SwapsDimensions()
{
var polygon = new Polygon();
polygon.Vertices.Add(new Vector(0, 0));
polygon.Vertices.Add(new Vector(20, 0));
polygon.Vertices.Add(new Vector(20, 10));
polygon.Vertices.Add(new Vector(0, 10));
polygon.UpdateBounds();
var rotated = PolygonHelper.RotatePolygon(polygon, Angle.HalfPI);
rotated.UpdateBounds();
// Width and height should swap (approximately).
Assert.True(System.Math.Abs(rotated.BoundingBox.Width - 10) < 0.1);
Assert.True(System.Math.Abs(rotated.BoundingBox.Length - 20) < 0.1);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~PolygonHelperTests" --no-build 2>&1 || dotnet test OpenNest.Tests --filter "FullyQualifiedName~PolygonHelperTests"`
Expected: Build error — `PolygonHelper` does not exist yet.
- [ ] **Step 3: Create `PolygonHelper.cs`**
Create `OpenNest.Engine/BestFit/PolygonHelper.cs`:
```csharp
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Engine.BestFit
{
public static class PolygonHelper
{
public static PolygonExtractionResult ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count == 0)
return new PolygonExtractionResult(null, Vector.Zero);
var definedShape = new ShapeProfile(entities);
var perimeter = definedShape.Perimeter;
if (perimeter == null)
return new PolygonExtractionResult(null, Vector.Zero);
// Compute the perimeter bounding box before inflation for coordinate correction.
perimeter.UpdateBounds();
var perimeterBb = perimeter.BoundingBox;
// Inflate by half-spacing if spacing is non-zero.
Shape inflated;
if (halfSpacing > 0)
{
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Left);
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 new PolygonExtractionResult(null, Vector.Zero);
// Compute correction: difference between program origin and perimeter origin.
// Part.CreateAtOrigin normalizes to program bbox; polygon normalizes to perimeter bbox.
var programBb = drawing.Program.BoundingBox();
var correction = new Vector(
perimeterBb.Left - programBb.Location.X,
perimeterBb.Bottom - programBb.Location.Y);
// Normalize: move reference point to origin.
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
polygon.Offset(-bb.Left, -bb.Bottom);
return new PolygonExtractionResult(polygon, correction);
}
public 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;
}
}
public record PolygonExtractionResult(Polygon Polygon, Vector Correction);
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~PolygonHelperTests"`
Expected: All 6 tests PASS.
- [ ] **Step 5: Update `AutoNester` to delegate to `PolygonHelper`**
In `OpenNest.Engine/Nfp/AutoNester.cs`, replace the private `ExtractPerimeterPolygon` and `RotatePolygon` methods (lines 204-343) with delegates to `PolygonHelper`:
```csharp
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
{
return BestFit.PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing).Polygon;
}
private static Polygon RotatePolygon(Polygon polygon, double angle)
{
return BestFit.PolygonHelper.RotatePolygon(polygon, angle);
}
```
- [ ] **Step 6: Build solution to verify no regressions**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds with no errors.
- [ ] **Step 7: Commit**
```bash
git add OpenNest.Engine/BestFit/PolygonHelper.cs OpenNest.Tests/PolygonHelperTests.cs OpenNest.Tests/TestHelpers.cs OpenNest.Engine/Nfp/AutoNester.cs
git commit -m "refactor: extract PolygonHelper from AutoNester for shared polygon operations"
```
---
### Task 2: Create `NfpSlideStrategy`
**Files:**
- Create: `OpenNest.Engine/BestFit/NfpSlideStrategy.cs`
- Test: `OpenNest.Tests/NfpSlideStrategyTests.cs`
- [ ] **Step 1: Write tests for `NfpSlideStrategy`**
Create `OpenNest.Tests/NfpSlideStrategyTests.cs`:
```csharp
using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests;
public class NfpSlideStrategyTests
{
[Fact]
public void GenerateCandidates_ReturnsNonEmpty_ForSquare()
{
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
var drawing = TestHelpers.MakeSquareDrawing();
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.NotEmpty(candidates);
}
[Fact]
public void GenerateCandidates_AllCandidatesHaveCorrectDrawing()
{
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
var drawing = TestHelpers.MakeSquareDrawing();
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.All(candidates, c => Assert.Same(drawing, c.Drawing));
}
[Fact]
public void GenerateCandidates_Part1RotationIsAlwaysZero()
{
var strategy = new NfpSlideStrategy(Angle.HalfPI, 1, "90 deg NFP");
var drawing = TestHelpers.MakeSquareDrawing();
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.All(candidates, c => Assert.Equal(0, c.Part1Rotation));
}
[Fact]
public void GenerateCandidates_Part2RotationMatchesStrategy()
{
var rotation = Angle.HalfPI;
var strategy = new NfpSlideStrategy(rotation, 1, "90 deg NFP");
var drawing = TestHelpers.MakeSquareDrawing();
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.All(candidates, c => Assert.Equal(rotation, c.Part2Rotation));
}
[Fact]
public void GenerateCandidates_NoDuplicateOffsets()
{
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
var drawing = TestHelpers.MakeSquareDrawing();
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
var uniqueOffsets = candidates
.Select(c => (System.Math.Round(c.Part2Offset.X, 6), System.Math.Round(c.Part2Offset.Y, 6)))
.Distinct()
.Count();
Assert.Equal(candidates.Count, uniqueOffsets);
}
[Fact]
public void GenerateCandidates_MoreCandidates_WithSmallerStepSize()
{
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
var drawing = TestHelpers.MakeSquareDrawing();
var largeStep = strategy.GenerateCandidates(drawing, 0.25, 5.0);
var smallStep = strategy.GenerateCandidates(drawing, 0.25, 0.5);
// Smaller step should add more edge samples.
Assert.True(smallStep.Count >= largeStep.Count);
}
[Fact]
public void GenerateCandidates_ReturnsEmpty_ForEmptyDrawing()
{
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
var pgm = new Program();
var drawing = new Drawing("empty", pgm);
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.Empty(candidates);
}
[Fact]
public void GenerateCandidates_LShape_ProducesMoreCandidates_ThanSquare()
{
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
var square = TestHelpers.MakeSquareDrawing();
var lshape = TestHelpers.MakeLShapeDrawing();
var squareCandidates = strategy.GenerateCandidates(square, 0.25, 0.25);
var lshapeCandidates = strategy.GenerateCandidates(lshape, 0.25, 0.25);
// L-shape NFP has more vertices/edges than square NFP.
Assert.True(lshapeCandidates.Count > squareCandidates.Count);
}
[Fact]
public void GenerateCandidates_At180Degrees_ProducesCandidates()
{
var strategy = new NfpSlideStrategy(System.Math.PI, 1, "180 deg NFP");
var drawing = TestHelpers.MakeSquareDrawing();
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
Assert.NotEmpty(candidates);
Assert.All(candidates, c => Assert.Equal(System.Math.PI, c.Part2Rotation));
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~NfpSlideStrategyTests" --no-build 2>&1 || dotnet test OpenNest.Tests --filter "FullyQualifiedName~NfpSlideStrategyTests"`
Expected: Build error — `NfpSlideStrategy` does not exist yet.
- [ ] **Step 3: Create `NfpSlideStrategy.cs`**
Create `OpenNest.Engine/BestFit/NfpSlideStrategy.cs`:
```csharp
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Engine.BestFit
{
public class NfpSlideStrategy : IBestFitStrategy
{
private readonly double _part2Rotation;
public NfpSlideStrategy(double part2Rotation, int type, string description)
{
_part2Rotation = part2Rotation;
Type = type;
Description = description;
}
public int Type { get; }
public string Description { get; }
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
{
var candidates = new List<PairCandidate>();
var halfSpacing = spacing / 2;
// Extract stationary polygon (Part1 at rotation 0), with spacing applied.
var stationaryResult = PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing);
if (stationaryResult.Polygon == null)
return candidates;
var stationaryPoly = stationaryResult.Polygon;
// Extract orbiting polygon (Part2 at _part2Rotation).
// Reuse stationary result if rotation is 0, otherwise rotate.
var orbitingPoly = PolygonHelper.RotatePolygon(stationaryResult.Polygon, _part2Rotation);
// Compute NFP.
var nfp = NoFitPolygon.Compute(stationaryPoly, orbitingPoly);
if (nfp == null || nfp.Vertices.Count < 3)
return candidates;
// Coordinate correction: NFP offsets are in polygon-space.
// Part.CreateAtOrigin uses program bbox origin.
var correction = stationaryResult.Correction;
// Walk NFP boundary — vertices + edge samples.
var verts = nfp.Vertices;
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
var testNumber = 0;
for (var i = 0; i < vertCount; i++)
{
// Add vertex candidate.
var offset = ApplyCorrection(verts[i], correction);
candidates.Add(MakeCandidate(drawing, offset, spacing, testNumber++));
// Add edge samples for long edges.
var next = (i + 1) % vertCount;
var dx = verts[next].X - verts[i].X;
var dy = verts[next].Y - verts[i].Y;
var edgeLength = System.Math.Sqrt(dx * dx + dy * dy);
if (edgeLength > stepSize)
{
var steps = (int)(edgeLength / stepSize);
for (var s = 1; s < steps; s++)
{
var t = (double)s / steps;
var sample = new Vector(
verts[i].X + dx * t,
verts[i].Y + dy * t);
var sampleOffset = ApplyCorrection(sample, correction);
candidates.Add(MakeCandidate(drawing, sampleOffset, spacing, testNumber++));
}
}
}
return candidates;
}
private static Vector ApplyCorrection(Vector nfpVertex, Vector correction)
{
return new Vector(nfpVertex.X + correction.X, nfpVertex.Y + correction.Y);
}
private PairCandidate MakeCandidate(Drawing drawing, Vector offset, double spacing, int testNumber)
{
return new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = _part2Rotation,
Part2Offset = offset,
StrategyType = Type,
TestNumber = testNumber,
Spacing = spacing
};
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~NfpSlideStrategyTests"`
Expected: All 9 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add OpenNest.Engine/BestFit/NfpSlideStrategy.cs OpenNest.Tests/NfpSlideStrategyTests.cs
git commit -m "feat: add NfpSlideStrategy for NFP-based best-fit candidate generation"
```
---
### Task 3: Wire `NfpSlideStrategy` into `BestFitFinder`
**Files:**
- Modify: `OpenNest.Engine/BestFit/BestFitFinder.cs:78-91`
- Test: `OpenNest.Tests/NfpBestFitIntegrationTests.cs`
- [ ] **Step 1: Write integration test**
Create `OpenNest.Tests/NfpBestFitIntegrationTests.cs`:
```csharp
using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class NfpBestFitIntegrationTests
{
[Fact]
public void FindBestFits_ReturnsKeptResults_ForSquare()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeSquareDrawing();
var results = finder.FindBestFits(drawing);
Assert.NotEmpty(results);
Assert.NotEmpty(results.Where(r => r.Keep));
}
[Fact]
public void FindBestFits_ResultsHaveValidDimensions()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeSquareDrawing();
var results = finder.FindBestFits(drawing);
foreach (var result in results.Where(r => r.Keep))
{
Assert.True(result.BoundingWidth > 0);
Assert.True(result.BoundingHeight > 0);
Assert.True(result.RotatedArea > 0);
}
}
[Fact]
public void FindBestFits_LShape_HasBetterUtilization_ThanBoundingBox()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeLShapeDrawing();
var results = finder.FindBestFits(drawing);
// At least one kept result should have >50% utilization
// (L-shapes interlock well, bounding box alone would be ~50%).
var bestUtilization = results
.Where(r => r.Keep)
.Max(r => r.Utilization);
Assert.True(bestUtilization > 0.5);
}
[Fact]
public void FindBestFits_NoOverlaps_InKeptResults()
{
var finder = new BestFitFinder(120, 60);
var drawing = TestHelpers.MakeSquareDrawing();
var results = finder.FindBestFits(drawing);
// All kept results should be non-overlapping (verified by PairEvaluator).
Assert.All(results.Where(r => r.Keep), r =>
Assert.Equal("Valid", r.Reason));
}
}
```
- [ ] **Step 2: Swap `BuildStrategies` to use `NfpSlideStrategy`**
In `OpenNest.Engine/BestFit/BestFitFinder.cs`, replace the `BuildStrategies` method (lines 78-91):
```csharp
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
{
var angles = GetRotationAngles(drawing);
var strategies = new List<IBestFitStrategy>();
var type = 1;
foreach (var angle in angles)
{
var desc = $"{Angle.ToDegrees(angle):F1} deg NFP";
strategies.Add(new NfpSlideStrategy(angle, type++, desc));
}
return strategies;
}
```
Add `using OpenNest.Math;` to the top of the file if not already present.
- [ ] **Step 3: Run integration tests**
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~NfpBestFitIntegrationTests"`
Expected: All 4 tests PASS with the NFP pipeline.
- [ ] **Step 4: Run all tests to check for regressions**
Run: `dotnet test OpenNest.Tests`
Expected: All tests PASS.
- [ ] **Step 5: Build full solution**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds.
- [ ] **Step 6: Commit**
```bash
git add OpenNest.Engine/BestFit/BestFitFinder.cs OpenNest.Tests/NfpBestFitIntegrationTests.cs
git commit -m "feat: wire NfpSlideStrategy into BestFitFinder pipeline"
```
---
### Task 4: Remove `AutoNester.Optimize` calls
The `Optimize` calls are dead weight for single-drawing fills (as discussed). Now that NFP is properly integrated via best-fit, remove the no-op optimization passes.
**Files:**
- Modify: `OpenNest.Engine/NestEngineBase.cs:133-134`
- Modify: `OpenNest.Engine/NfpNestEngine.cs:52-53`
- Modify: `OpenNest.Engine/StripNestEngine.cs:126-127`
- Modify: `OpenNest/Controls/PlateView.cs` (the line calling `AutoNester.Optimize`)
- [ ] **Step 1: Remove `AutoNester.Optimize` call from `NestEngineBase.cs`**
In `OpenNest.Engine/NestEngineBase.cs`, remove lines 133-134:
```csharp
// NFP optimization pass — re-place parts using geometry-aware BLF.
allParts = AutoNester.Optimize(allParts, Plate);
```
- [ ] **Step 2: Remove `AutoNester.Optimize` call from `NfpNestEngine.cs`**
In `OpenNest.Engine/NfpNestEngine.cs`, remove lines 52-53:
```csharp
// NFP optimization pass — re-place parts using geometry-aware BLF.
parts = AutoNester.Optimize(parts, Plate);
```
- [ ] **Step 3: Remove `AutoNester.Optimize` call from `StripNestEngine.cs`**
In `OpenNest.Engine/StripNestEngine.cs`, remove lines 126-127:
```csharp
// NFP optimization pass — re-place parts using geometry-aware BLF.
allParts = AutoNester.Optimize(allParts, Plate);
```
- [ ] **Step 4: Remove `AutoNester.Optimize` call from `PlateView.cs`**
In `OpenNest/Controls/PlateView.cs`, find the line calling `AutoNester.Optimize(result, workArea, spacing)` and replace:
```csharp
return AutoNester.Optimize(result, workArea, spacing);
```
with:
```csharp
return result;
```
- [ ] **Step 5: Run all tests**
Run: `dotnet test OpenNest.Tests`
Expected: All tests PASS.
- [ ] **Step 6: Build full solution**
Run: `dotnet build OpenNest.sln`
Expected: Build succeeds.
- [ ] **Step 7: Commit**
```bash
git add OpenNest.Engine/NestEngineBase.cs OpenNest.Engine/NfpNestEngine.cs OpenNest.Engine/StripNestEngine.cs OpenNest/Controls/PlateView.cs
git commit -m "perf: remove no-op AutoNester.Optimize calls from fill pipelines"
```
@@ -0,0 +1,129 @@
# NFP-Based Best-Fit Strategy
## Problem
The current best-fit pair generation uses `RotationSlideStrategy`, which samples Part2 positions by sliding it toward Part1 from 4 directions at discrete step sizes. This is brute-force: more precision requires more samples, it can miss optimal interlocking positions between steps, and it generates hundreds of candidates per rotation angle.
## Solution
Replace the slide-based sampling with NFP (No-Fit Polygon) computation. The NFP of two polygons gives the exact mathematical boundary of all valid positions where Part2 can touch Part1 without overlapping. Every point on that boundary is a guaranteed-valid candidate offset.
## Approach
Implement `NfpSlideStrategy : IBestFitStrategy` that plugs into the existing `BestFitFinder` pipeline. No changes to `PairEvaluator`, `BestFitFilter`, `BestFitResult`, tiling, or caching.
## Design
### New class: `NfpSlideStrategy`
**Location:** `OpenNest.Engine/BestFit/NfpSlideStrategy.cs`
**Implements:** `IBestFitStrategy`
**Constructor parameters:**
- `double part2Rotation` — rotation angle for Part2 (same as `RotationSlideStrategy`)
- `int type` — strategy type id (same as `RotationSlideStrategy`)
- `string description` — human-readable description
- `Polygon stationaryPoly` (optional) — pre-extracted stationary polygon to avoid redundant extraction across rotation angles
**`GenerateCandidates(Drawing drawing, double spacing, double stepSize)`:**
1. Extract perimeter polygon from the drawing inflated by `spacing / 2` using `PolygonHelper.ExtractPerimeterPolygon` (shared helper, extracted from `AutoNester`)
2. If polygon extraction fails (null), return empty list
3. Create a rotated copy of the polygon at `part2Rotation` using `PolygonHelper.RotatePolygon` (also extracted)
4. Compute `NoFitPolygon.Compute(stationaryPoly, orbitingPoly)` — single call
5. If the NFP is null or has fewer than 3 vertices, return empty list
6. Convert NFP vertices from polygon-space to Part-space (see Coordinate Correction below)
7. Walk the NFP boundary:
- Each vertex becomes a `PairCandidate` with that vertex as `Part2Offset`
- For edges longer than `stepSize`, add intermediate sample points starting at `stepSize` from the edge start, exclusive of endpoints (to avoid duplicates with vertex candidates)
- Skip the closing vertex if the polygon is closed (first == last)
8. Part1 is always at rotation 0, matching existing `RotationSlideStrategy` behavior
9. Return the candidates list
### Coordinate correction
`ExtractPerimeterPolygon` inflates by `halfSpacing` and re-normalizes to origin based on the inflated bounding box. `Part.CreateAtOrigin` normalizes using the raw program bounding box — a different reference point. NFP offsets are in polygon-space and must be mapped to Part-space.
**Correction:** Compute the offset between the two reference points:
```
programOrigin = (program.BoundingBox.Left, program.BoundingBox.Bottom)
polygonOrigin = (inflatedPerimeter.BoundingBox.Left, inflatedPerimeter.BoundingBox.Bottom) → (0, 0) after normalization
correction = programOrigin - polygonOrigin
```
Since both are normalized to (0,0), the actual correction is the difference between where the inflated perimeter's bottom-left sits relative to the program's bottom-left *before* normalization. In practice:
- The program bbox includes all entities (rapid moves, all layers)
- The perimeter polygon only uses non-rapid cut geometry, inflated outward
`PolygonHelper` will compute this correction vector once per drawing and return it alongside the polygon. `NfpSlideStrategy` applies it to each NFP vertex before creating `PairCandidate` offsets.
### Floating-point boundary tolerance
NFP boundary positions represent exact touching. Floating-point imprecision may cause `PairEvaluator`'s shape-intersection test to falsely detect overlap at valid boundary points. The `PairEvaluator` overlap check serves as a safety net — a few boundary positions may be filtered out, but the best results should remain valid since we sample many boundary points.
### Shared helper: `PolygonHelper`
**Location:** `OpenNest.Engine/BestFit/PolygonHelper.cs`
**Static methods extracted from `AutoNester`:**
- `ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)` — extracts and inflates the perimeter polygon
- `RotatePolygon(Polygon polygon, double angle)` — creates a rotated copy normalized to origin
After extraction, `AutoNester` delegates to these methods to avoid duplication.
### Changes to `BestFitFinder.BuildStrategies`
Replace `RotationSlideStrategy` instances with `NfpSlideStrategy` instances. Same rotation angles from `GetRotationAngles(drawing)`, different strategy class. No `ISlideComputer` dependency needed.
Extract the stationary polygon once and pass it to each strategy to avoid redundant computation (strategies run in `Parallel.ForEach`):
```csharp
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
{
var angles = GetRotationAngles(drawing);
var strategies = new List<IBestFitStrategy>();
var type = 1;
// Extract stationary polygon once, shared across all rotation strategies.
var stationaryPoly = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
foreach (var angle in angles)
{
var desc = $"{Angle.ToDegrees(angle):F1} deg NFP";
strategies.Add(new NfpSlideStrategy(angle, type++, desc, stationaryPoly));
}
return strategies;
}
```
Note: spacing inflation is applied inside `GenerateCandidates` since it depends on the `spacing` parameter, not at strategy construction time.
### No changes required
- `PairEvaluator` — still evaluates candidates (overlap check becomes redundant but harmless and fast)
- `BestFitFilter` — still filters results by aspect ratio, plate fit, etc.
- `BestFitResult` — unchanged
- `BestFitCache` — unchanged
- Tiling pipeline — unchanged
- `PairsFillStrategy` — unchanged
## Edge Sampling
NFP vertices alone may miss optimal positions along long straight edges. For each edge of the NFP polygon where `edgeLength > stepSize`, interpolate additional points at `stepSize` intervals. This reuses the existing `stepSize` parameter meaningfully — it controls resolution along NFP edges rather than grid spacing.
## Files Changed
| File | Change |
|------|--------|
| `OpenNest.Engine/BestFit/NfpSlideStrategy.cs` | New — `IBestFitStrategy` implementation |
| `OpenNest.Engine/BestFit/PolygonHelper.cs` | New — shared polygon extraction/rotation |
| `OpenNest.Engine/Nfp/AutoNester.cs` | Delegate to `PolygonHelper` methods |
| `OpenNest.Engine/BestFit/BestFitFinder.cs` | Swap `RotationSlideStrategy` for `NfpSlideStrategy` in `BuildStrategies` |
## What This Does NOT Change
- The `RotationSlideStrategy` class stays in the codebase (not deleted) in case GPU slide computation is still wanted
- The `ISlideComputer` / GPU pipeline remains available
- `BestFitFinder` constructor still accepts `ISlideComputer` but it won't be passed to NFP strategies (they don't need it)