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

@@ -1,5 +1,6 @@
using System.Drawing;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
@@ -8,6 +9,7 @@ namespace OpenNest
{
public class Drawing
{
private static int nextId;
private Program program;
public Drawing()
@@ -22,6 +24,7 @@ namespace OpenNest
public Drawing(string name, Program pgm)
{
Id = Interlocked.Increment(ref nextId);
Name = name;
Material = new Material();
Program = pgm;
@@ -29,6 +32,8 @@ namespace OpenNest
Source = new SourceInfo();
}
public int Id { get; }
public string Name { get; set; }
public string Customer { get; set; }

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;
}
}
}

View File

@@ -5,6 +5,7 @@
<AssemblyName>OpenNest.Core</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Clipper2" Version="2.0.0" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,121 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// NFP-based Bottom-Left Fill (BLF) placement engine.
/// Places parts one at a time using feasible regions computed from
/// the Inner-Fit Polygon minus the union of No-Fit Polygons.
/// </summary>
public class BottomLeftFill
{
private readonly Box workArea;
private readonly NfpCache nfpCache;
public BottomLeftFill(Box workArea, NfpCache nfpCache)
{
this.workArea = workArea;
this.nfpCache = nfpCache;
}
/// <summary>
/// Places parts according to the given sequence using NFP-based BLF.
/// Each entry is (drawingId, rotation) determining what to place and how.
/// Returns the list of successfully placed parts with their positions.
/// </summary>
public List<PlacedPart> Fill(List<(int drawingId, double rotation, Drawing drawing)> sequence)
{
var placedParts = new List<PlacedPart>();
foreach (var (drawingId, rotation, drawing) in sequence)
{
var polygon = nfpCache.GetPolygon(drawingId, rotation);
if (polygon == null || polygon.Vertices.Count < 3)
continue;
// Compute IFP for this part inside the work area.
var ifp = InnerFitPolygon.Compute(workArea, polygon);
if (ifp.Vertices.Count < 3)
continue;
// Compute NFPs against all already-placed parts.
var nfps = new Polygon[placedParts.Count];
for (var i = 0; i < placedParts.Count; i++)
{
var placed = placedParts[i];
var nfp = nfpCache.Get(placed.DrawingId, placed.Rotation, drawingId, rotation);
// Translate NFP to the placed part's position.
var translated = TranslatePolygon(nfp, placed.Position);
nfps[i] = translated;
}
// Compute feasible region and find bottom-left point.
var feasible = InnerFitPolygon.ComputeFeasibleRegion(ifp, nfps);
var point = InnerFitPolygon.FindBottomLeftPoint(feasible);
if (double.IsNaN(point.X))
continue;
placedParts.Add(new PlacedPart
{
DrawingId = drawingId,
Rotation = rotation,
Position = point,
Drawing = drawing
});
}
return placedParts;
}
/// <summary>
/// Converts placed parts to OpenNest Part instances positioned on the plate.
/// </summary>
public static List<Part> ToNestParts(List<PlacedPart> placedParts)
{
var parts = new List<Part>(placedParts.Count);
foreach (var placed in placedParts)
{
var part = new Part(placed.Drawing);
if (placed.Rotation != 0)
part.Rotate(placed.Rotation);
part.Location = placed.Position;
parts.Add(part);
}
return parts;
}
/// <summary>
/// Creates a translated copy of a polygon.
/// </summary>
private static Polygon TranslatePolygon(Polygon polygon, Vector offset)
{
var result = new Polygon();
foreach (var v in polygon.Vertices)
result.Vertices.Add(new Vector(v.X + offset.X, v.Y + offset.Y));
return result;
}
}
/// <summary>
/// Represents a part that has been placed by the BLF algorithm.
/// </summary>
public class PlacedPart
{
public int DrawingId { get; set; }
public double Rotation { get; set; }
public Vector Position { get; set; }
public Drawing Drawing { get; set; }
}
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// Result of a nest optimization run.
/// </summary>
public class NestResult
{
/// <summary>
/// The best sequence found: (drawingId, rotation, drawing) tuples in placement order.
/// </summary>
public List<(int drawingId, double rotation, Drawing drawing)> Sequence { get; set; }
/// <summary>
/// The score achieved by the best sequence.
/// </summary>
public FillScore Score { get; set; }
/// <summary>
/// Number of iterations performed.
/// </summary>
public int Iterations { get; set; }
}
/// <summary>
/// Interface for nest optimization algorithms that search for the best
/// part ordering and rotation to maximize plate utilization.
/// </summary>
public interface INestOptimizer
{
NestResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
Dictionary<int, List<double>> candidateRotations,
CancellationToken cancellation = default);
}
}

