From 5f4288a7867571d2ef42b204dccbd4ac26fcadfc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 20 Mar 2026 20:03:52 -0400 Subject: [PATCH] feat: add NfpSlideStrategy for NFP-based best-fit candidate generation Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/BestFit/NfpSlideStrategy.cs | 100 +++++++++++++++++++ OpenNest.Tests/NfpSlideStrategyTests.cs | 103 ++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 OpenNest.Engine/BestFit/NfpSlideStrategy.cs create mode 100644 OpenNest.Tests/NfpSlideStrategyTests.cs diff --git a/OpenNest.Engine/BestFit/NfpSlideStrategy.cs b/OpenNest.Engine/BestFit/NfpSlideStrategy.cs new file mode 100644 index 0000000..e5426f9 --- /dev/null +++ b/OpenNest.Engine/BestFit/NfpSlideStrategy.cs @@ -0,0 +1,100 @@ +using OpenNest.Geometry; +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 GenerateCandidates(Drawing drawing, double spacing, double stepSize) + { + var candidates = new List(); + 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; + + // Orbiting polygon: same shape rotated to Part2's angle. + 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 + }; + } + } +} diff --git a/OpenNest.Tests/NfpSlideStrategyTests.cs b/OpenNest.Tests/NfpSlideStrategyTests.cs new file mode 100644 index 0000000..2037043 --- /dev/null +++ b/OpenNest.Tests/NfpSlideStrategyTests.cs @@ -0,0 +1,103 @@ +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); + 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); + + 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)); + } +}