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:
@@ -1,4 +1,5 @@
|
|||||||
using Clipper2Lib;
|
using Clipper2Lib;
|
||||||
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
@@ -22,8 +23,20 @@ namespace OpenNest.Geometry
|
|||||||
return MinkowskiSum(stationary, reflected);
|
return MinkowskiSum(stationary, reflected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optimized version of Compute for polygons known to be convex.
|
||||||
|
/// Bypasses expensive triangulation and Clipper unions.
|
||||||
|
/// </summary>
|
||||||
|
public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting)
|
||||||
|
{
|
||||||
|
var reflected = Reflect(orbiting);
|
||||||
|
return ConvexMinkowskiSum(stationary, reflected);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reflects a polygon through the origin (negates all vertex coordinates).
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static Polygon Reflect(Polygon polygon)
|
private static Polygon Reflect(Polygon polygon)
|
||||||
{
|
{
|
||||||
@@ -32,8 +45,6 @@ namespace OpenNest.Geometry
|
|||||||
foreach (var v in polygon.Vertices)
|
foreach (var v in polygon.Vertices)
|
||||||
result.Vertices.Add(new Vector(-v.X, -v.Y));
|
result.Vertices.Add(new Vector(-v.X, -v.Y));
|
||||||
|
|
||||||
// Reflecting reverses winding order — reverse to maintain CCW.
|
|
||||||
result.Vertices.Reverse();
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,14 +94,19 @@ namespace OpenNest.Geometry
|
|||||||
var edgesA = GetEdgeVectors(a);
|
var edgesA = GetEdgeVectors(a);
|
||||||
var edgesB = GetEdgeVectors(b);
|
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 startA = FindBottomLeft(a);
|
||||||
var startB = FindBottomLeft(b);
|
var startB = FindBottomLeft(b);
|
||||||
|
|
||||||
var result = new Polygon();
|
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(
|
var current = new Vector(
|
||||||
a.Vertices[startA].X + b.Vertices[startB].X,
|
a.Vertices[startA].X + b.Vertices[startB].X,
|
||||||
a.Vertices[startA].Y + b.Vertices[startB].Y);
|
a.Vertices[startA].Y + b.Vertices[startB].Y);
|
||||||
|
|
||||||
result.Vertices.Add(current);
|
result.Vertices.Add(current);
|
||||||
|
|
||||||
var ia = 0;
|
var ia = 0;
|
||||||
@@ -98,7 +114,6 @@ namespace OpenNest.Geometry
|
|||||||
var na = edgesA.Count;
|
var na = edgesA.Count;
|
||||||
var nb = edgesB.Count;
|
var nb = edgesB.Count;
|
||||||
|
|
||||||
// Reorder edges to start from the bottom-left vertex.
|
|
||||||
var orderedA = ReorderEdges(edgesA, startA);
|
var orderedA = ReorderEdges(edgesA, startA);
|
||||||
var orderedB = ReorderEdges(edgesB, startB);
|
var orderedB = ReorderEdges(edgesB, startB);
|
||||||
|
|
||||||
@@ -117,7 +132,10 @@ namespace OpenNest.Geometry
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
|
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);
|
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
|
||||||
|
if (angleB < 0) angleB += Angle.TwoPI;
|
||||||
|
|
||||||
if (angleA < angleB)
|
if (angleA < angleB)
|
||||||
{
|
{
|
||||||
@@ -129,7 +147,6 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Same angle — merge both edges.
|
|
||||||
edge = new Vector(
|
edge = new Vector(
|
||||||
orderedA[ia].X + orderedB[ib].X,
|
orderedA[ia].X + orderedB[ib].X,
|
||||||
orderedA[ia].Y + orderedB[ib].Y);
|
orderedA[ia].Y + orderedB[ib].Y);
|
||||||
@@ -143,6 +160,7 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
result.Close();
|
result.Close();
|
||||||
|
result.UpdateBounds();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
double stepSize = 0.25,
|
double stepSize = 0.25,
|
||||||
BestFitSortField sortBy = BestFitSortField.Area)
|
BestFitSortField sortBy = BestFitSortField.Area)
|
||||||
{
|
{
|
||||||
var strategies = BuildStrategies(drawing);
|
var strategies = BuildStrategies(drawing, spacing);
|
||||||
|
|
||||||
var candidateBags = new ConcurrentBag<List<PairCandidate>>();
|
var candidateBags = new ConcurrentBag<List<PairCandidate>>();
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<IBestFitStrategy> BuildStrategies(Drawing drawing)
|
private List<IBestFitStrategy> BuildStrategies(Drawing drawing, double spacing)
|
||||||
{
|
{
|
||||||
var angles = GetRotationAngles(drawing);
|
var angles = GetRotationAngles(drawing);
|
||||||
var strategies = new List<IBestFitStrategy>();
|
var strategies = new List<IBestFitStrategy>();
|
||||||
|
|||||||
@@ -1,22 +1,61 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
namespace OpenNest.Engine.BestFit
|
namespace OpenNest.Engine.BestFit
|
||||||
{
|
{
|
||||||
public class NfpSlideStrategy : IBestFitStrategy
|
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;
|
_part2Rotation = part2Rotation;
|
||||||
Type = type;
|
Type = type;
|
||||||
Description = description;
|
Description = description;
|
||||||
|
_stationaryPerimeter = stationaryPerimeter;
|
||||||
|
_stationaryHull = stationaryHull;
|
||||||
|
_correction = correction;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Type { get; }
|
public int Type { get; }
|
||||||
public string Description { get; }
|
public string Description { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an NfpSlideStrategy by extracting polygon data from a drawing.
|
||||||
|
/// Returns null if the drawing has no valid perimeter.
|
||||||
|
/// </summary>
|
||||||
|
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<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
|
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
|
||||||
{
|
{
|
||||||
var candidates = new List<PairCandidate>();
|
var candidates = new List<PairCandidate>();
|
||||||
@@ -24,42 +63,45 @@ namespace OpenNest.Engine.BestFit
|
|||||||
if (stepSize <= 0)
|
if (stepSize <= 0)
|
||||||
return candidates;
|
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.
|
// Orbiting polygon: same shape rotated to Part2's angle.
|
||||||
var stationaryResult = PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing);
|
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
|
||||||
|
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
|
||||||
|
|
||||||
if (stationaryResult.Polygon == null)
|
Log($" Stationary hull: {_stationaryHull.Vertices.Count} verts, bounds={FormatBounds(_stationaryHull)}");
|
||||||
return candidates;
|
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
|
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
|
||||||
// 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);
|
|
||||||
|
|
||||||
if (nfp == null || nfp.Vertices.Count < 3)
|
if (nfp == null || nfp.Vertices.Count < 3)
|
||||||
|
{
|
||||||
|
Log($" NFP failed or degenerate (verts={nfp?.Vertices.Count ?? 0})");
|
||||||
return candidates;
|
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 verts = nfp.Vertices;
|
||||||
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
|
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;
|
var testNumber = 0;
|
||||||
|
|
||||||
for (var i = 0; i < vertCount; i++)
|
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++));
|
candidates.Add(MakeCandidate(drawing, offset, spacing, testNumber++));
|
||||||
|
|
||||||
// Add edge samples for long edges.
|
// Add edge samples for long edges.
|
||||||
@@ -77,18 +119,32 @@ namespace OpenNest.Engine.BestFit
|
|||||||
var sample = new Vector(
|
var sample = new Vector(
|
||||||
verts[i].X + dx * t,
|
verts[i].X + dx * t,
|
||||||
verts[i].Y + dy * t);
|
verts[i].Y + dy * t);
|
||||||
var sampleOffset = ApplyCorrection(sample, correction);
|
var sampleOffset = ApplyCorrection(sample, _correction);
|
||||||
candidates.Add(MakeCandidate(drawing, sampleOffset, spacing, testNumber++));
|
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;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector ApplyCorrection(Vector nfpVertex, Vector correction)
|
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)
|
private PairCandidate MakeCandidate(Drawing drawing, Vector offset, double spacing, int testNumber)
|
||||||
@@ -104,5 +160,20 @@ namespace OpenNest.Engine.BestFit
|
|||||||
Spacing = spacing
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,10 @@ namespace OpenNest.Engine.BestFit
|
|||||||
if (perimeter == null)
|
if (perimeter == null)
|
||||||
return new PolygonExtractionResult(null, Vector.Zero);
|
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.
|
// Inflate by half-spacing if spacing is non-zero.
|
||||||
|
// OffsetSide.Right = outward for CCW perimeters (standard for outer contours).
|
||||||
var inflated = halfSpacing > 0
|
var inflated = halfSpacing > 0
|
||||||
? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Left) as Shape ?? perimeter)
|
? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Right) as Shape ?? perimeter)
|
||||||
: perimeter;
|
: perimeter;
|
||||||
|
|
||||||
// Convert to polygon with circumscribed arcs for tight nesting.
|
// Convert to polygon with circumscribed arcs for tight nesting.
|
||||||
@@ -37,22 +34,18 @@ namespace OpenNest.Engine.BestFit
|
|||||||
if (polygon.Vertices.Count < 3)
|
if (polygon.Vertices.Count < 3)
|
||||||
return new PolygonExtractionResult(null, Vector.Zero);
|
return new PolygonExtractionResult(null, Vector.Zero);
|
||||||
|
|
||||||
// Compute correction: difference between program origin and perimeter origin.
|
// Normalize: move polygon to 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.
|
|
||||||
polygon.UpdateBounds();
|
polygon.UpdateBounds();
|
||||||
var bb = polygon.BoundingBox;
|
var bb = polygon.BoundingBox;
|
||||||
polygon.Offset(-bb.Left, -bb.Bottom);
|
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))
|
if (angle.IsEqualTo(0))
|
||||||
return polygon;
|
return polygon;
|
||||||
@@ -68,10 +61,13 @@ namespace OpenNest.Engine.BestFit
|
|||||||
v.X * sin + v.Y * cos));
|
v.X * sin + v.Y * cos));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-normalize to origin.
|
if (reNormalize)
|
||||||
result.UpdateBounds();
|
{
|
||||||
var bb = result.BoundingBox;
|
// Re-normalize to origin.
|
||||||
result.Offset(-bb.Left, -bb.Bottom);
|
result.UpdateBounds();
|
||||||
|
var bb = result.BoundingBox;
|
||||||
|
result.Offset(-bb.Left, -bb.Bottom);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
using OpenNest.Engine.BestFit;
|
using OpenNest.Engine.BestFit;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
@@ -10,8 +11,9 @@ public class NfpSlideStrategyTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void GenerateCandidates_ReturnsNonEmpty_ForSquare()
|
public void GenerateCandidates_ReturnsNonEmpty_ForSquare()
|
||||||
{
|
{
|
||||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
|
||||||
var drawing = TestHelpers.MakeSquareDrawing();
|
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);
|
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||||
Assert.NotEmpty(candidates);
|
Assert.NotEmpty(candidates);
|
||||||
}
|
}
|
||||||
@@ -19,8 +21,9 @@ public class NfpSlideStrategyTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void GenerateCandidates_AllCandidatesHaveCorrectDrawing()
|
public void GenerateCandidates_AllCandidatesHaveCorrectDrawing()
|
||||||
{
|
{
|
||||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
|
||||||
var drawing = TestHelpers.MakeSquareDrawing();
|
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);
|
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||||
Assert.All(candidates, c => Assert.Same(drawing, c.Drawing));
|
Assert.All(candidates, c => Assert.Same(drawing, c.Drawing));
|
||||||
}
|
}
|
||||||
@@ -28,8 +31,9 @@ public class NfpSlideStrategyTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void GenerateCandidates_Part1RotationIsAlwaysZero()
|
public void GenerateCandidates_Part1RotationIsAlwaysZero()
|
||||||
{
|
{
|
||||||
var strategy = new NfpSlideStrategy(Angle.HalfPI, 1, "90 deg NFP");
|
|
||||||
var drawing = TestHelpers.MakeSquareDrawing();
|
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);
|
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||||
Assert.All(candidates, c => Assert.Equal(0, c.Part1Rotation));
|
Assert.All(candidates, c => Assert.Equal(0, c.Part1Rotation));
|
||||||
}
|
}
|
||||||
@@ -38,8 +42,9 @@ public class NfpSlideStrategyTests
|
|||||||
public void GenerateCandidates_Part2RotationMatchesStrategy()
|
public void GenerateCandidates_Part2RotationMatchesStrategy()
|
||||||
{
|
{
|
||||||
var rotation = Angle.HalfPI;
|
var rotation = Angle.HalfPI;
|
||||||
var strategy = new NfpSlideStrategy(rotation, 1, "90 deg NFP");
|
|
||||||
var drawing = TestHelpers.MakeSquareDrawing();
|
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);
|
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||||
Assert.All(candidates, c => Assert.Equal(rotation, c.Part2Rotation));
|
Assert.All(candidates, c => Assert.Equal(rotation, c.Part2Rotation));
|
||||||
}
|
}
|
||||||
@@ -47,8 +52,9 @@ public class NfpSlideStrategyTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void GenerateCandidates_ProducesReasonableCandidateCount()
|
public void GenerateCandidates_ProducesReasonableCandidateCount()
|
||||||
{
|
{
|
||||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
|
||||||
var drawing = TestHelpers.MakeSquareDrawing();
|
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);
|
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
||||||
|
|
||||||
// Convex hull NFP for a square produces vertices + edge samples.
|
// Convex hull NFP for a square produces vertices + edge samples.
|
||||||
@@ -60,39 +66,59 @@ public class NfpSlideStrategyTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void GenerateCandidates_MoreCandidates_WithSmallerStepSize()
|
public void GenerateCandidates_MoreCandidates_WithSmallerStepSize()
|
||||||
{
|
{
|
||||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
|
||||||
var drawing = TestHelpers.MakeSquareDrawing();
|
var drawing = TestHelpers.MakeSquareDrawing();
|
||||||
var largeStep = strategy.GenerateCandidates(drawing, 0.25, 5.0);
|
var largeStepStrategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
|
||||||
var smallStep = strategy.GenerateCandidates(drawing, 0.25, 0.5);
|
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);
|
Assert.True(smallStep.Count >= largeStep.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 pgm = new Program();
|
||||||
var drawing = new Drawing("empty", pgm);
|
var drawing = new Drawing("empty", pgm);
|
||||||
var candidates = strategy.GenerateCandidates(drawing, 0.25, 0.25);
|
var strategy = NfpSlideStrategy.Create(drawing, 0, 1, "0 deg NFP", 0.25);
|
||||||
Assert.Empty(candidates);
|
Assert.Null(strategy);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GenerateCandidates_LShape_ProducesCandidates()
|
public void GenerateCandidates_LShape_ProducesCandidates()
|
||||||
{
|
{
|
||||||
var strategy = new NfpSlideStrategy(0, 1, "0 deg NFP");
|
|
||||||
var lshape = TestHelpers.MakeLShapeDrawing();
|
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);
|
var candidates = strategy.GenerateCandidates(lshape, 0.25, 0.25);
|
||||||
Assert.NotEmpty(candidates);
|
Assert.NotEmpty(candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 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.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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user