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:
154
OpenNest.Core/Geometry/ConvexDecomposition.cs
Normal file
154
OpenNest.Core/Geometry/ConvexDecomposition.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
144
OpenNest.Core/Geometry/InnerFitPolygon.cs
Normal file
144
OpenNest.Core/Geometry/InnerFitPolygon.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
286
OpenNest.Core/Geometry/NoFitPolygon.cs
Normal file
286
OpenNest.Core/Geometry/NoFitPolygon.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user