fix: correct NFP polygon computation and inflation direction

Three bugs fixed in NfpSlideStrategy pipeline:

1. NoFitPolygon.Reflect() incorrectly reversed vertex order. Point
   reflection (negating both axes) is a 180° rotation that preserves
   winding — the Reverse() call was converting CCW to CW, producing
   self-intersecting bowtie NFPs.

2. PolygonHelper inflation used OffsetSide.Left which is inward for
   CCW perimeters. Changed to OffsetSide.Right for outward inflation
   so NFP boundary positions give properly-spaced part placements.

3. Removed incorrect correction vector — same-drawing pairs have
   identical polygon-to-part offsets that cancel out in the NFP
   displacement.

Also refactored NfpSlideStrategy to be immutable (removed mutable
cache fields, single constructor with required data, added Create
factory method). BestFitFinder remains on RotationSlideStrategy
as default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:24:04 -04:00
parent 38dcaf16d3
commit 7f96d632f3
5 changed files with 181 additions and 70 deletions

View File

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