diff --git a/OpenNest.Core/Geometry/NoFitPolygon.cs b/OpenNest.Core/Geometry/NoFitPolygon.cs
index ad680a3..7d88c87 100644
--- a/OpenNest.Core/Geometry/NoFitPolygon.cs
+++ b/OpenNest.Core/Geometry/NoFitPolygon.cs
@@ -1,4 +1,5 @@
using Clipper2Lib;
+using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Geometry
@@ -22,8 +23,20 @@ namespace OpenNest.Geometry
return MinkowskiSum(stationary, reflected);
}
+ ///
+ /// Optimized version of Compute for polygons known to be convex.
+ /// Bypasses expensive triangulation and Clipper unions.
+ ///
+ public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting)
+ {
+ var reflected = Reflect(orbiting);
+ return ConvexMinkowskiSum(stationary, reflected);
+ }
+
///
/// 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.
///
private static Polygon Reflect(Polygon polygon)
{
@@ -32,8 +45,6 @@ namespace OpenNest.Geometry
foreach (var v in polygon.Vertices)
result.Vertices.Add(new Vector(-v.X, -v.Y));
- // Reflecting reverses winding order — reverse to maintain CCW.
- result.Vertices.Reverse();
return result;
}
@@ -83,14 +94,19 @@ namespace OpenNest.Geometry
var edgesA = GetEdgeVectors(a);
var edgesB = GetEdgeVectors(b);
- // Find bottom-most (then left-most) vertex for each polygon as starting point.
+ // Find indices of bottom-left vertices for both.
var startA = FindBottomLeft(a);
var startB = FindBottomLeft(b);
var result = new Polygon();
+
+ // The starting point of the Minkowski sum A + B is the sum of the
+ // starting points of A and B. For NFP = A + (-B), this is
+ // startA + startReflectedB.
var current = new Vector(
a.Vertices[startA].X + b.Vertices[startB].X,
a.Vertices[startA].Y + b.Vertices[startB].Y);
+
result.Vertices.Add(current);
var ia = 0;
@@ -98,7 +114,6 @@ namespace OpenNest.Geometry
var na = edgesA.Count;
var nb = edgesB.Count;
- // Reorder edges to start from the bottom-left vertex.
var orderedA = ReorderEdges(edgesA, startA);
var orderedB = ReorderEdges(edgesB, startB);
@@ -117,7 +132,10 @@ namespace OpenNest.Geometry
else
{
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
+ if (angleA < 0) angleA += Angle.TwoPI;
+
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
+ if (angleB < 0) angleB += Angle.TwoPI;
if (angleA < angleB)
{
@@ -129,7 +147,6 @@ namespace OpenNest.Geometry
}
else
{
- // Same angle — merge both edges.
edge = new Vector(
orderedA[ia].X + orderedB[ib].X,
orderedA[ia].Y + orderedB[ib].Y);
@@ -143,6 +160,7 @@ namespace OpenNest.Geometry
}
result.Close();
+ result.UpdateBounds();
return result;
}
diff --git a/OpenNest.Engine/BestFit/BestFitFinder.cs b/OpenNest.Engine/BestFit/BestFitFinder.cs
index 95861b5..be5a26d 100644
--- a/OpenNest.Engine/BestFit/BestFitFinder.cs
+++ b/OpenNest.Engine/BestFit/BestFitFinder.cs
@@ -36,7 +36,7 @@ namespace OpenNest.Engine.BestFit
double stepSize = 0.25,
BestFitSortField sortBy = BestFitSortField.Area)
{
- var strategies = BuildStrategies(drawing);
+ var strategies = BuildStrategies(drawing, spacing);
var candidateBags = new ConcurrentBag>();
@@ -75,7 +75,7 @@ namespace OpenNest.Engine.BestFit
.ToList();
}
- private List BuildStrategies(Drawing drawing)
+ private List BuildStrategies(Drawing drawing, double spacing)
{
var angles = GetRotationAngles(drawing);
var strategies = new List();
diff --git a/OpenNest.Engine/BestFit/NfpSlideStrategy.cs b/OpenNest.Engine/BestFit/NfpSlideStrategy.cs
index 9211f22..70f18e4 100644
--- a/OpenNest.Engine/BestFit/NfpSlideStrategy.cs
+++ b/OpenNest.Engine/BestFit/NfpSlideStrategy.cs
@@ -1,22 +1,61 @@
using OpenNest.Geometry;
+using OpenNest.Math;
using System.Collections.Generic;
+using System.IO;
namespace OpenNest.Engine.BestFit
{
public class NfpSlideStrategy : IBestFitStrategy
{
- private readonly double _part2Rotation;
+ private static readonly string LogPath = Path.Combine(
+ System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
+ "nfp-slide-debug.log");
- public NfpSlideStrategy(double part2Rotation, int type, string description)
+ 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;
Type = type;
Description = description;
+ _stationaryPerimeter = stationaryPerimeter;
+ _stationaryHull = stationaryHull;
+ _correction = correction;
}
public int Type { get; }
public string Description { get; }
+ ///
+ /// Creates an NfpSlideStrategy by extracting polygon data from a drawing.
+ /// Returns null if the drawing has no valid perimeter.
+ ///
+ 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 GenerateCandidates(Drawing drawing, double spacing, double stepSize)
{
var candidates = new List();
@@ -24,42 +63,45 @@ namespace OpenNest.Engine.BestFit
if (stepSize <= 0)
return candidates;
- var halfSpacing = spacing / 2;
+ Log($"--- GenerateCandidates: drawing={drawing.Name}, part2Rot={Angle.ToDegrees(_part2Rotation):F1}deg, spacing={spacing}, stepSize={stepSize} ---");
- // Extract stationary polygon (Part1 at rotation 0), with spacing applied.
- var stationaryResult = PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing);
+ // Orbiting polygon: same shape rotated to Part2's angle.
+ var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
+ var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
- if (stationaryResult.Polygon == null)
- return candidates;
+ 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)}");
- // Use convex hulls for NFP — avoids expensive triangulation of
- // concave parts. Hull inputs produce few triangles, so the
- // Minkowski sum + Clipper union inside Compute stays fast.
- var stationaryPoly = ConvexHull.Compute(stationaryResult.Polygon.Vertices);
-
- // Orbiting polygon: same shape rotated to Part2's angle, then hulled.
- var rotated = PolygonHelper.RotatePolygon(stationaryResult.Polygon, _part2Rotation);
- var orbitingPoly = ConvexHull.Compute(rotated.Vertices);
-
- // Let Compute handle reflection, winding, and Minkowski sum correctly.
- var nfp = NoFitPolygon.Compute(stationaryPoly, 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;
+ }
- // 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;
+
+ 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++)
{
- // Add vertex candidate.
- var offset = ApplyCorrection(verts[i], correction);
+ var offset = ApplyCorrection(verts[i], _correction);
candidates.Add(MakeCandidate(drawing, offset, spacing, testNumber++));
// Add edge samples for long edges.
@@ -77,18 +119,32 @@ namespace OpenNest.Engine.BestFit
var sample = new Vector(
verts[i].X + dx * t,
verts[i].Y + dy * t);
- var sampleOffset = ApplyCorrection(sample, correction);
+ 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);
+ return new Vector(nfpVertex.X - correction.X, nfpVertex.Y - correction.Y);
}
private PairCandidate MakeCandidate(Drawing drawing, Vector offset, double spacing, int testNumber)
@@ -104,5 +160,20 @@ namespace OpenNest.Engine.BestFit
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");
+ }
+ }
}
}
diff --git a/OpenNest.Engine/BestFit/PolygonHelper.cs b/OpenNest.Engine/BestFit/PolygonHelper.cs
index 7a49d6a..d754449 100644
--- a/OpenNest.Engine/BestFit/PolygonHelper.cs
+++ b/OpenNest.Engine/BestFit/PolygonHelper.cs
@@ -22,13 +22,10 @@ namespace OpenNest.Engine.BestFit
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.
+ // OffsetSide.Right = outward for CCW perimeters (standard for outer contours).
var inflated = halfSpacing > 0
- ? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Left) as Shape ?? perimeter)
+ ? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Right) as Shape ?? perimeter)
: perimeter;
// Convert to polygon with circumscribed arcs for tight nesting.
@@ -37,22 +34,18 @@ namespace OpenNest.Engine.BestFit
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.
+ // Normalize: move polygon to origin.
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
polygon.Offset(-bb.Left, -bb.Bottom);
- return new PolygonExtractionResult(polygon, correction);
+ // 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)
+ public static Polygon RotatePolygon(Polygon polygon, double angle, bool reNormalize = true)
{
if (angle.IsEqualTo(0))
return polygon;
@@ -68,10 +61,13 @@ namespace OpenNest.Engine.BestFit
v.X * sin + v.Y * cos));
}
- // Re-normalize to origin.
- result.UpdateBounds();
- var bb = result.BoundingBox;
- result.Offset(-bb.Left, -bb.Bottom);
+ if (reNormalize)
+ {
+ // Re-normalize to origin.
+ result.UpdateBounds();
+ var bb = result.BoundingBox;
+ result.Offset(-bb.Left, -bb.Bottom);
+ }
return result;
}
diff --git a/OpenNest.Tests/NfpSlideStrategyTests.cs b/OpenNest.Tests/NfpSlideStrategyTests.cs
index ab9ec4b..6412f9a 100644
--- a/OpenNest.Tests/NfpSlideStrategyTests.cs
+++ b/OpenNest.Tests/NfpSlideStrategyTests.cs
@@ -1,4 +1,5 @@
using OpenNest.CNC;
+using OpenNest.Converters;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -10,8 +11,9 @@ public class NfpSlideStrategyTests
[Fact]
public void GenerateCandidates_ReturnsNonEmpty_ForSquare()
{
- var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
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);
}
@@ -19,8 +21,9 @@ public class NfpSlideStrategyTests
[Fact]
public void GenerateCandidates_AllCandidatesHaveCorrectDrawing()
{
- var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
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));
}
@@ -28,8 +31,9 @@ public class NfpSlideStrategyTests
[Fact]
public void GenerateCandidates_Part1RotationIsAlwaysZero()
{
- var strategy = new NfpSlideStrategy(Angle.HalfPI, 1, "90 deg NFP");
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));
}
@@ -38,8 +42,9 @@ public class NfpSlideStrategyTests
public void GenerateCandidates_Part2RotationMatchesStrategy()
{
var rotation = Angle.HalfPI;
- var strategy = new NfpSlideStrategy(rotation, 1, "90 deg NFP");
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));
}
@@ -47,8 +52,9 @@ public class NfpSlideStrategyTests
[Fact]
public void GenerateCandidates_ProducesReasonableCandidateCount()
{
- var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
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.
@@ -60,39 +66,59 @@ public class NfpSlideStrategyTests
[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);
+ 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 GenerateCandidates_ReturnsEmpty_ForEmptyDrawing()
+ public void Create_ReturnsNull_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);
+ var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
+ Assert.Null(strategy);
}
[Fact]
public void GenerateCandidates_LShape_ProducesCandidates()
{
- var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
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_ProducesCandidates()
+ public void GenerateCandidates_At180Degrees_ProducesAtLeastOneNonOverlappingCandidate()
{
- var strategy = new NfpSlideStrategy(System.Math.PI, 1, "180 deg NFP");
var drawing = TestHelpers.MakeSquareDrawing();
- var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
+ 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);
- Assert.All(candidates, c => Assert.Equal(System.Math.PI, c.Part2Rotation));
+
+ 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}");
}
}