View File

@@ -1,6 +1,8 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Converters;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -533,5 +535,223 @@ namespace OpenNest
return best;
}
/// <summary>
/// Mixed-part geometry-aware nesting using NFP-based collision avoidance
/// and simulated annealing optimization.
/// </summary>
public List<Part> AutoNest(List<NestItem> items, CancellationToken cancellation = default)
{
return AutoNest(items, Plate, cancellation);
}
/// <summary>
/// Mixed-part geometry-aware nesting using NFP-based collision avoidance
/// and simulated annealing optimization.
/// </summary>
public static List<Part> AutoNest(List<NestItem> items, Plate plate,
CancellationToken cancellation = default)
{
var workArea = plate.WorkArea();
var halfSpacing = plate.PartSpacing / 2.0;
var nfpCache = new NfpCache();
var candidateRotations = new Dictionary<int, List<double>>();
// Extract perimeter polygons for each unique drawing.
foreach (var item in items)
{
var drawing = item.Drawing;
if (candidateRotations.ContainsKey(drawing.Id))
continue;
var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing);
if (perimeterPolygon == null)
{
Debug.WriteLine($"[AutoNest] Skipping drawing '{drawing.Name}': no valid perimeter");
continue;
}
// Compute candidate rotations for this drawing.
var rotations = ComputeCandidateRotations(item, perimeterPolygon, workArea);
candidateRotations[drawing.Id] = rotations;
// Register polygons at each candidate rotation.
foreach (var rotation in rotations)
{
var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation);
nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon);
}
}
if (candidateRotations.Count == 0)
return new List<Part>();
// Pre-compute all NFPs.
nfpCache.PreComputeAll();
Debug.WriteLine($"[AutoNest] NFP cache: {nfpCache.Count} entries for {candidateRotations.Count} drawings");
// Run simulated annealing optimizer.
var optimizer = new SimulatedAnnealing();
var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation);
if (result.Sequence == null || result.Sequence.Count == 0)
return new List<Part>();
// Final BLF placement with the best solution.
var blf = new BottomLeftFill(workArea, nfpCache);
var placedParts = blf.Fill(result.Sequence);
var parts = BottomLeftFill.ToNestParts(placedParts);
Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations");
return parts;
}
/// <summary>
/// Extracts the perimeter polygon from a drawing, inflated by half-spacing.
/// </summary>
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count == 0)
return null;
var definedShape = new DefinedShape(entities);
var perimeter = definedShape.Perimeter;
if (perimeter == null)
return null;
// Inflate by half-spacing if spacing is non-zero.
Shape inflated;
if (halfSpacing > 0)
{
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right);
inflated = offsetEntity as Shape ?? perimeter;
}
else
{
inflated = perimeter;
}
// Convert to polygon with circumscribed arcs for tight nesting.
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
if (polygon.Vertices.Count < 3)
return null;
// Normalize: move reference point to origin.
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
polygon.Offset(-bb.Left, -bb.Bottom);
return polygon;
}
/// <summary>
/// Computes candidate rotation angles for a drawing.
/// </summary>
private static List<double> ComputeCandidateRotations(NestItem item,
Polygon perimeterPolygon, Box workArea)
{
var rotations = new List<double> { 0 };
// Add hull-edge angles from the polygon itself.
var hullAngles = ComputeHullEdgeAngles(perimeterPolygon);
foreach (var angle in hullAngles)
{
if (!rotations.Any(r => r.IsEqualTo(angle)))
rotations.Add(angle);
}
// Add 90-degree rotation.
if (!rotations.Any(r => r.IsEqualTo(Angle.HalfPI)))
rotations.Add(Angle.HalfPI);
// For narrow work areas, add sweep angles.
var partBounds = perimeterPolygon.BoundingBox;
var partLongest = System.Math.Max(partBounds.Width, partBounds.Height);
var workShort = System.Math.Min(workArea.Width, workArea.Height);
if (workShort < partLongest)
{
var step = Angle.ToRadians(5);
for (var a = 0.0; a < System.Math.PI; a += step)
{
if (!rotations.Any(r => r.IsEqualTo(a)))
rotations.Add(a);
}
}
return rotations;
}
/// <summary>
/// Computes convex hull edge angles from a polygon for candidate rotations.
/// </summary>
private static List<double> ComputeHullEdgeAngles(Polygon polygon)
{
var angles = new List<double>();
if (polygon.Vertices.Count < 3)
return angles;
var hull = ConvexHull.Compute(polygon.Vertices);
var verts = hull.Vertices;
var n = hull.IsClosed() ? verts.Count - 1 : verts.Count;
for (var i = 0; i < n; i++)
{
var next = (i + 1) % n;
var dx = verts[next].X - verts[i].X;
var dy = verts[next].Y - verts[i].Y;
if (dx * dx + dy * dy < Tolerance.Epsilon)
continue;
var angle = -System.Math.Atan2(dy, dx);
if (!angles.Any(a => a.IsEqualTo(angle)))
angles.Add(angle);
}
return angles;
}
/// <summary>
/// Creates a rotated copy of a polygon around the origin.
/// </summary>
private static Polygon RotatePolygon(Polygon polygon, double angle)
{
if (angle.IsEqualTo(0))
return polygon;
var result = new Polygon();
var cos = System.Math.Cos(angle);
var sin = System.Math.Sin(angle);
foreach (var v in polygon.Vertices)
{
result.Vertices.Add(new Vector(
v.X * cos - v.Y * sin,
v.X * sin + v.Y * cos));
}
// Re-normalize to origin.
result.UpdateBounds();
var bb = result.BoundingBox;
result.Offset(-bb.Left, -bb.Bottom);
return result;
}
}
}

