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}"); } }