Compare commits
18 Commits
2d1f2217e5
...
de527cd668
| Author | SHA1 | Date | |
|---|---|---|---|
| de527cd668 | |||
| 9887cb1aa3 | |||
| cdf8e4e40e | |||
| 4f21fb91a1 | |||
| 7f96d632f3 | |||
| 38dcaf16d3 | |||
| 8c57e43221 | |||
| bc78ddc49c | |||
| c88cec2beb | |||
| b7c7cecd75 | |||
| 4d0d8c453b | |||
| 5f4288a786 | |||
| 707ddb80d9 | |||
| 71f28600d1 | |||
| d39b0ae540 | |||
| ee5c77c645 | |||
| 4615bcb40d | |||
| 7843de145b |
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Generated
+12
-2
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user