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 Clipper2Lib;
|
||||||
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
@@ -22,8 +23,20 @@ namespace OpenNest.Geometry
|
|||||||
return MinkowskiSum(stationary, reflected);
|
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>
|
/// <summary>
|
||||||
/// Reflects a polygon through the origin (negates all vertex coordinates).
|
/// 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>
|
/// </summary>
|
||||||
private static Polygon Reflect(Polygon polygon)
|
private static Polygon Reflect(Polygon polygon)
|
||||||
{
|
{
|
||||||
@@ -32,8 +45,6 @@ namespace OpenNest.Geometry
|
|||||||
foreach (var v in polygon.Vertices)
|
foreach (var v in polygon.Vertices)
|
||||||
result.Vertices.Add(new Vector(-v.X, -v.Y));
|
result.Vertices.Add(new Vector(-v.X, -v.Y));
|
||||||
|
|
||||||
// Reflecting reverses winding order — reverse to maintain CCW.
|
|
||||||
result.Vertices.Reverse();
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,19 +89,24 @@ namespace OpenNest.Geometry
|
|||||||
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
|
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
|
||||||
/// Both polygons must have CCW winding.
|
/// Both polygons must have CCW winding.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
|
public static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
|
||||||
{
|
{
|
||||||
var edgesA = GetEdgeVectors(a);
|
var edgesA = GetEdgeVectors(a);
|
||||||
var edgesB = GetEdgeVectors(b);
|
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 startA = FindBottomLeft(a);
|
||||||
var startB = FindBottomLeft(b);
|
var startB = FindBottomLeft(b);
|
||||||
|
|
||||||
var result = new Polygon();
|
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(
|
var current = new Vector(
|
||||||
a.Vertices[startA].X + b.Vertices[startB].X,
|
a.Vertices[startA].X + b.Vertices[startB].X,
|
||||||
a.Vertices[startA].Y + b.Vertices[startB].Y);
|
a.Vertices[startA].Y + b.Vertices[startB].Y);
|
||||||
|
|
||||||
result.Vertices.Add(current);
|
result.Vertices.Add(current);
|
||||||
|
|
||||||
var ia = 0;
|
var ia = 0;
|
||||||
@@ -98,7 +114,6 @@ namespace OpenNest.Geometry
|
|||||||
var na = edgesA.Count;
|
var na = edgesA.Count;
|
||||||
var nb = edgesB.Count;
|
var nb = edgesB.Count;
|
||||||
|
|
||||||
// Reorder edges to start from the bottom-left vertex.
|
|
||||||
var orderedA = ReorderEdges(edgesA, startA);
|
var orderedA = ReorderEdges(edgesA, startA);
|
||||||
var orderedB = ReorderEdges(edgesB, startB);
|
var orderedB = ReorderEdges(edgesB, startB);
|
||||||
|
|
||||||
@@ -117,7 +132,10 @@ namespace OpenNest.Geometry
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
|
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);
|
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
|
||||||
|
if (angleB < 0) angleB += Angle.TwoPI;
|
||||||
|
|
||||||
if (angleA < angleB)
|
if (angleA < angleB)
|
||||||
{
|
{
|
||||||
@@ -129,7 +147,6 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Same angle — merge both edges.
|
|
||||||
edge = new Vector(
|
edge = new Vector(
|
||||||
orderedA[ia].X + orderedB[ib].X,
|
orderedA[ia].X + orderedB[ib].X,
|
||||||
orderedA[ia].Y + orderedB[ib].Y);
|
orderedA[ia].Y + orderedB[ib].Y);
|
||||||
@@ -143,6 +160,7 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.Close();
|
result.Close();
|
||||||
|
result.UpdateBounds();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ namespace OpenNest.Geometry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[System.Runtime.CompilerServices.MethodImpl(
|
[System.Runtime.CompilerServices.MethodImpl(
|
||||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||||
private static double RayEdgeDistance(
|
public static double RayEdgeDistance(
|
||||||
double vx, double vy,
|
double vx, double vy,
|
||||||
double p1x, double p1y, double p2x, double p2y,
|
double p1x, double p1y, double p2x, double p2y,
|
||||||
double dirX, double dirY)
|
double dirX, double dirY)
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ namespace OpenNest.Engine.BestFit
|
|||||||
public class BestFitFinder
|
public class BestFitFinder
|
||||||
{
|
{
|
||||||
private readonly IPairEvaluator _evaluator;
|
private readonly IPairEvaluator _evaluator;
|
||||||
private readonly ISlideComputer _slideComputer;
|
private readonly IDistanceComputer _distanceComputer;
|
||||||
private readonly BestFitFilter _filter;
|
private readonly BestFitFilter _filter;
|
||||||
|
|
||||||
public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
|
public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
|
||||||
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
|
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
|
||||||
{
|
{
|
||||||
_evaluator = evaluator ?? new PairEvaluator();
|
_evaluator = evaluator ?? new PairEvaluator();
|
||||||
_slideComputer = slideComputer;
|
_distanceComputer = slideComputer != null
|
||||||
|
? (IDistanceComputer)new GpuDistanceComputer(slideComputer)
|
||||||
|
: new CpuDistanceComputer();
|
||||||
var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) /
|
var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) /
|
||||||
System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
|
System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
|
||||||
_filter = new BestFitFilter
|
_filter = new BestFitFilter
|
||||||
@@ -36,7 +38,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
double stepSize = 0.25,
|
double stepSize = 0.25,
|
||||||
BestFitSortField sortBy = BestFitSortField.Area)
|
BestFitSortField sortBy = BestFitSortField.Area)
|
||||||
{
|
{
|
||||||
var strategies = BuildStrategies(drawing);
|
var strategies = BuildStrategies(drawing, spacing);
|
||||||
|
|
||||||
var candidateBags = new ConcurrentBag<List<PairCandidate>>();
|
var candidateBags = new ConcurrentBag<List<PairCandidate>>();
|
||||||
|
|
||||||
@@ -75,16 +77,16 @@ namespace OpenNest.Engine.BestFit
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
|
private List<IBestFitStrategy> BuildStrategies(Drawing drawing, double spacing)
|
||||||
{
|
{
|
||||||
var angles = GetRotationAngles(drawing);
|
var angles = GetRotationAngles(drawing);
|
||||||
var strategies = new List<IBestFitStrategy>();
|
var strategies = new List<IBestFitStrategy>();
|
||||||
var type = 1;
|
var index = 1;
|
||||||
|
|
||||||
foreach (var angle in angles)
|
foreach (var angle in angles)
|
||||||
{
|
{
|
||||||
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
|
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;
|
return strategies;
|
||||||
@@ -226,7 +228,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
case BestFitSortField.ShortestSide:
|
case BestFitSortField.ShortestSide:
|
||||||
return results.OrderBy(r => r.ShortestSide).ToList();
|
return results.OrderBy(r => r.ShortestSide).ToList();
|
||||||
case BestFitSortField.Type:
|
case BestFitSortField.Type:
|
||||||
return results.OrderBy(r => r.Candidate.StrategyType)
|
return results.OrderBy(r => r.Candidate.StrategyIndex)
|
||||||
.ThenBy(r => r.Candidate.TestNumber).ToList();
|
.ThenBy(r => r.Candidate.TestNumber).ToList();
|
||||||
case BestFitSortField.OriginalSequence:
|
case BestFitSortField.OriginalSequence:
|
||||||
return results.OrderBy(r => r.Candidate.TestNumber).ToList();
|
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
|
public interface IBestFitStrategy
|
||||||
{
|
{
|
||||||
int Type { get; }
|
int StrategyIndex { get; }
|
||||||
string Description { get; }
|
string Description { get; }
|
||||||
List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize);
|
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 Part1Rotation { get; set; }
|
||||||
public double Part2Rotation { get; set; }
|
public double Part2Rotation { get; set; }
|
||||||
public Vector Part2Offset { get; set; }
|
public Vector Part2Offset { get; set; }
|
||||||
public int StrategyType { get; set; }
|
public int StrategyIndex { get; set; }
|
||||||
public int TestNumber { get; set; }
|
public int TestNumber { get; set; }
|
||||||
public double Spacing { 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 OpenNest.Geometry;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace OpenNest.Engine.BestFit
|
namespace OpenNest.Engine.BestFit
|
||||||
{
|
{
|
||||||
public class RotationSlideStrategy : IBestFitStrategy
|
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,
|
public RotationSlideStrategy(double part2Rotation, int strategyIndex, string description,
|
||||||
ISlideComputer slideComputer = null)
|
IDistanceComputer distanceComputer)
|
||||||
{
|
{
|
||||||
Part2Rotation = part2Rotation;
|
Part2Rotation = part2Rotation;
|
||||||
Type = type;
|
StrategyIndex = strategyIndex;
|
||||||
Description = description;
|
Description = description;
|
||||||
_slideComputer = slideComputer;
|
_distanceComputer = distanceComputer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public double Part2Rotation { get; }
|
public double Part2Rotation { get; }
|
||||||
public int Type { get; }
|
public int StrategyIndex { get; }
|
||||||
public string Description { get; }
|
public string Description { get; }
|
||||||
|
|
||||||
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
|
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
|
||||||
@@ -40,36 +42,25 @@ namespace OpenNest.Engine.BestFit
|
|||||||
var bbox1 = part1.BoundingBox;
|
var bbox1 = part1.BoundingBox;
|
||||||
var bbox2 = part2Template.BoundingBox;
|
var bbox2 = part2Template.BoundingBox;
|
||||||
|
|
||||||
// Collect offsets and directions across all 4 axes
|
var offsets = BuildOffsets(bbox1, bbox2, spacing, stepSize);
|
||||||
var allDx = new List<double>();
|
|
||||||
var allDy = new List<double>();
|
|
||||||
var allDirs = new List<PushDirection>();
|
|
||||||
|
|
||||||
foreach (var pushDir in AllDirections)
|
if (offsets.Length == 0)
|
||||||
BuildOffsets(bbox1, bbox2, spacing, stepSize, pushDir, allDx, allDy, allDirs);
|
|
||||||
|
|
||||||
if (allDx.Count == 0)
|
|
||||||
return candidates;
|
return candidates;
|
||||||
|
|
||||||
// Compute all distances — single GPU dispatch or CPU loop
|
var distances = _distanceComputer.ComputeDistances(
|
||||||
var distances = ComputeAllDistances(
|
part1Lines, part2TemplateLines, offsets);
|
||||||
part1Lines, part2TemplateLines, allDx, allDy, allDirs);
|
|
||||||
|
|
||||||
// Create candidates from valid results
|
|
||||||
var testNumber = 0;
|
var testNumber = 0;
|
||||||
|
|
||||||
for (var i = 0; i < allDx.Count; i++)
|
for (var i = 0; i < offsets.Length; i++)
|
||||||
{
|
{
|
||||||
var slideDist = distances[i];
|
var slideDist = distances[i];
|
||||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var dx = allDx[i];
|
|
||||||
var dy = allDy[i];
|
|
||||||
var pushVector = GetPushVector(allDirs[i], slideDist);
|
|
||||||
var finalPosition = new Vector(
|
var finalPosition = new Vector(
|
||||||
part2Template.Location.X + dx + pushVector.X,
|
part2Template.Location.X + offsets[i].Dx + offsets[i].DirX * slideDist,
|
||||||
part2Template.Location.Y + dy + pushVector.Y);
|
part2Template.Location.Y + offsets[i].Dy + offsets[i].DirY * slideDist);
|
||||||
|
|
||||||
candidates.Add(new PairCandidate
|
candidates.Add(new PairCandidate
|
||||||
{
|
{
|
||||||
@@ -77,7 +68,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
Part1Rotation = 0,
|
Part1Rotation = 0,
|
||||||
Part2Rotation = Part2Rotation,
|
Part2Rotation = Part2Rotation,
|
||||||
Part2Offset = finalPosition,
|
Part2Offset = finalPosition,
|
||||||
StrategyType = Type,
|
StrategyIndex = StrategyIndex,
|
||||||
TestNumber = testNumber++,
|
TestNumber = testNumber++,
|
||||||
Spacing = spacing
|
Spacing = spacing
|
||||||
});
|
});
|
||||||
@@ -86,158 +77,44 @@ namespace OpenNest.Engine.BestFit
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void BuildOffsets(
|
private static SlideOffset[] BuildOffsets(Box bbox1, Box bbox2, double spacing, double stepSize)
|
||||||
Box bbox1, Box bbox2, double spacing, double stepSize,
|
|
||||||
PushDirection pushDir, List<double> allDx, List<double> allDy,
|
|
||||||
List<PushDirection> allDirs)
|
|
||||||
{
|
{
|
||||||
var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right;
|
var offsets = new List<SlideOffset>();
|
||||||
|
|
||||||
double perpMin, perpMax, pushStartOffset;
|
foreach (var (dirX, dirY) in PushDirections)
|
||||||
|
|
||||||
if (isHorizontalPush)
|
|
||||||
{
|
{
|
||||||
perpMin = -(bbox2.Length + spacing);
|
var isHorizontalPush = System.Math.Abs(dirX) > System.Math.Abs(dirY);
|
||||||
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 alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
double perpMin, perpMax, pushStartOffset;
|
||||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
|
||||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
|
||||||
|
|
||||||
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
|
if (isHorizontalPush)
|
||||||
{
|
|
||||||
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++)
|
|
||||||
{
|
{
|
||||||
offsets[i * 2] = allDx[i];
|
perpMin = -(bbox2.Length + spacing);
|
||||||
offsets[i * 2 + 1] = allDy[i];
|
perpMax = bbox1.Length + bbox2.Length + spacing;
|
||||||
directions[i] = (int)allDirs[i];
|
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
|
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);
|
perpMin = -(bbox2.Width + spacing);
|
||||||
if (d < minDist) minDist = d;
|
perpMax = bbox1.Width + bbox2.Width + spacing;
|
||||||
|
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 2: Stationary vertices -> Moving edges (translated)
|
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
||||||
foreach (var sv in stationaryVerticesArray)
|
|
||||||
|
// 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);
|
var dx = isHorizontalPush ? startPos : offset;
|
||||||
if (d < minDist) minDist = d;
|
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.Fill;
|
||||||
using OpenNest.Engine.Nfp;
|
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@@ -130,9 +129,6 @@ namespace OpenNest
|
|||||||
// Compact placed parts toward the origin to close gaps.
|
// Compact placed parts toward the origin to close gaps.
|
||||||
Compactor.Settle(allParts, Plate.WorkArea(), Plate.PartSpacing);
|
Compactor.Settle(allParts, Plate.WorkArea(), Plate.PartSpacing);
|
||||||
|
|
||||||
// NFP optimization pass — re-place parts using geometry-aware BLF.
|
|
||||||
allParts = AutoNester.Optimize(allParts, Plate);
|
|
||||||
|
|
||||||
return allParts;
|
return allParts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using OpenNest.Converters;
|
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System;
|
using System;
|
||||||
@@ -203,44 +202,7 @@ namespace OpenNest.Engine.Nfp
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
|
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
return BestFit.PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing).Polygon;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -320,26 +282,7 @@ namespace OpenNest.Engine.Nfp
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static Polygon RotatePolygon(Polygon polygon, double angle)
|
private static Polygon RotatePolygon(Polygon polygon, double angle)
|
||||||
{
|
{
|
||||||
if (angle.IsEqualTo(0))
|
return BestFit.PolygonHelper.RotatePolygon(polygon, angle);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,9 +49,6 @@ namespace OpenNest
|
|||||||
// Compact placed parts toward the origin to close gaps.
|
// Compact placed parts toward the origin to close gaps.
|
||||||
Compactor.Settle(parts, Plate.WorkArea(), Plate.PartSpacing);
|
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.
|
// Deduct placed quantities from original items.
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Engine.Nfp;
|
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
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.
|
// Deduct placed quantities from original items.
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ namespace OpenNest.IO
|
|||||||
Part1Rotation = r.Part1Rotation,
|
Part1Rotation = r.Part1Rotation,
|
||||||
Part2Rotation = r.Part2Rotation,
|
Part2Rotation = r.Part2Rotation,
|
||||||
Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY),
|
Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY),
|
||||||
StrategyType = r.StrategyType,
|
StrategyIndex = r.StrategyType,
|
||||||
TestNumber = r.TestNumber,
|
TestNumber = r.TestNumber,
|
||||||
Spacing = r.CandidateSpacing
|
Spacing = r.CandidateSpacing
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ namespace OpenNest.IO
|
|||||||
Part2Rotation = r.Candidate.Part2Rotation,
|
Part2Rotation = r.Candidate.Part2Rotation,
|
||||||
Part2OffsetX = r.Candidate.Part2Offset.X,
|
Part2OffsetX = r.Candidate.Part2Offset.X,
|
||||||
Part2OffsetY = r.Candidate.Part2Offset.Y,
|
Part2OffsetY = r.Candidate.Part2Offset.Y,
|
||||||
StrategyType = r.Candidate.StrategyType,
|
StrategyType = r.Candidate.StrategyIndex,
|
||||||
TestNumber = r.Candidate.TestNumber,
|
TestNumber = r.Candidate.TestNumber,
|
||||||
CandidateSpacing = r.Candidate.Spacing,
|
CandidateSpacing = r.Candidate.Spacing,
|
||||||
RotatedArea = r.RotatedArea,
|
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);
|
plate.Parts.Add(p);
|
||||||
return plate;
|
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[]
|
metadataLines = new[]
|
||||||
{
|
{
|
||||||
string.Format("#{0} {1:F1}x{2:F1} Area={3:F1}",
|
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",
|
string.Format("Util={0:P1} Rot={1:F1}\u00b0",
|
||||||
result.Utilization,
|
result.Utilization,
|
||||||
Angle.ToDegrees(result.OptimalRotation)),
|
Angle.ToDegrees(result.OptimalRotation)),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Collections;
|
using OpenNest.Collections;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
using OpenNest.Engine.Nfp;
|
|
||||||
using OpenNest.Forms;
|
using OpenNest.Forms;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
@@ -961,7 +960,7 @@ namespace OpenNest.Controls
|
|||||||
{
|
{
|
||||||
var result = engine.Fill(groupParts, workArea, progress, cts.Token);
|
var result = engine.Fill(groupParts, workArea, progress, cts.Token);
|
||||||
Compactor.Settle(result, workArea, spacing);
|
Compactor.Settle(result, workArea, spacing);
|
||||||
return AutoNester.Optimize(result, workArea, spacing);
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
|
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
|
||||||
|
|||||||
@@ -291,8 +291,8 @@ namespace OpenNest.Forms
|
|||||||
cell.PartColor = partColor;
|
cell.PartColor = partColor;
|
||||||
cell.Dock = DockStyle.Fill;
|
cell.Dock = DockStyle.Fill;
|
||||||
cell.Plate.Size = new Geometry.Size(
|
cell.Plate.Size = new Geometry.Size(
|
||||||
result.BoundingWidth,
|
result.BoundingHeight,
|
||||||
result.BoundingHeight);
|
result.BoundingWidth);
|
||||||
|
|
||||||
var parts = result.BuildParts(drawing);
|
var parts = result.BuildParts(drawing);
|
||||||
|
|
||||||
|
|||||||
Generated
+12
-2
@@ -131,6 +131,7 @@
|
|||||||
plateIndexStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
plateIndexStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||||
plateSizeStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
plateSizeStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||||
plateQtyStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
plateQtyStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||||
|
plateUtilStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||||
gpuStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
gpuStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||||
selectionStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
selectionStatusLabel = new System.Windows.Forms.ToolStripStatusLabel();
|
||||||
toolStrip1 = new System.Windows.Forms.ToolStrip();
|
toolStrip1 = new System.Windows.Forms.ToolStrip();
|
||||||
@@ -829,7 +830,7 @@
|
|||||||
//
|
//
|
||||||
// statusStrip1
|
// 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.Location = new System.Drawing.Point(0, 630);
|
||||||
statusStrip1.Name = "statusStrip1";
|
statusStrip1.Name = "statusStrip1";
|
||||||
statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0);
|
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.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
|
||||||
plateQtyStatusLabel.Size = new System.Drawing.Size(55, 19);
|
plateQtyStatusLabel.Size = new System.Drawing.Size(55, 19);
|
||||||
plateQtyStatusLabel.Text = "Qty : 0";
|
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
|
||||||
//
|
//
|
||||||
gpuStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left;
|
gpuStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left;
|
||||||
@@ -1128,6 +1137,7 @@
|
|||||||
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem10;
|
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem10;
|
||||||
private System.Windows.Forms.ToolStripMenuItem mnuCloseAll;
|
private System.Windows.Forms.ToolStripMenuItem mnuCloseAll;
|
||||||
private System.Windows.Forms.ToolStripStatusLabel plateQtyStatusLabel;
|
private System.Windows.Forms.ToolStripStatusLabel plateQtyStatusLabel;
|
||||||
|
private System.Windows.Forms.ToolStripStatusLabel plateUtilStatusLabel;
|
||||||
private System.Windows.Forms.ToolStripMenuItem mnuFileExportAll;
|
private System.Windows.Forms.ToolStripMenuItem mnuFileExportAll;
|
||||||
private System.Windows.Forms.ToolStripMenuItem openNestToolStripMenuItem;
|
private System.Windows.Forms.ToolStripMenuItem openNestToolStripMenuItem;
|
||||||
private System.Windows.Forms.ToolStripMenuItem pEPToolStripMenuItem;
|
private System.Windows.Forms.ToolStripMenuItem pEPToolStripMenuItem;
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ namespace OpenNest.Forms
|
|||||||
plateIndexStatusLabel.Text = string.Empty;
|
plateIndexStatusLabel.Text = string.Empty;
|
||||||
plateSizeStatusLabel.Text = string.Empty;
|
plateSizeStatusLabel.Text = string.Empty;
|
||||||
plateQtyStatusLabel.Text = string.Empty;
|
plateQtyStatusLabel.Text = string.Empty;
|
||||||
|
plateUtilStatusLabel.Text = string.Empty;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +220,10 @@ namespace OpenNest.Forms
|
|||||||
plateQtyStatusLabel.Text = string.Format(
|
plateQtyStatusLabel.Text = string.Format(
|
||||||
"Qty: {0}",
|
"Qty: {0}",
|
||||||
activeForm.PlateView.Plate.Quantity);
|
activeForm.PlateView.Plate.Quantity);
|
||||||
|
|
||||||
|
plateUtilStatusLabel.Text = string.Format(
|
||||||
|
"Util: {0:P1}",
|
||||||
|
activeForm.PlateView.Plate.Utilization());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSelectionStatus()
|
private void UpdateSelectionStatus()
|
||||||
@@ -342,6 +347,8 @@ namespace OpenNest.Forms
|
|||||||
activeForm.PlateView.MouseClick -= PlateView_MouseClick;
|
activeForm.PlateView.MouseClick -= PlateView_MouseClick;
|
||||||
activeForm.PlateView.StatusChanged -= PlateView_StatusChanged;
|
activeForm.PlateView.StatusChanged -= PlateView_StatusChanged;
|
||||||
activeForm.PlateView.SelectionChanged -= PlateView_SelectionChanged;
|
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
|
// If nesting is in progress and the active form changed, cancel nesting
|
||||||
@@ -367,6 +374,8 @@ namespace OpenNest.Forms
|
|||||||
UpdateSelectionStatus();
|
UpdateSelectionStatus();
|
||||||
activeForm.PlateView.StatusChanged += PlateView_StatusChanged;
|
activeForm.PlateView.StatusChanged += PlateView_StatusChanged;
|
||||||
activeForm.PlateView.SelectionChanged += PlateView_SelectionChanged;
|
activeForm.PlateView.SelectionChanged += PlateView_SelectionChanged;
|
||||||
|
activeForm.PlateView.PartAdded += PlateView_PartAdded;
|
||||||
|
activeForm.PlateView.PartRemoved += PlateView_PartRemoved;
|
||||||
mnuViewDrawRapids.Checked = activeForm.PlateView.DrawRapid;
|
mnuViewDrawRapids.Checked = activeForm.PlateView.DrawRapid;
|
||||||
mnuViewDrawBounds.Checked = activeForm.PlateView.DrawBounds;
|
mnuViewDrawBounds.Checked = activeForm.PlateView.DrawBounds;
|
||||||
statusLabel1.Text = activeForm.PlateView.Status;
|
statusLabel1.Text = activeForm.PlateView.Status;
|
||||||
@@ -1215,6 +1224,9 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
#region PlateView Events
|
#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)
|
private void PlateView_MouseMove(object sender, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
UpdateLocationStatus();
|
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