feat: add NfpSlideStrategy for NFP-based best-fit candidate generation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 20:03:52 -04:00
parent 707ddb80d9
commit 5f4288a786
2 changed files with 203 additions and 0 deletions

View File

@@ -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<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;
// 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
};
}
}
}

View File

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