138
OpenNest.Engine/NfpCache.cs Normal file
View File

@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// Caches computed No-Fit Polygons keyed by (DrawingA.Id, RotationA, DrawingB.Id, RotationB).
/// NFPs are computed on first access and stored for reuse during optimization.
/// Thread-safe for concurrent reads after pre-computation.
/// </summary>
public class NfpCache
{
private readonly Dictionary<NfpKey, Polygon> cache = new Dictionary<NfpKey, Polygon>();
private readonly Dictionary<int, Dictionary<double, Polygon>> polygonCache
= new Dictionary<int, Dictionary<double, Polygon>>();
/// <summary>
/// Registers a pre-computed polygon for a drawing at a specific rotation.
/// Call this during initialization before computing NFPs.
/// </summary>
public void RegisterPolygon(int drawingId, double rotation, Polygon polygon)
{
if (!polygonCache.TryGetValue(drawingId, out var rotations))
{
rotations = new Dictionary<double, Polygon>();
polygonCache[drawingId] = rotations;
}
rotations[rotation] = polygon;
}
/// <summary>
/// Gets the polygon for a drawing at a specific rotation.
/// </summary>
public Polygon GetPolygon(int drawingId, double rotation)
{
if (polygonCache.TryGetValue(drawingId, out var rotations))
{
if (rotations.TryGetValue(rotation, out var polygon))
return polygon;
}
return null;
}
/// <summary>
/// Gets or computes the NFP between two drawings at their respective rotations.
/// The NFP is computed from the stationary polygon (drawingA at rotationA) and
/// the orbiting polygon (drawingB at rotationB).
/// </summary>
public Polygon Get(int drawingIdA, double rotationA, int drawingIdB, double rotationB)
{
var key = new NfpKey(drawingIdA, rotationA, drawingIdB, rotationB);
if (cache.TryGetValue(key, out var nfp))
return nfp;
var polyA = GetPolygon(drawingIdA, rotationA);
var polyB = GetPolygon(drawingIdB, rotationB);
if (polyA == null || polyB == null)
return new Polygon();
nfp = NoFitPolygon.Compute(polyA, polyB);
cache[key] = nfp;
return nfp;
}
/// <summary>
/// Pre-computes all NFPs for every combination of registered polygons.
/// Call after all polygons are registered to front-load computation.
/// </summary>
public void PreComputeAll()
{
var entries = new List<(int drawingId, double rotation)>();
foreach (var kvp in polygonCache)
{
foreach (var rot in kvp.Value)
entries.Add((kvp.Key, rot.Key));
}
for (var i = 0; i < entries.Count; i++)
{
for (var j = 0; j < entries.Count; j++)
{
Get(entries[i].drawingId, entries[i].rotation,
entries[j].drawingId, entries[j].rotation);
}
}
}
/// <summary>
/// Number of cached NFP entries.
/// </summary>
public int Count => cache.Count;
private readonly struct NfpKey : IEquatable<NfpKey>
{
public readonly int DrawingIdA;
public readonly double RotationA;
public readonly int DrawingIdB;
public readonly double RotationB;
public NfpKey(int drawingIdA, double rotationA, int drawingIdB, double rotationB)
{
DrawingIdA = drawingIdA;
RotationA = rotationA;
DrawingIdB = drawingIdB;
RotationB = rotationB;
}
public bool Equals(NfpKey other)
{
return DrawingIdA == other.DrawingIdA
&& RotationA == other.RotationA
&& DrawingIdB == other.DrawingIdB
&& RotationB == other.RotationB;
}
public override bool Equals(object obj) => obj is NfpKey key && Equals(key);
public override int GetHashCode()
{
unchecked
{
var hash = 17;
hash = hash * 31 + DrawingIdA;
hash = hash * 31 + RotationA.GetHashCode();
hash = hash * 31 + DrawingIdB;
hash = hash * 31 + RotationB.GetHashCode();
return hash;
}
}
}
}
}

