Compare commits
16 Commits
776e9d218c
...
13264a2f8d
| Author | SHA1 | Date | |
|---|---|---|---|
| 13264a2f8d | |||
| 9df42d26de | |||
| 9daa768629 | |||
| 3592a4ce59 | |||
| e746afb57f | |||
| 0c98b240c3 | |||
| 56c9b17ff6 | |||
| c4d09f2466 | |||
| bbc3466bc8 | |||
| c18259a348 | |||
| bc3f1543ee | |||
| 90b26babc6 | |||
| faa36d7539 | |||
| 6b0bafc9de | |||
| 2632b3dbf7 | |||
| 3f3b07ef5d |
@@ -20,6 +20,7 @@ var checkOverlaps = false;
|
||||
var noSave = false;
|
||||
var noLog = false;
|
||||
var keepParts = false;
|
||||
var autoNest = false;
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
@@ -60,6 +61,9 @@ for (var i = 0; i < args.Length; i++)
|
||||
case "--keep-parts":
|
||||
keepParts = true;
|
||||
break;
|
||||
case "--autonest":
|
||||
autoNest = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
PrintUsage();
|
||||
@@ -145,11 +149,38 @@ else
|
||||
|
||||
Console.WriteLine("---");
|
||||
|
||||
// Run fill.
|
||||
// Run fill or autonest.
|
||||
var sw = Stopwatch.StartNew();
|
||||
var engine = new NestEngine(plate);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
||||
var success = engine.Fill(item);
|
||||
bool success;
|
||||
|
||||
if (autoNest)
|
||||
{
|
||||
// AutoNest: use all drawings (or specific drawing if --drawing given).
|
||||
var nestItems = new List<NestItem>();
|
||||
|
||||
if (drawingName != null)
|
||||
{
|
||||
nestItems.Add(new NestItem { Drawing = drawing, Quantity = quantity > 0 ? quantity : 1 });
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var d in nest.Drawings)
|
||||
nestItems.Add(new NestItem { Drawing = d, Quantity = quantity > 0 ? quantity : 1 });
|
||||
}
|
||||
|
||||
Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts");
|
||||
|
||||
var parts = NestEngine.AutoNest(nestItems, plate);
|
||||
plate.Parts.AddRange(parts);
|
||||
success = parts.Count > 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
var engine = new NestEngine(plate);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
||||
success = engine.Fill(item);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// Check overlaps.
|
||||
@@ -208,6 +239,7 @@ void PrintUsage()
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing");
|
||||
Console.Error.WriteLine(" --size <WxH> Override plate size (e.g. 120x60)");
|
||||
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.zip)");
|
||||
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
|
||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||
Console.Error.WriteLine(" --no-save Skip saving output 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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,63 +483,54 @@ namespace OpenNest.Geometry
|
||||
if (!IsClosed() || Vertices.Count < 5)
|
||||
return;
|
||||
|
||||
bool found = true;
|
||||
|
||||
while (found)
|
||||
while (FindCrossing(out var edgeI, out var edgeJ, out var pt))
|
||||
{
|
||||
found = false;
|
||||
int n = Vertices.Count - 1; // exclude closing vertex
|
||||
Vertices = SplitAtCrossing(edgeI, edgeJ, pt);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < n && !found; i++)
|
||||
private bool FindCrossing(out int edgeI, out int edgeJ, out Vector pt)
|
||||
{
|
||||
var n = Vertices.Count - 1;
|
||||
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
for (var j = i + 2; j < n; j++)
|
||||
{
|
||||
var a1 = Vertices[i];
|
||||
var a2 = Vertices[i + 1];
|
||||
if (i == 0 && j == n - 1)
|
||||
continue;
|
||||
|
||||
for (int j = i + 2; j < n && !found; j++)
|
||||
if (SegmentsIntersect(Vertices[i], Vertices[i + 1], Vertices[j], Vertices[j + 1], out pt))
|
||||
{
|
||||
// Skip edges that share a vertex (first and last edge)
|
||||
if (i == 0 && j == n - 1)
|
||||
continue;
|
||||
|
||||
var b1 = Vertices[j];
|
||||
var b2 = Vertices[j + 1];
|
||||
|
||||
Vector pt;
|
||||
|
||||
if (SegmentsIntersect(a1, a2, b1, b2, out pt))
|
||||
{
|
||||
// Two loops formed by the crossing:
|
||||
// Loop A: vertices[0..i], pt, vertices[j+1..n-1], close
|
||||
// Loop B: pt, vertices[i+1..j], close
|
||||
var loopA = new List<Vector>();
|
||||
|
||||
for (int k = 0; k <= i; k++)
|
||||
loopA.Add(Vertices[k]);
|
||||
|
||||
loopA.Add(pt);
|
||||
|
||||
for (int k = j + 1; k < n; k++)
|
||||
loopA.Add(Vertices[k]);
|
||||
|
||||
loopA.Add(loopA[0]);
|
||||
|
||||
var loopB = new List<Vector>();
|
||||
loopB.Add(pt);
|
||||
|
||||
for (int k = i + 1; k <= j; k++)
|
||||
loopB.Add(Vertices[k]);
|
||||
|
||||
loopB.Add(pt);
|
||||
|
||||
var areaA = System.Math.Abs(CalculateArea(loopA));
|
||||
var areaB = System.Math.Abs(CalculateArea(loopB));
|
||||
|
||||
Vertices = areaA >= areaB ? loopA : loopB;
|
||||
found = true;
|
||||
}
|
||||
edgeI = i;
|
||||
edgeJ = j;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
edgeI = edgeJ = -1;
|
||||
pt = Vector.Zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<Vector> SplitAtCrossing(int edgeI, int edgeJ, Vector pt)
|
||||
{
|
||||
var n = Vertices.Count - 1;
|
||||
|
||||
var loopA = Vertices.GetRange(0, edgeI + 1);
|
||||
loopA.Add(pt);
|
||||
loopA.AddRange(Vertices.GetRange(edgeJ + 1, n - edgeJ - 1));
|
||||
loopA.Add(loopA[0]);
|
||||
|
||||
var loopB = new List<Vector> { pt };
|
||||
loopB.AddRange(Vertices.GetRange(edgeI + 1, edgeJ - edgeI));
|
||||
loopB.Add(pt);
|
||||
|
||||
var areaA = System.Math.Abs(CalculateArea(loopA));
|
||||
var areaB = System.Math.Abs(CalculateArea(loopB));
|
||||
|
||||
return areaA >= areaB ? loopA : loopB;
|
||||
}
|
||||
|
||||
private static bool SegmentsIntersect(Vector a1, Vector a2, Vector b1, Vector b2, out Vector pt)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -53,13 +53,15 @@ namespace OpenNest.Engine.BestFit
|
||||
double spacing, double stepSize, PushDirection pushDir,
|
||||
List<PairCandidate> candidates, ref int testNumber)
|
||||
{
|
||||
const int CoarseMultiplier = 16;
|
||||
const int MaxRegions = 5;
|
||||
|
||||
var bbox1 = part1.BoundingBox;
|
||||
var bbox2 = part2Template.BoundingBox;
|
||||
var halfSpacing = spacing / 2;
|
||||
|
||||
var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right;
|
||||
|
||||
// Perpendicular range: part2 slides across the full extent of part1
|
||||
double perpMin, perpMax, pushStartOffset;
|
||||
|
||||
if (isHorizontalPush)
|
||||
@@ -75,51 +77,102 @@ namespace OpenNest.Engine.BestFit
|
||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||
}
|
||||
|
||||
// Pre-compute part1's offset lines (half-spacing outward)
|
||||
var part1Lines = Helper.GetOffsetPartLines(part1, halfSpacing);
|
||||
|
||||
// Align sweep start to a multiple of stepSize so that offset=0 is always
|
||||
// included. This ensures perfect grid arrangements (side-by-side, stacked)
|
||||
// are generated for rectangular parts.
|
||||
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
||||
// Start with the full range as a single region.
|
||||
var regions = new List<(double min, double max)> { (perpMin, perpMax) };
|
||||
var currentStep = stepSize * CoarseMultiplier;
|
||||
|
||||
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
|
||||
// Iterative halving: coarse sweep, select top regions, narrow, repeat.
|
||||
while (currentStep > stepSize)
|
||||
{
|
||||
var part2 = (Part)part2Template.Clone();
|
||||
var hits = new List<(double offset, double slideDist)>();
|
||||
|
||||
// Place part2 far away along push axis, at perpendicular offset.
|
||||
// Left/Down: start on the positive side; Right/Up: start on the negative side.
|
||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
||||
|
||||
if (isHorizontalPush)
|
||||
part2.Offset(startPos, offset);
|
||||
else
|
||||
part2.Offset(offset, startPos);
|
||||
|
||||
// Get part2's offset lines (half-spacing outward)
|
||||
var part2Lines = Helper.GetOffsetPartLines(part2, halfSpacing);
|
||||
|
||||
// Find contact distance
|
||||
var slideDist = Helper.DirectionalDistance(part2Lines, part1Lines, pushDir);
|
||||
|
||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||
continue;
|
||||
|
||||
// Move part2 to contact position
|
||||
var pushVector = GetPushVector(pushDir, slideDist);
|
||||
var finalPosition = part2.Location + pushVector;
|
||||
|
||||
candidates.Add(new PairCandidate
|
||||
foreach (var (regionMin, regionMax) in regions)
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = Part2Rotation,
|
||||
Part2Offset = finalPosition,
|
||||
StrategyType = Type,
|
||||
TestNumber = testNumber++,
|
||||
Spacing = spacing
|
||||
});
|
||||
var alignedStart = System.Math.Ceiling(regionMin / currentStep) * currentStep;
|
||||
|
||||
for (var offset = alignedStart; offset <= regionMax; offset += currentStep)
|
||||
{
|
||||
var slideDist = ComputeSlideDistance(
|
||||
part2Template, part1Lines, halfSpacing,
|
||||
offset, pushStartOffset, isHorizontalPush, pushDir);
|
||||
|
||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||
continue;
|
||||
|
||||
hits.Add((offset, slideDist));
|
||||
}
|
||||
}
|
||||
|
||||
if (hits.Count == 0)
|
||||
return;
|
||||
|
||||
// Select top regions by tightest fit, deduplicating nearby hits.
|
||||
hits.Sort((a, b) => a.slideDist.CompareTo(b.slideDist));
|
||||
|
||||
var selectedOffsets = new List<double>();
|
||||
|
||||
foreach (var (offset, _) in hits)
|
||||
{
|
||||
var tooClose = false;
|
||||
|
||||
foreach (var selected in selectedOffsets)
|
||||
{
|
||||
if (System.Math.Abs(offset - selected) < currentStep)
|
||||
{
|
||||
tooClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tooClose)
|
||||
{
|
||||
selectedOffsets.Add(offset);
|
||||
|
||||
if (selectedOffsets.Count >= MaxRegions)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build narrowed regions around selected offsets.
|
||||
regions = new List<(double min, double max)>();
|
||||
|
||||
foreach (var offset in selectedOffsets)
|
||||
{
|
||||
var regionMin = System.Math.Max(perpMin, offset - currentStep);
|
||||
var regionMax = System.Math.Min(perpMax, offset + currentStep);
|
||||
regions.Add((regionMin, regionMax));
|
||||
}
|
||||
|
||||
currentStep /= 2;
|
||||
}
|
||||
|
||||
// Final pass: sweep refined regions at stepSize, generating candidates.
|
||||
foreach (var (regionMin, regionMax) in regions)
|
||||
{
|
||||
var alignedStart = System.Math.Ceiling(regionMin / stepSize) * stepSize;
|
||||
|
||||
for (var offset = alignedStart; offset <= regionMax; offset += stepSize)
|
||||
{
|
||||
var (slideDist, finalPosition) = ComputeSlideResult(
|
||||
part2Template, part1Lines, halfSpacing,
|
||||
offset, pushStartOffset, isHorizontalPush, pushDir);
|
||||
|
||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||
continue;
|
||||
|
||||
candidates.Add(new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = Part2Rotation,
|
||||
Part2Offset = finalPosition,
|
||||
StrategyType = Type,
|
||||
TestNumber = testNumber++,
|
||||
Spacing = spacing
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,5 +187,48 @@ namespace OpenNest.Engine.BestFit
|
||||
default: return Vector.Zero;
|
||||
}
|
||||
}
|
||||
private static double ComputeSlideDistance(
|
||||
Part part2Template, List<Line> part1Lines, double halfSpacing,
|
||||
double offset, double pushStartOffset,
|
||||
bool isHorizontalPush, PushDirection pushDir)
|
||||
{
|
||||
var part2 = (Part)part2Template.Clone();
|
||||
|
||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
||||
|
||||
if (isHorizontalPush)
|
||||
part2.Offset(startPos, offset);
|
||||
else
|
||||
part2.Offset(offset, startPos);
|
||||
|
||||
var part2Lines = Helper.GetOffsetPartLines(part2, halfSpacing);
|
||||
|
||||
return Helper.DirectionalDistance(part2Lines, part1Lines, pushDir);
|
||||
}
|
||||
|
||||
private static (double slideDist, Vector finalPosition) ComputeSlideResult(
|
||||
Part part2Template, List<Line> part1Lines, double halfSpacing,
|
||||
double offset, double pushStartOffset,
|
||||
bool isHorizontalPush, PushDirection pushDir)
|
||||
{
|
||||
var part2 = (Part)part2Template.Clone();
|
||||
|
||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
||||
|
||||
if (isHorizontalPush)
|
||||
part2.Offset(startPos, offset);
|
||||
else
|
||||
part2.Offset(offset, startPos);
|
||||
|
||||
var part2Lines = Helper.GetOffsetPartLines(part2, halfSpacing);
|
||||
var slideDist = Helper.DirectionalDistance(part2Lines, part1Lines, pushDir);
|
||||
|
||||
var pushVector = GetPushVector(pushDir, slideDist);
|
||||
var finalPosition = part2.Location + pushVector;
|
||||
|
||||
return (slideDist, finalPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -150,6 +151,13 @@ namespace OpenNest
|
||||
if (IsBetterFill(pairResult, best, workArea))
|
||||
best = pairResult;
|
||||
|
||||
// NFP phase (non-rectangular parts only)
|
||||
var nfpResult = FillNfpBestFit(item, workArea);
|
||||
Debug.WriteLine($"[FindBestFill] NFP: {nfpResult?.Count ?? 0} parts");
|
||||
|
||||
if (IsBetterFill(nfpResult, best, workArea))
|
||||
best = nfpResult;
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
@@ -237,6 +245,18 @@ namespace OpenNest
|
||||
best = pairResult;
|
||||
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea);
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// NFP phase (non-rectangular parts only)
|
||||
var nfpResult = FillNfpBestFit(item, workArea);
|
||||
Debug.WriteLine($"[FindBestFill] NFP: {nfpResult?.Count ?? 0} parts");
|
||||
|
||||
if (IsBetterFill(nfpResult, best, workArea))
|
||||
{
|
||||
best = nfpResult;
|
||||
ReportProgress(progress, NestPhase.Nfp, PlateNumber, best, workArea);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -300,6 +320,18 @@ namespace OpenNest
|
||||
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea);
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// NFP phase (non-rectangular parts only)
|
||||
var nfpResult = FillNfpBestFit(nestItem, workArea);
|
||||
Debug.WriteLine($"[Fill(groupParts,Box)] NFP: {nfpResult?.Count ?? 0} parts");
|
||||
|
||||
if (IsBetterFill(nfpResult, best, workArea))
|
||||
{
|
||||
best = nfpResult;
|
||||
ReportProgress(progress, NestPhase.Nfp, PlateNumber, best, workArea);
|
||||
}
|
||||
|
||||
// Try improving by filling the remainder strip separately.
|
||||
var improved = TryRemainderImprovement(nestItem, workArea, best);
|
||||
|
||||
@@ -472,6 +504,88 @@ namespace OpenNest
|
||||
return top;
|
||||
}
|
||||
|
||||
private List<Part> FillNfpBestFit(NestItem item, Box workArea)
|
||||
{
|
||||
var halfSpacing = Plate.PartSpacing / 2.0;
|
||||
var drawing = item.Drawing;
|
||||
|
||||
// Extract offset perimeter polygon.
|
||||
var polygon = ExtractPerimeterPolygon(drawing, halfSpacing);
|
||||
|
||||
if (polygon == null)
|
||||
return new List<Part>();
|
||||
|
||||
// Rectangularity gate: skip if bounding-box fill ratio > 0.95.
|
||||
var polyArea = polygon.Area();
|
||||
var bboxArea = polygon.BoundingBox.Area();
|
||||
|
||||
if (bboxArea > 0 && polyArea / bboxArea > 0.95)
|
||||
return new List<Part>();
|
||||
|
||||
// Compute candidate rotations and filter by rotation constraints.
|
||||
var rotations = ComputeCandidateRotations(item, polygon, workArea);
|
||||
|
||||
if (item.RotationStart != 0 || item.RotationEnd != 0)
|
||||
{
|
||||
rotations = rotations
|
||||
.Where(a => a >= item.RotationStart && a <= item.RotationEnd)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (rotations.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
// Build NFP cache with all rotation variants of this single drawing.
|
||||
var nfpCache = new NfpCache();
|
||||
|
||||
foreach (var rotation in rotations)
|
||||
{
|
||||
var rotatedPolygon = RotatePolygon(polygon, rotation);
|
||||
nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon);
|
||||
}
|
||||
|
||||
nfpCache.PreComputeAll();
|
||||
|
||||
// Estimate max copies that could fit.
|
||||
var maxN = (int)(workArea.Area() / polyArea);
|
||||
maxN = System.Math.Min(maxN, 500);
|
||||
|
||||
if (item.Quantity > 0)
|
||||
maxN = System.Math.Min(maxN, item.Quantity);
|
||||
|
||||
if (maxN <= 0)
|
||||
return new List<Part>();
|
||||
|
||||
// Try each rotation and keep the best BLF result.
|
||||
List<Part> bestParts = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var rotation in rotations)
|
||||
{
|
||||
var sequence = new List<(int drawingId, double rotation, Drawing drawing)>();
|
||||
|
||||
for (var i = 0; i < maxN; i++)
|
||||
sequence.Add((drawing.Id, rotation, drawing));
|
||||
|
||||
var blf = new BottomLeftFill(workArea, nfpCache);
|
||||
var placedParts = blf.Fill(sequence);
|
||||
|
||||
if (placedParts.Count == 0)
|
||||
continue;
|
||||
|
||||
var parts = BottomLeftFill.ToNestParts(placedParts);
|
||||
var score = FillScore.Compute(parts, workArea);
|
||||
|
||||
if (bestParts == null || score > bestScore)
|
||||
{
|
||||
bestParts = parts;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return bestParts ?? new List<Part>();
|
||||
}
|
||||
|
||||
private bool HasOverlaps(List<Part> parts, double spacing)
|
||||
{
|
||||
if (parts == null || parts.Count <= 1)
|
||||
@@ -747,5 +861,224 @@ namespace OpenNest
|
||||
BestParts = clonedParts
|
||||
});
|
||||
}
|
||||
|
||||
/// <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 ShapeProfile(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.Length);
|
||||
var workShort = System.Math.Min(workArea.Width, workArea.Length);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace OpenNest
|
||||
Linear,
|
||||
RectBestFit,
|
||||
Pairs,
|
||||
Nfp,
|
||||
Remainder
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.995;
|
||||
private const double DefaultMinTemperature = 0.1;
|
||||
private const int DefaultMaxNoImprovement = 500;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-52
@@ -735,22 +735,19 @@ namespace OpenNest.Forms
|
||||
nestingCts = new CancellationTokenSource();
|
||||
var token = nestingCts.Token;
|
||||
|
||||
var progressForm = new NestProgressForm(nestingCts, showPlateRow: true);
|
||||
var plateNumber = 1;
|
||||
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
activeForm.PlateView.SetTemporaryParts(p.BestParts);
|
||||
});
|
||||
|
||||
progressForm.Show(this);
|
||||
SetNestingLockout(true);
|
||||
|
||||
try
|
||||
{
|
||||
while (items.Any(it => it.Quantity > 0))
|
||||
var maxPlates = 100;
|
||||
|
||||
for (var plateCount = 0; plateCount < maxPlates; plateCount++)
|
||||
{
|
||||
var remaining = items.Where(i => i.Quantity > 0).ToList();
|
||||
|
||||
if (remaining.Count == 0)
|
||||
break;
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
@@ -758,64 +755,35 @@ namespace OpenNest.Forms
|
||||
? activeForm.Nest.CreatePlate()
|
||||
: activeForm.PlateView.Plate;
|
||||
|
||||
// If a new plate was created, switch to it
|
||||
if (plate != activeForm.PlateView.Plate)
|
||||
activeForm.LoadLastPlate();
|
||||
|
||||
var engine = new NestEngine(plate) { PlateNumber = plateNumber };
|
||||
var filled = false;
|
||||
var parts = await Task.Run(() =>
|
||||
NestEngine.AutoNest(remaining, plate, token));
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Quantity <= 0)
|
||||
continue;
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
// Run the engine on a background thread
|
||||
var parts = await Task.Run(() =>
|
||||
engine.Fill(item, plate.WorkArea(), progress, token));
|
||||
|
||||
if (parts.Count == 0)
|
||||
continue;
|
||||
|
||||
filled = true;
|
||||
|
||||
// Count parts per drawing before accepting (for quantity tracking)
|
||||
foreach (var group in parts.GroupBy(p => p.BaseDrawing))
|
||||
{
|
||||
var placed = group.Count();
|
||||
|
||||
foreach (var ni in items)
|
||||
{
|
||||
if (ni.Drawing == group.Key)
|
||||
ni.Quantity -= placed;
|
||||
}
|
||||
}
|
||||
|
||||
// Accept the preview parts into the real plate
|
||||
activeForm.PlateView.AcceptTemporaryParts();
|
||||
}
|
||||
|
||||
if (!filled)
|
||||
if (parts.Count == 0)
|
||||
break;
|
||||
|
||||
plateNumber++;
|
||||
plate.Parts.AddRange(parts);
|
||||
activeForm.PlateView.Invalidate();
|
||||
|
||||
// Deduct placed quantities using Drawing.Name to avoid reference issues.
|
||||
foreach (var item in remaining)
|
||||
{
|
||||
var placed = parts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
}
|
||||
|
||||
activeForm.Nest.UpdateDrawingQuantities();
|
||||
progressForm.ShowCompleted();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
activeForm.PlateView.ClearTemporaryParts();
|
||||
MessageBox.Show($"Nesting error: {ex.Message}", "Error",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
progressForm.Close();
|
||||
SetNestingLockout(false);
|
||||
nestingCts.Dispose();
|
||||
nestingCts = null;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
@@ -0,0 +1,92 @@
|
||||
# Iterative Halving Sweep in RotationSlideStrategy
|
||||
|
||||
## Problem
|
||||
|
||||
`RotationSlideStrategy.GenerateCandidatesForAxis` sweeps the full perpendicular range at `stepSize` (default 0.25"), calling `Helper.DirectionalDistance` at every step. Profiling shows `DirectionalDistance` accounts for 62% of CPU during best-fit computation. For parts with large bounding boxes, this produces hundreds of steps per direction, making the Pairs phase take 2.5+ minutes.
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the single fine sweep with an iterative halving search inside `GenerateCandidatesForAxis`. Starting at a coarse step size (16× the fine step), each iteration identifies the best offset regions by slide distance, then halves the step and re-sweeps only within narrow windows around those regions. This converges to the optimal offsets in ~85 `DirectionalDistance` calls vs ~160 for a full fine sweep.
|
||||
|
||||
## Design
|
||||
|
||||
### Modified method: `GenerateCandidatesForAxis`
|
||||
|
||||
Located in `OpenNest.Engine/BestFit/RotationSlideStrategy.cs`. The public `GenerateCandidates` method and all other code remain unchanged.
|
||||
|
||||
**Current flow:**
|
||||
1. Sweep `alignedStart` to `perpMax` at `stepSize`
|
||||
2. At each offset: clone part2, position, compute offset lines, call `DirectionalDistance`, build `PairCandidate`
|
||||
|
||||
**New flow:**
|
||||
|
||||
**Constants (local to the method):**
|
||||
- `CoarseMultiplier = 16` — initial step is `stepSize * 16`
|
||||
- `MaxRegions = 5` — top-N regions to keep per iteration
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
1. Compute `currentStep = stepSize * CoarseMultiplier`
|
||||
2. Set the initial sweep range to `[alignedStart, perpMax]` where `alignedStart = Math.Ceiling(perpMin / currentStep) * currentStep`
|
||||
3. **Iteration loop** — while `currentStep > stepSize`:
|
||||
a. Sweep all active regions at `currentStep`, collecting `(offset, slideDist)` tuples:
|
||||
- For each offset in each region: clone part2, position, compute offset lines, call `DirectionalDistance`
|
||||
- Skip if `slideDist >= double.MaxValue || slideDist < 0`
|
||||
b. Select top `MaxRegions` hits by `slideDist` ascending (tightest fit first), deduplicating any hits within `currentStep` of an already-selected hit
|
||||
c. Build new regions: for each selected hit, the new region is `[offset - currentStep, offset + currentStep]`, clamped to `[perpMin, perpMax]`
|
||||
d. Halve: `currentStep /= 2`
|
||||
e. Align each region's start to a multiple of `currentStep`
|
||||
4. **Final pass** — sweep all active regions at `stepSize`, generating full `PairCandidate` objects (same logic as current code: clone part2, position, compute offset lines, `DirectionalDistance`, build candidate)
|
||||
|
||||
**Iteration trace for a 20" range with `stepSize = 0.25`:**
|
||||
|
||||
| Pass | Step | Regions | Samples per region | Total samples |
|
||||
|------|------|---------|--------------------|---------------|
|
||||
| 1 | 4.0 | 1 (full range) | ~5 | ~5 |
|
||||
| 2 | 2.0 | up to 5 | ~4 | ~20 |
|
||||
| 3 | 1.0 | up to 5 | ~4 | ~20 |
|
||||
| 4 | 0.5 | up to 5 | ~4 | ~20 |
|
||||
| 5 (final) | 0.25 | up to 5 | ~4 | ~20 (generates candidates) |
|
||||
| **Total** | | | | **~85** vs **~160 current** |
|
||||
|
||||
**Alignment:** Each pass aligns its sweep start to a multiple of `currentStep`. Since `currentStep` is always a power-of-two multiple of `stepSize`, offset=0 is always a sample point when it falls within a region. This preserves perfect grid arrangements for rectangular parts.
|
||||
|
||||
**Region deduplication:** When selecting top hits, any hit whose offset is within `currentStep` of a previously selected hit is skipped. This prevents overlapping refinement windows from wasting samples on the same area.
|
||||
|
||||
### Integration points
|
||||
|
||||
The changes are entirely within the private method `GenerateCandidatesForAxis`. The method signature, parameters, and return type (`List<PairCandidate>`) are unchanged. The only behavioral difference is that it generates fewer candidates overall (only from the promising regions), but those candidates cover the same quality range because the iterative search converges on the best offsets.
|
||||
|
||||
### Performance
|
||||
|
||||
- Current: ~160 `DirectionalDistance` calls per direction (20" range / 0.25 step)
|
||||
- Iterative halving: ~85 calls (5 + 20 + 20 + 20 + 20)
|
||||
- ~47% reduction in `DirectionalDistance` calls per direction
|
||||
- Coarse passes are cheaper per-call since they only store `(offset, slideDist)` tuples rather than building full `PairCandidate` objects
|
||||
- Total across 4 directions × N angles: proportional reduction throughout
|
||||
- For larger parts (40"+ range), the savings are even greater since the coarse pass covers the range in very few samples
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` | Replace single sweep in `GenerateCandidatesForAxis` with iterative halving sweep |
|
||||
|
||||
## What Doesn't Change
|
||||
|
||||
- `RotationSlideStrategy.GenerateCandidates` — unchanged, calls `GenerateCandidatesForAxis` as before
|
||||
- `BestFitFinder` — unchanged, calls `strategy.GenerateCandidates` as before
|
||||
- `BestFitCache` — unchanged
|
||||
- `PairEvaluator` / `IPairEvaluator` — unchanged
|
||||
- `PairCandidate`, `BestFitResult`, `BestFitFilter` — unchanged
|
||||
- `Helper.DirectionalDistance`, `Helper.GetOffsetPartLines` — reused as-is
|
||||
- `NestEngine.FillWithPairs` — unchanged caller
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Part smaller than initial coarseStep:** The first pass produces very few samples (possibly 1-2), but each subsequent halving still narrows correctly. For tiny parts, the total range may be smaller than `coarseStep`, so the algorithm effectively skips to finer passes quickly.
|
||||
- **Refinement regions overlap after halving:** Deduplication at each iteration prevents selecting nearby hits. Even if two regions share a boundary after halving, at worst one offset is evaluated twice — negligible cost.
|
||||
- **No valid hits at any pass:** If all offsets at a given step produce invalid slide distances, the hit list is empty, no regions are generated, and subsequent passes produce no candidates. This matches current behavior for parts that can't pair in the given direction.
|
||||
- **Sweep region extends past bounds:** All regions are clamped to `[perpMin, perpMax]` at each iteration.
|
||||
- **Only one valid region found:** The algorithm works correctly with 1 region — it just refines a single window instead of 5. This is common for simple rectangular parts where there's one clear best offset.
|
||||
- **stepSize is not a power of two:** The halving produces steps like 4.0 → 2.0 → 1.0 → 0.5 → 0.25 regardless of whether `stepSize` is a power of two. The loop condition `currentStep > stepSize` terminates correctly because `currentStep` will eventually equal `stepSize` after enough halvings (since `CoarseMultiplier` is a power of 2).
|
||||
@@ -0,0 +1,94 @@
|
||||
# NFP Strategy in FindBestFill
|
||||
|
||||
## Problem
|
||||
|
||||
`NestEngine.FindBestFill()` currently runs three rectangle-based strategies (Linear, RectBestFit, Pairs) that treat parts as bounding boxes. For non-rectangular parts (L-shapes, circles, irregular profiles), this wastes significant plate area because the strategies can't interlock actual part geometry.
|
||||
|
||||
The NFP infrastructure already exists (used by `AutoNest`) but is completely separate from the single-drawing fill path.
|
||||
|
||||
## Solution
|
||||
|
||||
Add `FillNfpBestFit` as a new competing strategy in `FindBestFill()`. It uses the existing NFP/BLF infrastructure to place many copies of a single drawing using actual part geometry instead of bounding boxes. It only runs when the part is non-rectangular (where it can actually improve on grid packing).
|
||||
|
||||
## Design
|
||||
|
||||
### New method: `FillNfpBestFit(NestItem item, Box workArea)`
|
||||
|
||||
Located in `NestEngine.cs`, private method alongside `FillRectangleBestFit` and `FillWithPairs`.
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
1. Compute `halfSpacing = Plate.PartSpacing / 2.0`
|
||||
2. Extract the offset perimeter polygon via `ExtractPerimeterPolygon(drawing, halfSpacing)` (already exists as a private static method in NestEngine). Returns null if invalid — return empty list.
|
||||
3. **Rectangularity gate:** compute `polygon.Area() / polygon.BoundingBox.Area()`. If ratio > 0.95, return empty list — grid strategies already handle rectangular parts optimally. Note: `BoundingBox` is a property (set by `UpdateBounds()` which `ExtractPerimeterPolygon` calls before returning).
|
||||
4. Compute candidate rotation angles via `ComputeCandidateRotations(item, polygon, workArea)` (already exists in NestEngine — computes hull edge angles, adds 0° and 90°, adds narrow-area sweep). Then filter the results by `NestItem.RotationStart` / `NestItem.RotationEnd` window (keep angles where `RotationStart <= angle <= RotationEnd`; if both are 0, treat as unconstrained). This filtering is applied locally after `ComputeCandidateRotations` returns — the shared method is not modified, so `AutoNest` behavior is unchanged.
|
||||
5. Build an `NfpCache`:
|
||||
- For each candidate rotation, rotate the polygon via `RotatePolygon()` and register it via `nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon)`
|
||||
- Call `nfpCache.PreComputeAll()` — since all entries share the same drawing ID, this computes NFPs between all rotation pairs of the single part shape
|
||||
6. For each candidate rotation, run `BottomLeftFill.Fill()`:
|
||||
- Build a sequence of N copies of `(drawing.Id, rotation, drawing)`
|
||||
- N = `(int)(workArea.Area() / polygon.Area())`, capped to 500 max, and further capped to `item.Quantity` when Quantity > 0 (avoids wasting BLF cycles on parts that will be discarded)
|
||||
- Convert BLF result via `BottomLeftFill.ToNestParts()` to get `List<Part>`
|
||||
- Score via `FillScore.Compute(parts, workArea)`
|
||||
7. Return the parts list from the highest-scoring rotation
|
||||
|
||||
### Integration points
|
||||
|
||||
**Both `FindBestFill` overloads** — insert after the Pairs phase, before remainder improvement:
|
||||
|
||||
```csharp
|
||||
// NFP phase (non-rectangular parts only)
|
||||
var nfpResult = FillNfpBestFit(item, workArea);
|
||||
Debug.WriteLine($"[FindBestFill] NFP: {nfpResult?.Count ?? 0} parts");
|
||||
|
||||
if (IsBetterFill(nfpResult, best, workArea))
|
||||
{
|
||||
best = nfpResult;
|
||||
ReportProgress(progress, NestPhase.Nfp, PlateNumber, best, workArea);
|
||||
}
|
||||
```
|
||||
|
||||
The progress-reporting overload also adds `token.ThrowIfCancellationRequested()` before the NFP phase.
|
||||
|
||||
**`Fill(List<Part> groupParts, ...)` overload** — this method runs its own RectBestFit and Pairs phases inline when `groupParts.Count == 1`, bypassing `FindBestFill`. Add the NFP phase here too, after Pairs and before remainder improvement, following the same pattern.
|
||||
|
||||
### NestPhase enum
|
||||
|
||||
Add `Nfp` after `Pairs`:
|
||||
|
||||
```csharp
|
||||
public enum NestPhase
|
||||
{
|
||||
Linear,
|
||||
RectBestFit,
|
||||
Pairs,
|
||||
Nfp,
|
||||
Remainder
|
||||
}
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `OpenNest.Engine/NestEngine.cs` | Add `FillNfpBestFit()` method; call from both `FindBestFill` overloads and the `Fill(List<Part>, ...)` single-drawing path, after Pairs phase |
|
||||
| `OpenNest.Engine/NestProgress.cs` | Add `Nfp` to `NestPhase` enum |
|
||||
|
||||
## What Doesn't Change
|
||||
|
||||
- `FillBestFit`, `FillLinear`, `FillWithPairs` — untouched
|
||||
- `AutoNest` — separate code path, untouched
|
||||
- `BottomLeftFill`, `NfpCache`, `NoFitPolygon`, `InnerFitPolygon` — reused as-is, no modifications
|
||||
- `ComputeCandidateRotations`, `ExtractPerimeterPolygon`, `RotatePolygon` — reused as-is
|
||||
- UI callers (`ActionFillArea`, `ActionClone`, `PlateView.FillWithProgress`) — no changes
|
||||
- MCP tools (`NestingTools`) — no changes
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Part with no valid perimeter polygon:** `ExtractPerimeterPolygon` returns null → return empty list
|
||||
- **Rectangularity ratio > 0.95:** skip NFP entirely, grid strategies are optimal
|
||||
- **All rotations filtered out by constraints:** no BLF runs → return empty list
|
||||
- **BLF places zero parts at a rotation:** skip that rotation, try others
|
||||
- **Very small work area where part doesn't fit:** IFP computation returns invalid polygon → BLF places nothing → return empty list
|
||||
- **Large plate with small part:** N capped to 500 to keep BLF O(N^2) cost manageable
|
||||
- **item.Quantity is set:** N further capped to Quantity to avoid placing parts that will be discarded
|
||||
Reference in New Issue
Block a user