feat: add NFP-based mixed-part autonesting

Implement geometry-aware nesting using No-Fit Polygons and simulated
annealing optimization. Parts interlock based on true shape rather than
bounding boxes, producing tighter layouts for mixed-part scenarios.

New types in Core/Geometry:
- ConvexDecomposition: ear-clipping triangulation for concave polygons
- NoFitPolygon: Minkowski sum via convex decomposition + Clipper2 union
- InnerFitPolygon: feasible region computation for plate placement

New types in Engine:
- NfpCache: caches NFPs keyed by (drawingId, rotation) pairs
- BottomLeftFill: places parts using feasible regions from IFP - NFP union
- INestOptimizer: abstraction for future GA/parallel upgrades
- SimulatedAnnealing: optimizes part ordering and rotation

Integration:
- NestEngine.AutoNest(): new public entry point for mixed-part nesting
- MainForm.RunAutoNest_Click: uses AutoNest instead of Pack
- NestingTools.autonest_plate: new MCP tool for Claude Code integration
- Drawing.Id: auto-incrementing identifier for NFP cache keys
- Clipper2 NuGet added to OpenNest.Core for polygon boolean operations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 08:08:22 -04:00
parent 9f84357c34
commit 3f3b07ef5d
12 changed files with 1447 additions and 7 deletions

View File

@@ -0,0 +1,154 @@
using System.Collections.Generic;
namespace OpenNest.Geometry
{
/// <summary>
/// Decomposes concave polygons into convex sub-polygons using ear-clipping
/// triangulation. Produces O(n-2) triangles per polygon.
/// </summary>
public static class ConvexDecomposition
{
/// <summary>
/// Decomposes a polygon into a list of convex triangles using ear-clipping.
/// The input polygon must be simple (non-self-intersecting).
/// Returns a list of triangles, each represented as a Polygon with 3 vertices (closed).
/// </summary>
public static List<Polygon> Triangulate(Polygon polygon)
{
var triangles = new List<Polygon>();
var verts = new List<Vector>(polygon.Vertices);
// Remove closing vertex if polygon is closed.
if (verts.Count > 1 && verts[0].X == verts[verts.Count - 1].X
&& verts[0].Y == verts[verts.Count - 1].Y)
verts.RemoveAt(verts.Count - 1);
if (verts.Count < 3)
return triangles;
// Ensure counter-clockwise winding for ear detection.
if (SignedArea(verts) < 0)
verts.Reverse();
// Build a linked list of vertex indices.
var indices = new List<int>(verts.Count);
for (var i = 0; i < verts.Count; i++)
indices.Add(i);
var n = indices.Count;
// Safety counter to avoid infinite loop on degenerate polygons.
var maxIterations = n * n;
var iterations = 0;
var i0 = 0;
while (n > 2 && iterations < maxIterations)
{
iterations++;
var prevIdx = (i0 + n - 1) % n;
var currIdx = i0 % n;
var nextIdx = (i0 + 1) % n;
var prev = verts[indices[prevIdx]];
var curr = verts[indices[currIdx]];
var next = verts[indices[nextIdx]];
if (IsEar(prev, curr, next, verts, indices, n))
{
var tri = new Polygon();
tri.Vertices.Add(prev);
tri.Vertices.Add(curr);
tri.Vertices.Add(next);
tri.Close();
triangles.Add(tri);
indices.RemoveAt(currIdx);
n--;
i0 = 0;
}
else
{
i0++;
if (i0 >= n)
i0 = 0;
}
}
return triangles;
}
/// <summary>
/// Tests whether the vertex at curr forms an ear (a convex vertex whose
/// triangle contains no other polygon vertices).
/// </summary>
private static bool IsEar(Vector prev, Vector curr, Vector next,
List<Vector> verts, List<int> indices, int n)
{
// Must be convex (CCW turn).
if (Cross(prev, curr, next) <= 0)
return false;
// Check that no other vertex lies inside the triangle.
for (var i = 0; i < n; i++)
{
var v = verts[indices[i]];
if (v.X == prev.X && v.Y == prev.Y)
continue;
if (v.X == curr.X && v.Y == curr.Y)
continue;
if (v.X == next.X && v.Y == next.Y)
continue;
if (PointInTriangle(v, prev, curr, next))
return false;
}
return true;
}
/// <summary>
/// Returns positive value if A→B→C is a CCW (left) turn.
/// </summary>
internal static double Cross(Vector a, Vector b, Vector c)
{
return (b.X - a.X) * (c.Y - a.Y) - (b.Y - a.Y) * (c.X - a.X);
}
/// <summary>
/// Returns true if point p is strictly inside triangle (a, b, c).
/// Assumes CCW winding.
/// </summary>
private static bool PointInTriangle(Vector p, Vector a, Vector b, Vector c)
{
var d1 = Cross(a, b, p);
var d2 = Cross(b, c, p);
var d3 = Cross(c, a, p);
var hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
var hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(hasNeg && hasPos);
}
/// <summary>
/// Signed area of a polygon. Positive = CCW, negative = CW.
/// </summary>
private static double SignedArea(List<Vector> verts)
{
var area = 0.0;
for (var i = 0; i < verts.Count; i++)
{
var j = (i + 1) % verts.Count;
area += verts[i].X * verts[j].Y;
area -= verts[j].X * verts[i].Y;
}
return area * 0.5;
}
}
}

View File