View File

@@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// Simulated annealing optimizer for NFP-based nesting.
/// Searches for the best part ordering and rotation to maximize plate utilization.
/// </summary>
public class SimulatedAnnealing : INestOptimizer
{
private const double DefaultCoolingRate = 0.997;
private const double DefaultMinTemperature = 0.01;
private const int DefaultMaxNoImprovement = 2000;
public NestResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
Dictionary<int, List<double>> candidateRotations,
CancellationToken cancellation = default)
{
var random = new Random();
// Build initial sequence: expand NestItems into individual (drawingId, rotation, drawing) entries,
// sorted by area descending.
var sequence = BuildInitialSequence(items, candidateRotations);
if (sequence.Count == 0)
return new NestResult { Sequence = sequence, Score = default, Iterations = 0 };
// Evaluate initial solution.
var blf = new BottomLeftFill(workArea, cache);
var bestPlaced = blf.Fill(sequence);
var bestScore = FillScore.Compute(BottomLeftFill.ToNestParts(bestPlaced), workArea);
var bestSequence = new List<(int, double, Drawing)>(sequence);
var currentSequence = new List<(int, double, Drawing)>(sequence);
var currentScore = bestScore;
// Calibrate initial temperature so ~80% of worse moves are accepted.
var initialTemp = CalibrateTemperature(currentSequence, workArea, cache,
candidateRotations, random);
var temperature = initialTemp;
var noImprovement = 0;
var iteration = 0;
Debug.WriteLine($"[SA] Initial: {bestScore.Count} parts, density={bestScore.Density:P1}, temp={initialTemp:F2}");
while (temperature > DefaultMinTemperature
&& noImprovement < DefaultMaxNoImprovement
&& !cancellation.IsCancellationRequested)
{
iteration++;
var candidate = new List<(int drawingId, double rotation, Drawing drawing)>(currentSequence);
Mutate(candidate, candidateRotations, random);
var candidatePlaced = blf.Fill(candidate);
var candidateScore = FillScore.Compute(BottomLeftFill.ToNestParts(candidatePlaced), workArea);
var delta = candidateScore.CompareTo(currentScore);
if (delta > 0)
{
// Better solution — always accept.
currentSequence = candidate;
currentScore = candidateScore;
if (currentScore > bestScore)
{
bestScore = currentScore;
bestSequence = new List<(int, double, Drawing)>(currentSequence);
noImprovement = 0;
Debug.WriteLine($"[SA] New best at iter {iteration}: {bestScore.Count} parts, density={bestScore.Density:P1}");
}
else
{
noImprovement++;
}
}
else if (delta < 0)
{
// Worse solution — accept with probability based on temperature.
var scoreDiff = ScoreDifference(currentScore, candidateScore);
var acceptProb = System.Math.Exp(-scoreDiff / temperature);
if (random.NextDouble() < acceptProb)
{
currentSequence = candidate;
currentScore = candidateScore;
}
noImprovement++;
}
else
{
noImprovement++;
}
temperature *= DefaultCoolingRate;
}
Debug.WriteLine($"[SA] Done: {iteration} iters, best={bestScore.Count} parts, density={bestScore.Density:P1}");
return new NestResult
{
Sequence = bestSequence,
Score = bestScore,
Iterations = iteration
};
}
/// <summary>
/// Builds the initial placement sequence sorted by drawing area descending.
/// Each NestItem is expanded by its quantity.
/// </summary>
private static List<(int drawingId, double rotation, Drawing drawing)> BuildInitialSequence(
List<NestItem> items, Dictionary<int, List<double>> candidateRotations)
{
var sequence = new List<(int drawingId, double rotation, Drawing drawing)>();
// Sort items by area descending.
var sorted = items.OrderByDescending(i => i.Drawing.Area).ToList();
foreach (var item in sorted)
{
var qty = item.Quantity > 0 ? item.Quantity : 1;
var rotation = 0.0;
if (candidateRotations.TryGetValue(item.Drawing.Id, out var rotations) && rotations.Count > 0)
rotation = rotations[0];
for (var i = 0; i < qty; i++)
sequence.Add((item.Drawing.Id, rotation, item.Drawing));
}
return sequence;
}
/// <summary>
/// Applies a random mutation to the sequence.
/// </summary>
private static void Mutate(List<(int drawingId, double rotation, Drawing drawing)> sequence,
Dictionary<int, List<double>> candidateRotations, Random random)
{
if (sequence.Count < 2)
return;
var op = random.Next(3);
switch (op)
{
case 0: // Swap
MutateSwap(sequence, random);
break;
case 1: // Rotate
MutateRotate(sequence, candidateRotations, random);
break;
case 2: // Segment reverse
MutateReverse(sequence, random);
break;
}
}
/// <summary>
/// Swaps two random parts in the sequence.
/// </summary>
private static void MutateSwap(List<(int, double, Drawing)> sequence, Random random)
{
var i = random.Next(sequence.Count);
var j = random.Next(sequence.Count);
while (j == i && sequence.Count > 1)
j = random.Next(sequence.Count);
(sequence[i], sequence[j]) = (sequence[j], sequence[i]);
}
/// <summary>
/// Changes a random part's rotation to another candidate angle.
/// </summary>
private static void MutateRotate(List<(int drawingId, double rotation, Drawing drawing)> sequence,
Dictionary<int, List<double>> candidateRotations, Random random)
{
var idx = random.Next(sequence.Count);
var entry = sequence[idx];
if (!candidateRotations.TryGetValue(entry.drawingId, out var rotations) || rotations.Count <= 1)
return;
var newRotation = rotations[random.Next(rotations.Count)];
sequence[idx] = (entry.drawingId, newRotation, entry.drawing);
}
/// <summary>
/// Reverses a random contiguous subsequence.
/// </summary>
private static void MutateReverse(List<(int, double, Drawing)> sequence, Random random)
{
var i = random.Next(sequence.Count);
var j = random.Next(sequence.Count);
if (i > j)
(i, j) = (j, i);
while (i < j)
{
(sequence[i], sequence[j]) = (sequence[j], sequence[i]);
i++;
j--;
}
}
/// <summary>
/// Calibrates the initial temperature by sampling random mutations and
/// measuring score differences. Sets temperature so ~80% of worse moves
/// are accepted initially.
/// </summary>
private static double CalibrateTemperature(
List<(int drawingId, double rotation, Drawing drawing)> sequence,
Box workArea, NfpCache cache,
Dictionary<int, List<double>> candidateRotations, Random random)
{
const int samples = 20;
var deltas = new List<double>();
var blf = new BottomLeftFill(workArea, cache);
var basePlaced = blf.Fill(sequence);
var baseScore = FillScore.Compute(BottomLeftFill.ToNestParts(basePlaced), workArea);
for (var i = 0; i < samples; i++)
{
var candidate = new List<(int, double, Drawing)>(sequence);
Mutate(candidate, candidateRotations, random);
var placed = blf.Fill(candidate);
var score = FillScore.Compute(BottomLeftFill.ToNestParts(placed), workArea);
var diff = ScoreDifference(baseScore, score);
if (diff > 0)
deltas.Add(diff);
}
if (deltas.Count == 0)
return 1.0;
// T = -avgDelta / ln(0.8) ≈ avgDelta * 4.48
var avgDelta = deltas.Average();
return -avgDelta / System.Math.Log(0.8);
}
/// <summary>
/// Computes a numeric difference between two scores for SA acceptance probability.
/// Uses a weighted combination of count and density.
/// </summary>
private static double ScoreDifference(FillScore better, FillScore worse)
{
// Weight count heavily (each part is worth 10 density points).
var countDiff = better.Count - worse.Count;
var densityDiff = better.Density - worse.Density;
return countDiff * 10.0 + densityDiff;
}
}
}

