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>
305 lines
9.8 KiB
C#
305 lines
9.8 KiB
C#
using Clipper2Lib;
|
|
using OpenNest.Math;
|
|
using System.Collections.Generic;
|
|
|
|
namespace OpenNest.Geometry
|
|
{
|
|
/// <summary>
|
|
/// Computes the No-Fit Polygon (NFP) between two polygons.
|
|
/// The NFP defines all positions where the orbiting polygon's reference point
|
|
/// would cause overlap with the stationary polygon.
|
|
/// </summary>
|
|
public static class NoFitPolygon
|
|
{
|
|
private const double ClipperScale = 1000.0;
|
|
|
|
/// <summary>
|
|
/// Computes the NFP between a stationary polygon A and an orbiting polygon B.
|
|
/// NFP(A, B) = Minkowski sum of A and -B (B reflected through its reference point).
|
|
/// </summary>
|
|
public static Polygon Compute(Polygon stationary, Polygon orbiting)
|
|
{
|
|
var reflected = Reflect(orbiting);
|
|
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>
|
|
/// 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>
|
|
private static Polygon Reflect(Polygon polygon)
|
|
{
|
|
var result = new Polygon();
|
|
|
|
foreach (var v in polygon.Vertices)
|
|
result.Vertices.Add(new Vector(-v.X, -v.Y));
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the Minkowski sum of two polygons using convex decomposition.
|
|
/// For convex polygons, uses the direct O(n+m) merge-sort of edge vectors.
|
|
/// For concave polygons, decomposes into triangles, computes pairwise
|
|
/// convex Minkowski sums, and unions the results with Clipper2.
|
|
/// </summary>
|
|
private static Polygon MinkowskiSum(Polygon a, Polygon b)
|
|
{
|
|
var trisA = ConvexDecomposition.Triangulate(a);
|
|
var trisB = ConvexDecomposition.Triangulate(b);
|
|
|
|
if (trisA.Count == 0 || trisB.Count == 0)
|
|
return new Polygon();
|
|
|
|
var partialSums = new List<Polygon>();
|
|
|
|
foreach (var ta in trisA)
|
|
{
|
|
foreach (var tb in trisB)
|
|
{
|
|
var sum = ConvexMinkowskiSum(ta, tb);
|
|
|
|
if (sum.Vertices.Count >= 3)
|
|
partialSums.Add(sum);
|
|
}
|
|
}
|
|
|
|
if (partialSums.Count == 0)
|
|
return new Polygon();
|
|
|
|
if (partialSums.Count == 1)
|
|
return partialSums[0];
|
|
|
|
return UnionPolygons(partialSums);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the Minkowski sum of two convex polygons by merging their
|
|
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
|
|
/// Both polygons must have CCW winding.
|
|
/// </summary>
|
|
public static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
|
|
{
|
|
var edgesA = GetEdgeVectors(a);
|
|
var edgesB = GetEdgeVectors(b);
|
|
|
|
// 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;
|
|
var ib = 0;
|
|
var na = edgesA.Count;
|
|
var nb = edgesB.Count;
|
|
|
|
var orderedA = ReorderEdges(edgesA, startA);
|
|
var orderedB = ReorderEdges(edgesB, startB);
|
|
|
|
while (ia < na || ib < nb)
|
|
{
|
|
Vector edge;
|
|
|
|
if (ia >= na)
|
|
{
|
|
edge = orderedB[ib++];
|
|
}
|
|
else if (ib >= nb)
|
|
{
|
|
edge = orderedA[ia++];
|
|
}
|
|
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)
|
|
{
|
|
edge = orderedA[ia++];
|
|
}
|
|
else if (angleB < angleA)
|
|
{
|
|
edge = orderedB[ib++];
|
|
}
|
|
else
|
|
{
|
|
edge = new Vector(
|
|
orderedA[ia].X + orderedB[ib].X,
|
|
orderedA[ia].Y + orderedB[ib].Y);
|
|
ia++;
|
|
ib++;
|
|
}
|
|
}
|
|
|
|
current = new Vector(current.X + edge.X, current.Y + edge.Y);
|
|
result.Vertices.Add(current);
|
|
}
|
|
|
|
result.Close();
|
|
result.UpdateBounds();
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets edge vectors for a polygon (each edge as a direction vector).
|
|
/// Assumes the polygon is closed (last vertex == first vertex) or handles open polygons.
|
|
/// </summary>
|
|
private static List<Vector> GetEdgeVectors(Polygon polygon)
|
|
{
|
|
var verts = polygon.Vertices;
|
|
var n = verts.Count;
|
|
|
|
// If closed, skip last duplicate vertex.
|
|
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
|
|
n--;
|
|
|
|
var edges = new List<Vector>(n);
|
|
|
|
for (var i = 0; i < n; i++)
|
|
{
|
|
var next = (i + 1) % n;
|
|
edges.Add(new Vector(verts[next].X - verts[i].X, verts[next].Y - verts[i].Y));
|
|
}
|
|
|
|
return edges;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the index of the bottom-most (then left-most) vertex.
|
|
/// </summary>
|
|
private static int FindBottomLeft(Polygon polygon)
|
|
{
|
|
var verts = polygon.Vertices;
|
|
var n = verts.Count;
|
|
|
|
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
|
|
n--;
|
|
|
|
var best = 0;
|
|
|
|
for (var i = 1; i < n; i++)
|
|
{
|
|
if (verts[i].Y < verts[best].Y ||
|
|
(verts[i].Y == verts[best].Y && verts[i].X < verts[best].X))
|
|
best = i;
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reorders edge vectors to start from the given vertex index.
|
|
/// </summary>
|
|
private static List<Vector> ReorderEdges(List<Vector> edges, int startIndex)
|
|
{
|
|
var n = edges.Count;
|
|
var result = new List<Vector>(n);
|
|
|
|
for (var i = 0; i < n; i++)
|
|
result.Add(edges[(startIndex + i) % n]);
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unions multiple polygons using Clipper2.
|
|
/// Returns the outer boundary of the union as a single polygon.
|
|
/// </summary>
|
|
internal static Polygon UnionPolygons(List<Polygon> polygons)
|
|
{
|
|
var paths = new PathsD();
|
|
|
|
foreach (var poly in polygons)
|
|
{
|
|
var path = ToClipperPath(poly);
|
|
|
|
if (path.Count >= 3)
|
|
paths.Add(path);
|
|
}
|
|
|
|
if (paths.Count == 0)
|
|
return new Polygon();
|
|
|
|
var result = Clipper.Union(paths, FillRule.NonZero);
|
|
|
|
if (result.Count == 0)
|
|
return new Polygon();
|
|
|
|
// Find the largest polygon (by area) as the outer boundary.
|
|
var largest = result[0];
|
|
var largestArea = System.Math.Abs(Clipper.Area(largest));
|
|
|
|
for (var i = 1; i < result.Count; i++)
|
|
{
|
|
var area = System.Math.Abs(Clipper.Area(result[i]));
|
|
|
|
if (area > largestArea)
|
|
{
|
|
largest = result[i];
|
|
largestArea = area;
|
|
}
|
|
}
|
|
|
|
return FromClipperPath(largest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts an OpenNest Polygon to a Clipper2 PathD, with an optional offset.
|
|
/// </summary>
|
|
public static PathD ToClipperPath(Polygon polygon, Vector offset = default)
|
|
{
|
|
var path = new PathD();
|
|
var verts = polygon.Vertices;
|
|
var n = verts.Count;
|
|
|
|
// Skip closing vertex if present.
|
|
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
|
|
n--;
|
|
|
|
for (var i = 0; i < n; i++)
|
|
path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
|
|
|
|
return path;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a Clipper2 PathD to an OpenNest Polygon.
|
|
/// </summary>
|
|
public static Polygon FromClipperPath(PathD path)
|
|
{
|
|
var polygon = new Polygon();
|
|
|
|
foreach (var pt in path)
|
|
polygon.Vertices.Add(new Vector(pt.x, pt.y));
|
|
|
|
polygon.Close();
|
|
polygon.UpdateBounds();
|
|
return polygon;
|
|
}
|
|
}
|
|
}
|