diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs
index fdc2ce2..080e72f 100644
--- a/OpenNest.Core/Drawing.cs
+++ b/OpenNest.Core/Drawing.cs
@@ -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; }
diff --git a/OpenNest.Core/Geometry/ConvexDecomposition.cs b/OpenNest.Core/Geometry/ConvexDecomposition.cs
new file mode 100644
index 0000000..13026ba
--- /dev/null
+++ b/OpenNest.Core/Geometry/ConvexDecomposition.cs
@@ -0,0 +1,154 @@
+using System.Collections.Generic;
+
+namespace OpenNest.Geometry
+{
+ ///
+ /// Decomposes concave polygons into convex sub-polygons using ear-clipping
+ /// triangulation. Produces O(n-2) triangles per polygon.
+ ///
+ public static class ConvexDecomposition
+ {
+ ///
+ /// 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).
+ ///
+ public static List Triangulate(Polygon polygon)
+ {
+ var triangles = new List();
+ var verts = new List(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(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;
+ }
+
+ ///
+ /// Tests whether the vertex at curr forms an ear (a convex vertex whose
+ /// triangle contains no other polygon vertices).
+ ///
+ private static bool IsEar(Vector prev, Vector curr, Vector next,
+ List verts, List 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;
+ }
+
+ ///
+ /// Returns positive value if A→B→C is a CCW (left) turn.
+ ///
+ 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);
+ }
+
+ ///
+ /// Returns true if point p is strictly inside triangle (a, b, c).
+ /// Assumes CCW winding.
+ ///
+ 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);
+ }
+
+ ///
+ /// Signed area of a polygon. Positive = CCW, negative = CW.
+ ///
+ private static double SignedArea(List 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;
+ }
+ }
+}
diff --git a/OpenNest.Core/Geometry/InnerFitPolygon.cs b/OpenNest.Core/Geometry/InnerFitPolygon.cs
new file mode 100644
index 0000000..bfa09d4
--- /dev/null
+++ b/OpenNest.Core/Geometry/InnerFitPolygon.cs
@@ -0,0 +1,144 @@
+using Clipper2Lib;
+
+namespace OpenNest.Geometry
+{
+ ///
+ /// 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.
+ ///
+ public static class InnerFitPolygon
+ {
+ ///
+ /// Computes the IFP for placing a part polygon inside a rectangular work area.
+ /// The result is a polygon representing all valid reference point positions.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/OpenNest.Core/Geometry/NoFitPolygon.cs b/OpenNest.Core/Geometry/NoFitPolygon.cs
new file mode 100644
index 0000000..c4effa0
--- /dev/null
+++ b/OpenNest.Core/Geometry/NoFitPolygon.cs
@@ -0,0 +1,286 @@
+using System.Collections.Generic;
+using System.Linq;
+using Clipper2Lib;
+
+namespace OpenNest.Geometry
+{
+ ///
+ /// 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.
+ ///
+ public static class NoFitPolygon
+ {
+ private const double ClipperScale = 1000.0;
+
+ ///
+ /// 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).
+ ///
+ public static Polygon Compute(Polygon stationary, Polygon orbiting)
+ {
+ var reflected = Reflect(orbiting);
+ return MinkowskiSum(stationary, reflected);
+ }
+
+ ///
+ /// Reflects a polygon through the origin (negates all vertex coordinates).
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private static List 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(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;
+ }
+
+ ///
+ /// Finds the index of the bottom-most (then left-most) vertex.
+ ///
+ 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;
+ }
+
+ ///
+ /// Reorders edge vectors to start from the given vertex index.
+ ///
+ private static List ReorderEdges(List edges, int startIndex)
+ {
+ var n = edges.Count;
+ var result = new List(n);
+
+ for (var i = 0; i < n; i++)
+ result.Add(edges[(startIndex + i) % n]);
+
+ return result;
+ }
+
+ ///
+ /// Unions multiple polygons using Clipper2.
+ /// Returns the outer boundary of the union as a single polygon.
+ ///
+ internal static Polygon UnionPolygons(List 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);
+ }
+
+ ///
+ /// Converts an OpenNest Polygon to a Clipper2 PathD.
+ ///
+ 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;
+ }
+
+ ///
+ /// Converts a Clipper2 PathD to an OpenNest Polygon.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/OpenNest.Core/OpenNest.Core.csproj b/OpenNest.Core/OpenNest.Core.csproj
index 64b68e2..c3e24a7 100644
--- a/OpenNest.Core/OpenNest.Core.csproj
+++ b/OpenNest.Core/OpenNest.Core.csproj
@@ -5,6 +5,7 @@
OpenNest.Core
+
diff --git a/OpenNest.Engine/BottomLeftFill.cs b/OpenNest.Engine/BottomLeftFill.cs
new file mode 100644
index 0000000..192560b
--- /dev/null
+++ b/OpenNest.Engine/BottomLeftFill.cs
@@ -0,0 +1,121 @@
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// 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.
+ ///
+ public class BottomLeftFill
+ {
+ private readonly Box workArea;
+ private readonly NfpCache nfpCache;
+
+ public BottomLeftFill(Box workArea, NfpCache nfpCache)
+ {
+ this.workArea = workArea;
+ this.nfpCache = nfpCache;
+ }
+
+ ///
+ /// 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.
+ ///
+ public List Fill(List<(int drawingId, double rotation, Drawing drawing)> sequence)
+ {
+ var placedParts = new List();
+
+ 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;
+ }
+
+ ///
+ /// Converts placed parts to OpenNest Part instances positioned on the plate.
+ ///
+ public static List ToNestParts(List placedParts)
+ {
+ var parts = new List(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;
+ }
+
+ ///
+ /// Creates a translated copy of a polygon.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Represents a part that has been placed by the BLF algorithm.
+ ///
+ public class PlacedPart
+ {
+ public int DrawingId { get; set; }
+ public double Rotation { get; set; }
+ public Vector Position { get; set; }
+ public Drawing Drawing { get; set; }
+ }
+}
diff --git a/OpenNest.Engine/INestOptimizer.cs b/OpenNest.Engine/INestOptimizer.cs
new file mode 100644
index 0000000..42dce81
--- /dev/null
+++ b/OpenNest.Engine/INestOptimizer.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+using System.Threading;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// Result of a nest optimization run.
+ ///
+ public class NestResult
+ {
+ ///
+ /// The best sequence found: (drawingId, rotation, drawing) tuples in placement order.
+ ///
+ public List<(int drawingId, double rotation, Drawing drawing)> Sequence { get; set; }
+
+ ///
+ /// The score achieved by the best sequence.
+ ///
+ public FillScore Score { get; set; }
+
+ ///
+ /// Number of iterations performed.
+ ///
+ public int Iterations { get; set; }
+ }
+
+ ///
+ /// Interface for nest optimization algorithms that search for the best
+ /// part ordering and rotation to maximize plate utilization.
+ ///
+ public interface INestOptimizer
+ {
+ NestResult Optimize(List items, Box workArea, NfpCache cache,
+ Dictionary> candidateRotations,
+ CancellationToken cancellation = default);
+ }
+}
diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs
index 394660a..bb032cd 100644
--- a/OpenNest.Engine/NestEngine.cs
+++ b/OpenNest.Engine/NestEngine.cs
@@ -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;
}
+ ///
+ /// Mixed-part geometry-aware nesting using NFP-based collision avoidance
+ /// and simulated annealing optimization.
+ ///
+ public List AutoNest(List items, CancellationToken cancellation = default)
+ {
+ return AutoNest(items, Plate, cancellation);
+ }
+
+ ///
+ /// Mixed-part geometry-aware nesting using NFP-based collision avoidance
+ /// and simulated annealing optimization.
+ ///
+ public static List AutoNest(List items, Plate plate,
+ CancellationToken cancellation = default)
+ {
+ var workArea = plate.WorkArea();
+ var halfSpacing = plate.PartSpacing / 2.0;
+ var nfpCache = new NfpCache();
+ var candidateRotations = new Dictionary>();
+
+ // 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();
+
+ // 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();
+
+ // 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;
+ }
+
+ ///
+ /// Extracts the perimeter polygon from a drawing, inflated by half-spacing.
+ ///
+ 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;
+ }
+
+ ///
+ /// Computes candidate rotation angles for a drawing.
+ ///
+ private static List ComputeCandidateRotations(NestItem item,
+ Polygon perimeterPolygon, Box workArea)
+ {
+ var rotations = new List { 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;
+ }
+
+ ///
+ /// Computes convex hull edge angles from a polygon for candidate rotations.
+ ///
+ private static List ComputeHullEdgeAngles(Polygon polygon)
+ {
+ var angles = new List();
+
+ 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;
+ }
+
+ ///
+ /// Creates a rotated copy of a polygon around the origin.
+ ///
+ 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;
+ }
+
}
}
diff --git a/OpenNest.Engine/NfpCache.cs b/OpenNest.Engine/NfpCache.cs
new file mode 100644
index 0000000..bfcabeb
--- /dev/null
+++ b/OpenNest.Engine/NfpCache.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// 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.
+ ///
+ public class NfpCache
+ {
+ private readonly Dictionary cache = new Dictionary();
+ private readonly Dictionary> polygonCache
+ = new Dictionary>();
+
+ ///
+ /// Registers a pre-computed polygon for a drawing at a specific rotation.
+ /// Call this during initialization before computing NFPs.
+ ///
+ public void RegisterPolygon(int drawingId, double rotation, Polygon polygon)
+ {
+ if (!polygonCache.TryGetValue(drawingId, out var rotations))
+ {
+ rotations = new Dictionary();
+ polygonCache[drawingId] = rotations;
+ }
+
+ rotations[rotation] = polygon;
+ }
+
+ ///
+ /// Gets the polygon for a drawing at a specific rotation.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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).
+ ///
+ 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;
+ }
+
+ ///
+ /// Pre-computes all NFPs for every combination of registered polygons.
+ /// Call after all polygons are registered to front-load computation.
+ ///
+ 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);
+ }
+ }
+ }
+
+ ///
+ /// Number of cached NFP entries.
+ ///
+ public int Count => cache.Count;
+
+ private readonly struct NfpKey : IEquatable
+ {
+ 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;
+ }
+ }
+ }
+ }
+}
diff --git a/OpenNest.Engine/SimulatedAnnealing.cs b/OpenNest.Engine/SimulatedAnnealing.cs
new file mode 100644
index 0000000..c63f1eb
--- /dev/null
+++ b/OpenNest.Engine/SimulatedAnnealing.cs
@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// Simulated annealing optimizer for NFP-based nesting.
+ /// Searches for the best part ordering and rotation to maximize plate utilization.
+ ///
+ 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 items, Box workArea, NfpCache cache,
+ Dictionary> 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
+ };
+ }
+
+ ///
+ /// Builds the initial placement sequence sorted by drawing area descending.
+ /// Each NestItem is expanded by its quantity.
+ ///
+ private static List<(int drawingId, double rotation, Drawing drawing)> BuildInitialSequence(
+ List items, Dictionary> 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;
+ }
+
+ ///
+ /// Applies a random mutation to the sequence.
+ ///
+ private static void Mutate(List<(int drawingId, double rotation, Drawing drawing)> sequence,
+ Dictionary> 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;
+ }
+ }
+
+ ///
+ /// Swaps two random parts in the sequence.
+ ///
+ 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]);
+ }
+
+ ///
+ /// Changes a random part's rotation to another candidate angle.
+ ///
+ private static void MutateRotate(List<(int drawingId, double rotation, Drawing drawing)> sequence,
+ Dictionary> 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);
+ }
+
+ ///
+ /// Reverses a random contiguous subsequence.
+ ///
+ 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--;
+ }
+ }
+
+ ///
+ /// Calibrates the initial temperature by sampling random mutations and
+ /// measuring score differences. Sets temperature so ~80% of worse moves
+ /// are accepted initially.
+ ///
+ private static double CalibrateTemperature(
+ List<(int drawingId, double rotation, Drawing drawing)> sequence,
+ Box workArea, NfpCache cache,
+ Dictionary> candidateRotations, Random random)
+ {
+ const int samples = 20;
+ var deltas = new List();
+ 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);
+ }
+
+ ///
+ /// Computes a numeric difference between two scores for SA acceptance probability.
+ /// Uses a weighted combination of count and density.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs
index 46b865d..c8ad9f3 100644
--- a/OpenNest.Mcp/Tools/NestingTools.cs
+++ b/OpenNest.Mcp/Tools/NestingTools.cs
@@ -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();
+
+ 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();
+ }
}
}
diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs
index 9a4341c..f3335ab 100644
--- a/OpenNest/Forms/MainForm.cs
+++ b/OpenNest/Forms/MainForm.cs
@@ -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;
+ }
}
}