View File

@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading;
using ModelContextProtocol.Server;
using OpenNest.Geometry;
@@ -190,5 +191,62 @@ namespace OpenNest.Mcp.Tools
return sb.ToString();
}
[McpServerTool(Name = "autonest_plate")]
[Description("NFP-based mixed-part autonesting. Places multiple different drawings on a plate with geometry-aware collision avoidance and simulated annealing optimization. Produces tighter layouts than pack_plate by allowing parts to interlock.")]
public string AutoNestPlate(
[Description("Index of the plate")] int plateIndex,
[Description("Comma-separated drawing names")] string drawingNames,
[Description("Comma-separated quantities for each drawing")] string quantities)
{
var plate = _session.GetPlate(plateIndex);
if (plate == null)
return $"Error: plate {plateIndex} not found";
if (string.IsNullOrWhiteSpace(drawingNames))
return "Error: drawingNames is required";
if (string.IsNullOrWhiteSpace(quantities))
return "Error: quantities is required";
var names = drawingNames.Split(',').Select(n => n.Trim()).ToArray();
var qtyStrings = quantities.Split(',').Select(q => q.Trim()).ToArray();
var qtys = new int[qtyStrings.Length];
for (var i = 0; i < qtyStrings.Length; i++)
{
if (!int.TryParse(qtyStrings[i], out qtys[i]))
return $"Error: '{qtyStrings[i]}' is not a valid quantity";
}
if (names.Length != qtys.Length)
return $"Error: drawing names count ({names.Length}) does not match quantities count ({qtys.Length})";
var items = new List<NestItem>();
for (var i = 0; i < names.Length; i++)
{
var drawing = _session.GetDrawing(names[i]);
if (drawing == null)
return $"Error: drawing '{names[i]}' not found";
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] });
}
var parts = NestEngine.AutoNest(items, plate);
plate.Parts.AddRange(parts);
var sb = new StringBuilder();
sb.AppendLine($"AutoNest plate {plateIndex}: {(parts.Count > 0 ? "success" : "no parts placed")}");
sb.AppendLine($" Parts placed: {parts.Count}");
sb.AppendLine($" Total parts: {plate.Parts.Count}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
var groups = parts.GroupBy(p => p.BaseDrawing.Name);
foreach (var group in groups)
sb.AppendLine($" {group.Key}: {group.Count()}");
return sb.ToString();
}
}
}

View File

@@ -680,26 +680,32 @@ namespace OpenNest.Forms
return;
var items = form.GetNestItems();
var qty = new int[items.Count];
while (true)
{
for (int i = 0; i < items.Count; i++)
qty[i] = items[i].Drawing.Quantity.Remaining;
var remaining = items.Where(i => i.Quantity > 0).ToList();
if (remaining.Count == 0)
break;
var plate = activeForm.PlateView.Plate.Parts.Count > 0
? activeForm.Nest.CreatePlate()
: activeForm.PlateView.Plate;
var engine = new NestEngine(plate);
var parts = NestEngine.AutoNest(remaining, plate);
if (!engine.Pack(items))
if (parts.Count == 0)
break;
plate.Parts.AddRange(parts);
activeForm.Nest.UpdateDrawingQuantities();
for (int i = 0; i < items.Count; i++)
items[i].Quantity -= qty[i] - items[i].Drawing.Quantity.Remaining;
// Reduce remaining quantities by how many were placed per drawing.
foreach (var item in remaining)
{
var placed = parts.Count(p => p.BaseDrawing == item.Drawing);
item.Quantity -= placed;
}
}
}