@@ -0,0 +1,144 @@
using Clipper2Lib;
namespace OpenNest.Geometry
{
/// <summary>
/// Computes the Inner-Fit Polygon (IFP) — the feasible region where a part's
/// reference point can be placed so the part stays entirely within the plate boundary.
/// For a rectangular plate, the IFP is the plate shrunk by the part's bounding dimensions.
/// </summary>
public static class InnerFitPolygon
{
/// <summary>
/// Computes the IFP for placing a part polygon inside a rectangular work area.
/// The result is a polygon representing all valid reference point positions.
/// </summary>
public static Polygon Compute(Box workArea, Polygon partPolygon)
{
// Get the part's bounding box relative to its reference point (origin).
var verts = partPolygon.Vertices;
if (verts.Count < 3)
return new Polygon();
var minX = verts[0].X;
var maxX = verts[0].X;
var minY = verts[0].Y;
var maxY = verts[0].Y;
for (var i = 1; i < verts.Count; i++)
{
if (verts[i].X < minX) minX = verts[i].X;
if (verts[i].X > maxX) maxX = verts[i].X;
if (verts[i].Y < minY) minY = verts[i].Y;
if (verts[i].Y > maxY) maxY = verts[i].Y;
}
// The IFP is the work area shrunk inward by the part's extent in each direction.
// The reference point can range from (workArea.Left - minX) to (workArea.Right - maxX)
// and (workArea.Bottom - minY) to (workArea.Top - maxY).
var ifpLeft = workArea.X - minX;
var ifpRight = workArea.Right - maxX;
var ifpBottom = workArea.Y - minY;
var ifpTop = workArea.Top - maxY;
// If the part doesn't fit, return an empty polygon.
if (ifpRight < ifpLeft || ifpTop < ifpBottom)
return new Polygon();
var result = new Polygon();
result.Vertices.Add(new Vector(ifpLeft, ifpBottom));
result.Vertices.Add(new Vector(ifpRight, ifpBottom));
result.Vertices.Add(new Vector(ifpRight, ifpTop));
result.Vertices.Add(new Vector(ifpLeft, ifpTop));
result.Close();
return result;
}
/// <summary>
/// Computes the feasible region for placing a part given already-placed parts.
/// FeasibleRegion = IFP(plate, part) - union(NFP(placed_i, part))
/// Returns the polygon representing valid placement positions, or an empty
/// polygon if no valid position exists.
/// </summary>
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
{
if (ifp.Vertices.Count < 3)
return new Polygon();
if (nfps == null || nfps.Length == 0)
return ifp;
var ifpPath = NoFitPolygon.ToClipperPath(ifp);
var ifpPaths = new PathsD { ifpPath };
// Union all NFPs.
var nfpPaths = new PathsD();
foreach (var nfp in nfps)
{
if (nfp.Vertices.Count >= 3)
{
var path = NoFitPolygon.ToClipperPath(nfp);
nfpPaths.Add(path);
}
}
if (nfpPaths.Count == 0)
return ifp;
var nfpUnion = Clipper.Union(nfpPaths, FillRule.NonZero);
// Subtract the NFP union from the IFP.
var feasible = Clipper.Difference(ifpPaths, nfpUnion, FillRule.NonZero);
if (feasible.Count == 0)
return new Polygon();
// Find the polygon with the bottom-left-most point.
// This ensures we pick the correct region for placement.
PathD bestPath = null;
var bestY = double.MaxValue;
var bestX = double.MaxValue;
foreach (var path in feasible)
{
foreach (var pt in path)
{
if (pt.y < bestY || (pt.y == bestY && pt.x < bestX))
{
bestY = pt.y;
bestX = pt.x;
bestPath = path;
}
}
}
return bestPath != null ? NoFitPolygon.FromClipperPath(bestPath) : new Polygon();
}
/// <summary>
/// Finds the bottom-left-most point on a polygon boundary.
/// "Bottom-left" means: minimize Y first, then minimize X.
/// Returns Vector.Invalid if the polygon has no vertices.
/// </summary>
public static Vector FindBottomLeftPoint(Polygon polygon)
{
if (polygon.Vertices.Count == 0)
return Vector.Invalid;
var best = polygon.Vertices[0];
for (var i = 1; i < polygon.Vertices.Count; i++)
{
var v = polygon.Vertices[i];
if (v.Y < best.Y || (v.Y == best.Y && v.X < best.X))
best = v;
}
return best;
}
}
}

View File

@@ -0,0 +1,286 @@
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
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>
/// Reflects a polygon through the origin (negates all vertex coordinates).
/// </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));
// Reflecting reverses winding order — reverse to maintain CCW.
result.Vertices.Reverse();
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>
internal static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
{
var edgesA = GetEdgeVectors(a);
var edgesB = GetEdgeVectors(b);
// Find bottom-most (then left-most) vertex for each polygon as starting point.
var startA = FindBottomLeft(a);
var startB = FindBottomLeft(b);
var result = new Polygon();
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;
// Reorder edges to start from the bottom-left vertex.
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);
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
if (angleA < angleB)
{
edge = orderedA[ia++];
}
else if (angleB < angleA)
{
edge = orderedB[ib++];
}
else
{
// Same angle — merge both edges.
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();
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.
/// </summary>
internal static PathD ToClipperPath(Polygon polygon)
{
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, verts[i].Y));
return path;
}
/// <summary>
/// Converts a Clipper2 PathD to an OpenNest Polygon.
/// </summary>
internal 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();
return polygon;
}
}
}