merge: integrate NFP-based autonesting from feature/nfp-autonest
Brings in the full NFP implementation: ConvexDecomposition, NoFitPolygon, InnerFitPolygon, NfpCache, BottomLeftFill, SimulatedAnnealing optimizer, and INestOptimizer interface. Resolves conflicts by keeping master's progress reporting infrastructure alongside the new AutoNest methods, and adapting RunAutoNest_Click to use NFP AutoNest with async/cancellation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
|
||||
154
OpenNest.Core/Geometry/ConvexDecomposition.cs
Normal file
154
OpenNest.Core/Geometry/ConvexDecomposition.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Decomposes concave polygons into convex sub-polygons using ear-clipping
|
||||
/// triangulation. Produces O(n-2) triangles per polygon.
|
||||
/// </summary>
|
||||
public static class ConvexDecomposition
|
||||
{
|
||||
/// <summary>
|
||||
/// Decomposes a polygon into a list of convex triangles using ear-clipping.
|
||||
/// The input polygon must be simple (non-self-intersecting).
|
||||
/// Returns a list of triangles, each represented as a Polygon with 3 vertices (closed).
|
||||
/// </summary>
|
||||
public static List<Polygon> Triangulate(Polygon polygon)
|
||||
{
|
||||
var triangles = new List<Polygon>();
|
||||
var verts = new List<Vector>(polygon.Vertices);
|
||||
|
||||
// Remove closing vertex if polygon is closed.
|
||||
if (verts.Count > 1 && verts[0].X == verts[verts.Count - 1].X
|
||||
&& verts[0].Y == verts[verts.Count - 1].Y)
|
||||
verts.RemoveAt(verts.Count - 1);
|
||||
|
||||
if (verts.Count < 3)
|
||||
return triangles;
|
||||
|
||||
// Ensure counter-clockwise winding for ear detection.
|
||||
if (SignedArea(verts) < 0)
|
||||
verts.Reverse();
|
||||
|
||||
// Build a linked list of vertex indices.
|
||||
var indices = new List<int>(verts.Count);
|
||||
|
||||
for (var i = 0; i < verts.Count; i++)
|
||||
indices.Add(i);
|
||||
|
||||
var n = indices.Count;
|
||||
|
||||
// Safety counter to avoid infinite loop on degenerate polygons.
|
||||
var maxIterations = n * n;
|
||||
var iterations = 0;
|
||||
var i0 = 0;
|
||||
|
||||
while (n > 2 && iterations < maxIterations)
|
||||
{
|
||||
iterations++;
|
||||
|
||||
var prevIdx = (i0 + n - 1) % n;
|
||||
var currIdx = i0 % n;
|
||||
var nextIdx = (i0 + 1) % n;
|
||||
|
||||
var prev = verts[indices[prevIdx]];
|
||||
var curr = verts[indices[currIdx]];
|
||||
var next = verts[indices[nextIdx]];
|
||||
|
||||
if (IsEar(prev, curr, next, verts, indices, n))
|
||||
{
|
||||
var tri = new Polygon();
|
||||
tri.Vertices.Add(prev);
|
||||
tri.Vertices.Add(curr);
|
||||
tri.Vertices.Add(next);
|
||||
tri.Close();
|
||||
triangles.Add(tri);
|
||||
|
||||
indices.RemoveAt(currIdx);
|
||||
n--;
|
||||
i0 = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
i0++;
|
||||
|
||||
if (i0 >= n)
|
||||
i0 = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return triangles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests whether the vertex at curr forms an ear (a convex vertex whose
|
||||
/// triangle contains no other polygon vertices).
|
||||
/// </summary>
|
||||
private static bool IsEar(Vector prev, Vector curr, Vector next,
|
||||
List<Vector> verts, List<int> indices, int n)
|
||||
{
|
||||
// Must be convex (CCW turn).
|
||||
if (Cross(prev, curr, next) <= 0)
|
||||
return false;
|
||||
|
||||
// Check that no other vertex lies inside the triangle.
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var v = verts[indices[i]];
|
||||
|
||||
if (v.X == prev.X && v.Y == prev.Y)
|
||||
continue;
|
||||
if (v.X == curr.X && v.Y == curr.Y)
|
||||
continue;
|
||||
if (v.X == next.X && v.Y == next.Y)
|
||||
continue;
|
||||
|
||||
if (PointInTriangle(v, prev, curr, next))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns positive value if A→B→C is a CCW (left) turn.
|
||||
/// </summary>
|
||||
internal static double Cross(Vector a, Vector b, Vector c)
|
||||
{
|
||||
return (b.X - a.X) * (c.Y - a.Y) - (b.Y - a.Y) * (c.X - a.X);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if point p is strictly inside triangle (a, b, c).
|
||||
/// Assumes CCW winding.
|
||||
/// </summary>
|
||||
private static bool PointInTriangle(Vector p, Vector a, Vector b, Vector c)
|
||||
{
|
||||
var d1 = Cross(a, b, p);
|
||||
var d2 = Cross(b, c, p);
|
||||
var d3 = Cross(c, a, p);
|
||||
|
||||
var hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
|
||||
var hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
|
||||
|
||||
return !(hasNeg && hasPos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signed area of a polygon. Positive = CCW, negative = CW.
|
||||
/// </summary>
|
||||
private static double SignedArea(List<Vector> verts)
|
||||
{
|
||||
var area = 0.0;
|
||||
|
||||
for (var i = 0; i < verts.Count; i++)
|
||||
{
|
||||
var j = (i + 1) % verts.Count;
|
||||
area += verts[i].X * verts[j].Y;
|
||||
area -= verts[j].X * verts[i].Y;
|
||||
}
|
||||
|
||||
return area * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
144
OpenNest.Core/Geometry/InnerFitPolygon.cs
Normal file
144
OpenNest.Core/Geometry/InnerFitPolygon.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using Clipper2Lib;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the Inner-Fit Polygon (IFP) — the feasible region where a part's
|
||||
/// reference point can be placed so the part stays entirely within the plate boundary.
|
||||
/// For a rectangular plate, the IFP is the plate shrunk by the part's bounding dimensions.
|
||||
/// </summary>
|
||||
public static class InnerFitPolygon
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the IFP for placing a part polygon inside a rectangular work area.
|
||||
/// The result is a polygon representing all valid reference point positions.
|
||||
/// </summary>
|
||||
public static Polygon Compute(Box workArea, Polygon partPolygon)
|
||||
{
|
||||
// Get the part's bounding box relative to its reference point (origin).
|
||||
var verts = partPolygon.Vertices;
|
||||
|
||||
if (verts.Count < 3)
|
||||
return new Polygon();
|
||||
|
||||
var minX = verts[0].X;
|
||||
var maxX = verts[0].X;
|
||||
var minY = verts[0].Y;
|
||||
var maxY = verts[0].Y;
|
||||
|
||||
for (var i = 1; i < verts.Count; i++)
|
||||
{
|
||||
if (verts[i].X < minX) minX = verts[i].X;
|
||||
if (verts[i].X > maxX) maxX = verts[i].X;
|
||||
if (verts[i].Y < minY) minY = verts[i].Y;
|
||||
if (verts[i].Y > maxY) maxY = verts[i].Y;
|
||||
}
|
||||
|
||||
// The IFP is the work area shrunk inward by the part's extent in each direction.
|
||||
// The reference point can range from (workArea.Left - minX) to (workArea.Right - maxX)
|
||||
// and (workArea.Bottom - minY) to (workArea.Top - maxY).
|
||||
var ifpLeft = workArea.X - minX;
|
||||
var ifpRight = workArea.Right - maxX;
|
||||
var ifpBottom = workArea.Y - minY;
|
||||
var ifpTop = workArea.Top - maxY;
|
||||
|
||||
// If the part doesn't fit, return an empty polygon.
|
||||
if (ifpRight < ifpLeft || ifpTop < ifpBottom)
|
||||
return new Polygon();
|
||||
|
||||
var result = new Polygon();
|
||||
result.Vertices.Add(new Vector(ifpLeft, ifpBottom));
|
||||
result.Vertices.Add(new Vector(ifpRight, ifpBottom));
|
||||
result.Vertices.Add(new Vector(ifpRight, ifpTop));
|
||||
result.Vertices.Add(new Vector(ifpLeft, ifpTop));
|
||||
result.Close();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the feasible region for placing a part given already-placed parts.
|
||||
/// FeasibleRegion = IFP(plate, part) - union(NFP(placed_i, part))
|
||||
/// Returns the polygon representing valid placement positions, or an empty
|
||||
/// polygon if no valid position exists.
|
||||
/// </summary>
|
||||
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
|
||||
{
|
||||
if (ifp.Vertices.Count < 3)
|
||||
return new Polygon();
|
||||
|
||||
if (nfps == null || nfps.Length == 0)
|
||||
return ifp;
|
||||
|
||||
var ifpPath = NoFitPolygon.ToClipperPath(ifp);
|
||||
var ifpPaths = new PathsD { ifpPath };
|
||||
|
||||
// Union all NFPs.
|
||||
var nfpPaths = new PathsD();
|
||||
|
||||
foreach (var nfp in nfps)
|
||||
{
|
||||
if (nfp.Vertices.Count >= 3)
|
||||
{
|
||||
var path = NoFitPolygon.ToClipperPath(nfp);
|
||||
nfpPaths.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (nfpPaths.Count == 0)
|
||||
return ifp;
|
||||
|
||||
var nfpUnion = Clipper.Union(nfpPaths, FillRule.NonZero);
|
||||
|
||||
// Subtract the NFP union from the IFP.
|
||||
var feasible = Clipper.Difference(ifpPaths, nfpUnion, FillRule.NonZero);
|
||||
|
||||
if (feasible.Count == 0)
|
||||
return new Polygon();
|
||||
|
||||
// Find the polygon with the bottom-left-most point.
|
||||
// This ensures we pick the correct region for placement.
|
||||
PathD bestPath = null;
|
||||
var bestY = double.MaxValue;
|
||||
var bestX = double.MaxValue;
|
||||
|
||||
foreach (var path in feasible)
|
||||
{
|
||||
foreach (var pt in path)
|
||||
{
|
||||
if (pt.y < bestY || (pt.y == bestY && pt.x < bestX))
|
||||
{
|
||||
bestY = pt.y;
|
||||
bestX = pt.x;
|
||||
bestPath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestPath != null ? NoFitPolygon.FromClipperPath(bestPath) : new Polygon();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the bottom-left-most point on a polygon boundary.
|
||||
/// "Bottom-left" means: minimize Y first, then minimize X.
|
||||
/// Returns Vector.Invalid if the polygon has no vertices.
|
||||
/// </summary>
|
||||
public static Vector FindBottomLeftPoint(Polygon polygon)
|
||||
{
|
||||
if (polygon.Vertices.Count == 0)
|
||||
return Vector.Invalid;
|
||||
|
||||
var best = polygon.Vertices[0];
|
||||
|
||||
for (var i = 1; i < polygon.Vertices.Count; i++)
|
||||
{
|
||||
var v = polygon.Vertices[i];
|
||||
|
||||
if (v.Y < best.Y || (v.Y == best.Y && v.X < best.X))
|
||||
best = v;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
}
|
||||
}
|
||||
286
OpenNest.Core/Geometry/NoFitPolygon.cs
Normal file
286
OpenNest.Core/Geometry/NoFitPolygon.cs
Normal file
@@ -0,0 +1,286 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Clipper2Lib;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the No-Fit Polygon (NFP) between two polygons.
|
||||
/// The NFP defines all positions where the orbiting polygon's reference point
|
||||
/// would cause overlap with the stationary polygon.
|
||||
/// </summary>
|
||||
public static class NoFitPolygon
|
||||
{
|
||||
private const double ClipperScale = 1000.0;
|
||||
|
||||
/// <summary>
|
||||
/// Computes the NFP between a stationary polygon A and an orbiting polygon B.
|
||||
/// NFP(A, B) = Minkowski sum of A and -B (B reflected through its reference point).
|
||||
/// </summary>
|
||||
public static Polygon Compute(Polygon stationary, Polygon orbiting)
|
||||
{
|
||||
var reflected = Reflect(orbiting);
|
||||
return MinkowskiSum(stationary, reflected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflects a polygon through the origin (negates all vertex coordinates).
|
||||
/// </summary>
|
||||
private static Polygon Reflect(Polygon polygon)
|
||||
{
|
||||
var result = new Polygon();
|
||||
|
||||
foreach (var v in polygon.Vertices)
|
||||
result.Vertices.Add(new Vector(-v.X, -v.Y));
|
||||
|
||||
// Reflecting reverses winding order — reverse to maintain CCW.
|
||||
result.Vertices.Reverse();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the Minkowski sum of two polygons using convex decomposition.
|
||||
/// For convex polygons, uses the direct O(n+m) merge-sort of edge vectors.
|
||||
/// For concave polygons, decomposes into triangles, computes pairwise
|
||||
/// convex Minkowski sums, and unions the results with Clipper2.
|
||||
/// </summary>
|
||||
private static Polygon MinkowskiSum(Polygon a, Polygon b)
|
||||
{
|
||||
var trisA = ConvexDecomposition.Triangulate(a);
|
||||
var trisB = ConvexDecomposition.Triangulate(b);
|
||||
|
||||
if (trisA.Count == 0 || trisB.Count == 0)
|
||||
return new Polygon();
|
||||
|
||||
var partialSums = new List<Polygon>();
|
||||
|
||||
foreach (var ta in trisA)
|
||||
{
|
||||
foreach (var tb in trisB)
|
||||
{
|
||||
var sum = ConvexMinkowskiSum(ta, tb);
|
||||
|
||||
if (sum.Vertices.Count >= 3)
|
||||
partialSums.Add(sum);
|
||||
}
|
||||
}
|
||||
|
||||
if (partialSums.Count == 0)
|
||||
return new Polygon();
|
||||
|
||||
if (partialSums.Count == 1)
|
||||
return partialSums[0];
|
||||
|
||||
return UnionPolygons(partialSums);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the Minkowski sum of two convex polygons by merging their
|
||||
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
|
||||
/// Both polygons must have CCW winding.
|
||||
/// </summary>
|
||||
internal static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
|
||||
{
|
||||
var edgesA = GetEdgeVectors(a);
|
||||
var edgesB = GetEdgeVectors(b);
|
||||
|
||||
// Find bottom-most (then left-most) vertex for each polygon as starting point.
|
||||
var startA = FindBottomLeft(a);
|
||||
var startB = FindBottomLeft(b);
|
||||
|
||||
var result = new Polygon();
|
||||
var current = new Vector(
|
||||
a.Vertices[startA].X + b.Vertices[startB].X,
|
||||
a.Vertices[startA].Y + b.Vertices[startB].Y);
|
||||
result.Vertices.Add(current);
|
||||
|
||||
var ia = 0;
|
||||
var ib = 0;
|
||||
var na = edgesA.Count;
|
||||
var nb = edgesB.Count;
|
||||
|
||||
// Reorder edges to start from the bottom-left vertex.
|
||||
var orderedA = ReorderEdges(edgesA, startA);
|
||||
var orderedB = ReorderEdges(edgesB, startB);
|
||||
|
||||
while (ia < na || ib < nb)
|
||||
{
|
||||
Vector edge;
|
||||
|
||||
if (ia >= na)
|
||||
{
|
||||
edge = orderedB[ib++];
|
||||
}
|
||||
else if (ib >= nb)
|
||||
{
|
||||
edge = orderedA[ia++];
|
||||
}
|
||||
else
|
||||
{
|
||||
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
|
||||
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
|
||||
|
||||
if (angleA < angleB)
|
||||
{
|
||||
edge = orderedA[ia++];
|
||||
}
|
||||
else if (angleB < angleA)
|
||||
{
|
||||
edge = orderedB[ib++];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Same angle — merge both edges.
|
||||
edge = new Vector(
|
||||
orderedA[ia].X + orderedB[ib].X,
|
||||
orderedA[ia].Y + orderedB[ib].Y);
|
||||
ia++;
|
||||
ib++;
|
||||
}
|
||||
}
|
||||
|
||||
current = new Vector(current.X + edge.X, current.Y + edge.Y);
|
||||
result.Vertices.Add(current);
|
||||
}
|
||||
|
||||
result.Close();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets edge vectors for a polygon (each edge as a direction vector).
|
||||
/// Assumes the polygon is closed (last vertex == first vertex) or handles open polygons.
|
||||
/// </summary>
|
||||
private static List<Vector> GetEdgeVectors(Polygon polygon)
|
||||
{
|
||||
var verts = polygon.Vertices;
|
||||
var n = verts.Count;
|
||||
|
||||
// If closed, skip last duplicate vertex.
|
||||
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
|
||||
n--;
|
||||
|
||||
var edges = new List<Vector>(n);
|
||||
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var next = (i + 1) % n;
|
||||
edges.Add(new Vector(verts[next].X - verts[i].X, verts[next].Y - verts[i].Y));
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the index of the bottom-most (then left-most) vertex.
|
||||
/// </summary>
|
||||
private static int FindBottomLeft(Polygon polygon)
|
||||
{
|
||||
var verts = polygon.Vertices;
|
||||
var n = verts.Count;
|
||||
|
||||
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
|
||||
n--;
|
||||
|
||||
var best = 0;
|
||||
|
||||
for (var i = 1; i < n; i++)
|
||||
{
|
||||
if (verts[i].Y < verts[best].Y ||
|
||||
(verts[i].Y == verts[best].Y && verts[i].X < verts[best].X))
|
||||
best = i;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorders edge vectors to start from the given vertex index.
|
||||
/// </summary>
|
||||
private static List<Vector> ReorderEdges(List<Vector> edges, int startIndex)
|
||||
{
|
||||
var n = edges.Count;
|
||||
var result = new List<Vector>(n);
|
||||
|
||||
for (var i = 0; i < n; i++)
|
||||
result.Add(edges[(startIndex + i) % n]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unions multiple polygons using Clipper2.
|
||||
/// Returns the outer boundary of the union as a single polygon.
|
||||
/// </summary>
|
||||
internal static Polygon UnionPolygons(List<Polygon> polygons)
|
||||
{
|
||||
var paths = new PathsD();
|
||||
|
||||
foreach (var poly in polygons)
|
||||
{
|
||||
var path = ToClipperPath(poly);
|
||||
|
||||
if (path.Count >= 3)
|
||||
paths.Add(path);
|
||||
}
|
||||
|
||||
if (paths.Count == 0)
|
||||
return new Polygon();
|
||||
|
||||
var result = Clipper.Union(paths, FillRule.NonZero);
|
||||
|
||||
if (result.Count == 0)
|
||||
return new Polygon();
|
||||
|
||||
// Find the largest polygon (by area) as the outer boundary.
|
||||
var largest = result[0];
|
||||
var largestArea = System.Math.Abs(Clipper.Area(largest));
|
||||
|
||||
for (var i = 1; i < result.Count; i++)
|
||||
{
|
||||
var area = System.Math.Abs(Clipper.Area(result[i]));
|
||||
|
||||
if (area > largestArea)
|
||||
{
|
||||
largest = result[i];
|
||||
largestArea = area;
|
||||
}
|
||||
}
|
||||
|
||||
return FromClipperPath(largest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OpenNest Polygon to a Clipper2 PathD.
|
||||
/// </summary>
|
||||
internal static PathD ToClipperPath(Polygon polygon)
|
||||
{
|
||||
var path = new PathD();
|
||||
var verts = polygon.Vertices;
|
||||
var n = verts.Count;
|
||||
|
||||
// Skip closing vertex if present.
|
||||
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
|
||||
n--;
|
||||
|
||||
for (var i = 0; i < n; i++)
|
||||
path.Add(new PointD(verts[i].X, verts[i].Y));
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Clipper2 PathD to an OpenNest Polygon.
|
||||
/// </summary>
|
||||
internal static Polygon FromClipperPath(PathD path)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
|
||||
foreach (var pt in path)
|
||||
polygon.Vertices.Add(new Vector(pt.x, pt.y));
|
||||
|
||||
polygon.Close();
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
121
OpenNest.Engine/BottomLeftFill.cs
Normal file
121
OpenNest.Engine/BottomLeftFill.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// NFP-based Bottom-Left Fill (BLF) placement engine.
|
||||
/// Places parts one at a time using feasible regions computed from
|
||||
/// the Inner-Fit Polygon minus the union of No-Fit Polygons.
|
||||
/// </summary>
|
||||
public class BottomLeftFill
|
||||
{
|
||||
private readonly Box workArea;
|
||||
private readonly NfpCache nfpCache;
|
||||
|
||||
public BottomLeftFill(Box workArea, NfpCache nfpCache)
|
||||
{
|
||||
this.workArea = workArea;
|
||||
this.nfpCache = nfpCache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places parts according to the given sequence using NFP-based BLF.
|
||||
/// Each entry is (drawingId, rotation) determining what to place and how.
|
||||
/// Returns the list of successfully placed parts with their positions.
|
||||
/// </summary>
|
||||
public List<PlacedPart> Fill(List<(int drawingId, double rotation, Drawing drawing)> sequence)
|
||||
{
|
||||
var placedParts = new List<PlacedPart>();
|
||||
|
||||
foreach (var (drawingId, rotation, drawing) in sequence)
|
||||
{
|
||||
var polygon = nfpCache.GetPolygon(drawingId, rotation);
|
||||
|
||||
if (polygon == null || polygon.Vertices.Count < 3)
|
||||
continue;
|
||||
|
||||
// Compute IFP for this part inside the work area.
|
||||
var ifp = InnerFitPolygon.Compute(workArea, polygon);
|
||||
|
||||
if (ifp.Vertices.Count < 3)
|
||||
continue;
|
||||
|
||||
// Compute NFPs against all already-placed parts.
|
||||
var nfps = new Polygon[placedParts.Count];
|
||||
|
||||
for (var i = 0; i < placedParts.Count; i++)
|
||||
{
|
||||
var placed = placedParts[i];
|
||||
var nfp = nfpCache.Get(placed.DrawingId, placed.Rotation, drawingId, rotation);
|
||||
|
||||
// Translate NFP to the placed part's position.
|
||||
var translated = TranslatePolygon(nfp, placed.Position);
|
||||
nfps[i] = translated;
|
||||
}
|
||||
|
||||
// Compute feasible region and find bottom-left point.
|
||||
var feasible = InnerFitPolygon.ComputeFeasibleRegion(ifp, nfps);
|
||||
var point = InnerFitPolygon.FindBottomLeftPoint(feasible);
|
||||
|
||||
if (double.IsNaN(point.X))
|
||||
continue;
|
||||
|
||||
placedParts.Add(new PlacedPart
|
||||
{
|
||||
DrawingId = drawingId,
|
||||
Rotation = rotation,
|
||||
Position = point,
|
||||
Drawing = drawing
|
||||
});
|
||||
}
|
||||
|
||||
return placedParts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts placed parts to OpenNest Part instances positioned on the plate.
|
||||
/// </summary>
|
||||
public static List<Part> ToNestParts(List<PlacedPart> placedParts)
|
||||
{
|
||||
var parts = new List<Part>(placedParts.Count);
|
||||
|
||||
foreach (var placed in placedParts)
|
||||
{
|
||||
var part = new Part(placed.Drawing);
|
||||
|
||||
if (placed.Rotation != 0)
|
||||
part.Rotate(placed.Rotation);
|
||||
|
||||
part.Location = placed.Position;
|
||||
parts.Add(part);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a translated copy of a polygon.
|
||||
/// </summary>
|
||||
private static Polygon TranslatePolygon(Polygon polygon, Vector offset)
|
||||
{
|
||||
var result = new Polygon();
|
||||
|
||||
foreach (var v in polygon.Vertices)
|
||||
result.Vertices.Add(new Vector(v.X + offset.X, v.Y + offset.Y));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a part that has been placed by the BLF algorithm.
|
||||
/// </summary>
|
||||
public class PlacedPart
|
||||
{
|
||||
public int DrawingId { get; set; }
|
||||
public double Rotation { get; set; }
|
||||
public Vector Position { get; set; }
|
||||
public Drawing Drawing { get; set; }
|
||||
}
|
||||
}
|
||||
38
OpenNest.Engine/INestOptimizer.cs
Normal file
38
OpenNest.Engine/INestOptimizer.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of a nest optimization run.
|
||||
/// </summary>
|
||||
public class NestResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The best sequence found: (drawingId, rotation, drawing) tuples in placement order.
|
||||
/// </summary>
|
||||
public List<(int drawingId, double rotation, Drawing drawing)> Sequence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The score achieved by the best sequence.
|
||||
/// </summary>
|
||||
public FillScore Score { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of iterations performed.
|
||||
/// </summary>
|
||||
public int Iterations { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for nest optimization algorithms that search for the best
|
||||
/// part ordering and rotation to maximize plate utilization.
|
||||
/// </summary>
|
||||
public interface INestOptimizer
|
||||
{
|
||||
NestResult Optimize(List<NestItem> items, Box workArea, NfpCache cache,
|
||||
Dictionary<int, List<double>> candidateRotations,
|
||||
CancellationToken cancellation = default);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -747,5 +748,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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
138
OpenNest.Engine/NfpCache.cs
Normal file
138
OpenNest.Engine/NfpCache.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Caches computed No-Fit Polygons keyed by (DrawingA.Id, RotationA, DrawingB.Id, RotationB).
|
||||
/// NFPs are computed on first access and stored for reuse during optimization.
|
||||
/// Thread-safe for concurrent reads after pre-computation.
|
||||
/// </summary>
|
||||
public class NfpCache
|
||||
{
|
||||
private readonly Dictionary<NfpKey, Polygon> cache = new Dictionary<NfpKey, Polygon>();
|
||||
private readonly Dictionary<int, Dictionary<double, Polygon>> polygonCache
|
||||
= new Dictionary<int, Dictionary<double, Polygon>>();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a pre-computed polygon for a drawing at a specific rotation.
|
||||
/// Call this during initialization before computing NFPs.
|
||||
/// </summary>
|
||||
public void RegisterPolygon(int drawingId, double rotation, Polygon polygon)
|
||||
{
|
||||
if (!polygonCache.TryGetValue(drawingId, out var rotations))
|
||||
{
|
||||
rotations = new Dictionary<double, Polygon>();
|
||||
polygonCache[drawingId] = rotations;
|
||||
}
|
||||
|
||||
rotations[rotation] = polygon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the polygon for a drawing at a specific rotation.
|
||||
/// </summary>
|
||||
public Polygon GetPolygon(int drawingId, double rotation)
|
||||
{
|
||||
if (polygonCache.TryGetValue(drawingId, out var rotations))
|
||||
{
|
||||
if (rotations.TryGetValue(rotation, out var polygon))
|
||||
return polygon;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or computes the NFP between two drawings at their respective rotations.
|
||||
/// The NFP is computed from the stationary polygon (drawingA at rotationA) and
|
||||
/// the orbiting polygon (drawingB at rotationB).
|
||||
/// </summary>
|
||||
public Polygon Get(int drawingIdA, double rotationA, int drawingIdB, double rotationB)
|
||||
{
|
||||
var key = new NfpKey(drawingIdA, rotationA, drawingIdB, rotationB);
|
||||
|
||||
if (cache.TryGetValue(key, out var nfp))
|
||||
return nfp;
|
||||
|
||||
var polyA = GetPolygon(drawingIdA, rotationA);
|
||||
var polyB = GetPolygon(drawingIdB, rotationB);
|
||||
|
||||
if (polyA == null || polyB == null)
|
||||
return new Polygon();
|
||||
|
||||
nfp = NoFitPolygon.Compute(polyA, polyB);
|
||||
cache[key] = nfp;
|
||||
return nfp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-computes all NFPs for every combination of registered polygons.
|
||||
/// Call after all polygons are registered to front-load computation.
|
||||
/// </summary>
|
||||
public void PreComputeAll()
|
||||
{
|
||||
var entries = new List<(int drawingId, double rotation)>();
|
||||
|
||||
foreach (var kvp in polygonCache)
|
||||
{
|
||||
foreach (var rot in kvp.Value)
|
||||
entries.Add((kvp.Key, rot.Key));
|
||||
}
|
||||
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
for (var j = 0; j < entries.Count; j++)
|
||||
{
|
||||
Get(entries[i].drawingId, entries[i].rotation,
|
||||
entries[j].drawingId, entries[j].rotation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of cached NFP entries.
|
||||
/// </summary>
|
||||
public int Count => cache.Count;
|
||||
|
||||
private readonly struct NfpKey : IEquatable<NfpKey>
|
||||
{
|
||||
public readonly int DrawingIdA;
|
||||
public readonly double RotationA;
|
||||
public readonly int DrawingIdB;
|
||||
public readonly double RotationB;
|
||||
|
||||
public NfpKey(int drawingIdA, double rotationA, int drawingIdB, double rotationB)
|
||||
{
|
||||
DrawingIdA = drawingIdA;
|
||||
RotationA = rotationA;
|
||||
DrawingIdB = drawingIdB;
|
||||
RotationB = rotationB;
|
||||
}
|
||||
|
||||
public bool Equals(NfpKey other)
|
||||
{
|
||||
return DrawingIdA == other.DrawingIdA
|
||||
&& RotationA == other.RotationA
|
||||
&& DrawingIdB == other.DrawingIdB
|
||||
&& RotationB == other.RotationB;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj) => obj is NfpKey key && Equals(key);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var hash = 17;
|
||||
hash = hash * 31 + DrawingIdA;
|
||||
hash = hash * 31 + RotationA.GetHashCode();
|
||||
hash = hash * 31 + DrawingIdB;
|
||||
hash = hash * 31 + RotationB.GetHashCode();
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
269
OpenNest.Engine/SimulatedAnnealing.cs
Normal file
269
OpenNest.Engine/SimulatedAnnealing.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Simulated annealing optimizer for NFP-based nesting.
|
||||
/// Searches for the best part ordering and rotation to maximize plate utilization.
|
||||
/// </summary>
|
||||
public class SimulatedAnnealing : INestOptimizer
|
||||
{
|
||||
private const double DefaultCoolingRate = 0.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
120
OpenNest/Forms/NestProgressForm.resx
Normal file
120
OpenNest/Forms/NestProgressForm.resx
Normal file
@@ -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>
|
||||
Reference in New Issue
Block a user