diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index fe2c97e..f96e900 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -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(); + + 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 Override part spacing"); Console.Error.WriteLine(" --size Override plate size (e.g. 120x60)"); Console.Error.WriteLine(" --output Output nest file path (default: -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"); diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs index fdc2ce2..080e72f 100644 --- a/OpenNest.Core/Drawing.cs +++ b/OpenNest.Core/Drawing.cs @@ -1,5 +1,6 @@ using System.Drawing; using System.Linq; +using System.Threading; using OpenNest.CNC; using OpenNest.Converters; using OpenNest.Geometry; @@ -8,6 +9,7 @@ namespace OpenNest { public class Drawing { + private static int nextId; private Program program; public Drawing() @@ -22,6 +24,7 @@ namespace OpenNest public Drawing(string name, Program pgm) { + Id = Interlocked.Increment(ref nextId); Name = name; Material = new Material(); Program = pgm; @@ -29,6 +32,8 @@ namespace OpenNest Source = new SourceInfo(); } + public int Id { get; } + public string Name { get; set; } public string Customer { get; set; } diff --git a/OpenNest.Core/Geometry/ConvexDecomposition.cs b/OpenNest.Core/Geometry/ConvexDecomposition.cs new file mode 100644 index 0000000..13026ba --- /dev/null +++ b/OpenNest.Core/Geometry/ConvexDecomposition.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; + +namespace OpenNest.Geometry +{ + /// + /// Decomposes concave polygons into convex sub-polygons using ear-clipping + /// triangulation. Produces O(n-2) triangles per polygon. + /// + public static class ConvexDecomposition + { + /// + /// Decomposes a polygon into a list of convex triangles using ear-clipping. + /// The input polygon must be simple (non-self-intersecting). + /// Returns a list of triangles, each represented as a Polygon with 3 vertices (closed). + /// + public static List Triangulate(Polygon polygon) + { + var triangles = new List(); + var verts = new List(polygon.Vertices); + + // Remove closing vertex if polygon is closed. + if (verts.Count > 1 && verts[0].X == verts[verts.Count - 1].X + && verts[0].Y == verts[verts.Count - 1].Y) + verts.RemoveAt(verts.Count - 1); + + if (verts.Count < 3) + return triangles; + + // Ensure counter-clockwise winding for ear detection. + if (SignedArea(verts) < 0) + verts.Reverse(); + + // Build a linked list of vertex indices. + var indices = new List(verts.Count); + + for (var i = 0; i < verts.Count; i++) + indices.Add(i); + + var n = indices.Count; + + // Safety counter to avoid infinite loop on degenerate polygons. + var maxIterations = n * n; + var iterations = 0; + var i0 = 0; + + while (n > 2 && iterations < maxIterations) + { + iterations++; + + var prevIdx = (i0 + n - 1) % n; + var currIdx = i0 % n; + var nextIdx = (i0 + 1) % n; + + var prev = verts[indices[prevIdx]]; + var curr = verts[indices[currIdx]]; + var next = verts[indices[nextIdx]]; + + if (IsEar(prev, curr, next, verts, indices, n)) + { + var tri = new Polygon(); + tri.Vertices.Add(prev); + tri.Vertices.Add(curr); + tri.Vertices.Add(next); + tri.Close(); + triangles.Add(tri); + + indices.RemoveAt(currIdx); + n--; + i0 = 0; + } + else + { + i0++; + + if (i0 >= n) + i0 = 0; + } + } + + return triangles; + } + + /// + /// Tests whether the vertex at curr forms an ear (a convex vertex whose + /// triangle contains no other polygon vertices). + /// + private static bool IsEar(Vector prev, Vector curr, Vector next, + List verts, List indices, int n) + { + // Must be convex (CCW turn). + if (Cross(prev, curr, next) <= 0) + return false; + + // Check that no other vertex lies inside the triangle. + for (var i = 0; i < n; i++) + { + var v = verts[indices[i]]; + + if (v.X == prev.X && v.Y == prev.Y) + continue; + if (v.X == curr.X && v.Y == curr.Y) + continue; + if (v.X == next.X && v.Y == next.Y) + continue; + + if (PointInTriangle(v, prev, curr, next)) + return false; + } + + return true; + } + + /// + /// Returns positive value if A→B→C is a CCW (left) turn. + /// + internal static double Cross(Vector a, Vector b, Vector c) + { + return (b.X - a.X) * (c.Y - a.Y) - (b.Y - a.Y) * (c.X - a.X); + } + + /// + /// Returns true if point p is strictly inside triangle (a, b, c). + /// Assumes CCW winding. + /// + private static bool PointInTriangle(Vector p, Vector a, Vector b, Vector c) + { + var d1 = Cross(a, b, p); + var d2 = Cross(b, c, p); + var d3 = Cross(c, a, p); + + var hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0); + var hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0); + + return !(hasNeg && hasPos); + } + + /// + /// Signed area of a polygon. Positive = CCW, negative = CW. + /// + private static double SignedArea(List verts) + { + var area = 0.0; + + for (var i = 0; i < verts.Count; i++) + { + var j = (i + 1) % verts.Count; + area += verts[i].X * verts[j].Y; + area -= verts[j].X * verts[i].Y; + } + + return area * 0.5; + } + } +} diff --git a/OpenNest.Core/Geometry/InnerFitPolygon.cs b/OpenNest.Core/Geometry/InnerFitPolygon.cs new file mode 100644 index 0000000..bfa09d4 --- /dev/null +++ b/OpenNest.Core/Geometry/InnerFitPolygon.cs @@ -0,0 +1,144 @@ +using Clipper2Lib; + +namespace OpenNest.Geometry +{ + /// + /// Computes the Inner-Fit Polygon (IFP) — the feasible region where a part's + /// reference point can be placed so the part stays entirely within the plate boundary. + /// For a rectangular plate, the IFP is the plate shrunk by the part's bounding dimensions. + /// + public static class InnerFitPolygon + { + /// + /// Computes the IFP for placing a part polygon inside a rectangular work area. + /// The result is a polygon representing all valid reference point positions. + /// + public static Polygon Compute(Box workArea, Polygon partPolygon) + { + // Get the part's bounding box relative to its reference point (origin). + var verts = partPolygon.Vertices; + + if (verts.Count < 3) + return new Polygon(); + + var minX = verts[0].X; + var maxX = verts[0].X; + var minY = verts[0].Y; + var maxY = verts[0].Y; + + for (var i = 1; i < verts.Count; i++) + { + if (verts[i].X < minX) minX = verts[i].X; + if (verts[i].X > maxX) maxX = verts[i].X; + if (verts[i].Y < minY) minY = verts[i].Y; + if (verts[i].Y > maxY) maxY = verts[i].Y; + } + + // The IFP is the work area shrunk inward by the part's extent in each direction. + // The reference point can range from (workArea.Left - minX) to (workArea.Right - maxX) + // and (workArea.Bottom - minY) to (workArea.Top - maxY). + var ifpLeft = workArea.X - minX; + var ifpRight = workArea.Right - maxX; + var ifpBottom = workArea.Y - minY; + var ifpTop = workArea.Top - maxY; + + // If the part doesn't fit, return an empty polygon. + if (ifpRight < ifpLeft || ifpTop < ifpBottom) + return new Polygon(); + + var result = new Polygon(); + result.Vertices.Add(new Vector(ifpLeft, ifpBottom)); + result.Vertices.Add(new Vector(ifpRight, ifpBottom)); + result.Vertices.Add(new Vector(ifpRight, ifpTop)); + result.Vertices.Add(new Vector(ifpLeft, ifpTop)); + result.Close(); + + return result; + } + + /// + /// Computes the feasible region for placing a part given already-placed parts. + /// FeasibleRegion = IFP(plate, part) - union(NFP(placed_i, part)) + /// Returns the polygon representing valid placement positions, or an empty + /// polygon if no valid position exists. + /// + public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps) + { + if (ifp.Vertices.Count < 3) + return new Polygon(); + + if (nfps == null || nfps.Length == 0) + return ifp; + + var ifpPath = NoFitPolygon.ToClipperPath(ifp); + var ifpPaths = new PathsD { ifpPath }; + + // Union all NFPs. + var nfpPaths = new PathsD(); + + foreach (var nfp in nfps) + { + if (nfp.Vertices.Count >= 3) + { + var path = NoFitPolygon.ToClipperPath(nfp); + nfpPaths.Add(path); + } + } + + if (nfpPaths.Count == 0) + return ifp; + + var nfpUnion = Clipper.Union(nfpPaths, FillRule.NonZero); + + // Subtract the NFP union from the IFP. + var feasible = Clipper.Difference(ifpPaths, nfpUnion, FillRule.NonZero); + + if (feasible.Count == 0) + return new Polygon(); + + // Find the polygon with the bottom-left-most point. + // This ensures we pick the correct region for placement. + PathD bestPath = null; + var bestY = double.MaxValue; + var bestX = double.MaxValue; + + foreach (var path in feasible) + { + foreach (var pt in path) + { + if (pt.y < bestY || (pt.y == bestY && pt.x < bestX)) + { + bestY = pt.y; + bestX = pt.x; + bestPath = path; + } + } + } + + return bestPath != null ? NoFitPolygon.FromClipperPath(bestPath) : new Polygon(); + } + + /// + /// Finds the bottom-left-most point on a polygon boundary. + /// "Bottom-left" means: minimize Y first, then minimize X. + /// Returns Vector.Invalid if the polygon has no vertices. + /// + public static Vector FindBottomLeftPoint(Polygon polygon) + { + if (polygon.Vertices.Count == 0) + return Vector.Invalid; + + var best = polygon.Vertices[0]; + + for (var i = 1; i < polygon.Vertices.Count; i++) + { + var v = polygon.Vertices[i]; + + if (v.Y < best.Y || (v.Y == best.Y && v.X < best.X)) + best = v; + } + + return best; + } + } +} diff --git a/OpenNest.Core/Geometry/NoFitPolygon.cs b/OpenNest.Core/Geometry/NoFitPolygon.cs new file mode 100644 index 0000000..c4effa0 --- /dev/null +++ b/OpenNest.Core/Geometry/NoFitPolygon.cs @@ -0,0 +1,286 @@ +using System.Collections.Generic; +using System.Linq; +using Clipper2Lib; + +namespace OpenNest.Geometry +{ + /// + /// Computes the No-Fit Polygon (NFP) between two polygons. + /// The NFP defines all positions where the orbiting polygon's reference point + /// would cause overlap with the stationary polygon. + /// + public static class NoFitPolygon + { + private const double ClipperScale = 1000.0; + + /// + /// Computes the NFP between a stationary polygon A and an orbiting polygon B. + /// NFP(A, B) = Minkowski sum of A and -B (B reflected through its reference point). + /// + public static Polygon Compute(Polygon stationary, Polygon orbiting) + { + var reflected = Reflect(orbiting); + return MinkowskiSum(stationary, reflected); + } + + /// + /// Reflects a polygon through the origin (negates all vertex coordinates). + /// + private static Polygon Reflect(Polygon polygon) + { + var result = new Polygon(); + + foreach (var v in polygon.Vertices) + result.Vertices.Add(new Vector(-v.X, -v.Y)); + + // Reflecting reverses winding order — reverse to maintain CCW. + result.Vertices.Reverse(); + return result; + } + + /// + /// Computes the Minkowski sum of two polygons using convex decomposition. + /// For convex polygons, uses the direct O(n+m) merge-sort of edge vectors. + /// For concave polygons, decomposes into triangles, computes pairwise + /// convex Minkowski sums, and unions the results with Clipper2. + /// + private static Polygon MinkowskiSum(Polygon a, Polygon b) + { + var trisA = ConvexDecomposition.Triangulate(a); + var trisB = ConvexDecomposition.Triangulate(b); + + if (trisA.Count == 0 || trisB.Count == 0) + return new Polygon(); + + var partialSums = new List(); + + foreach (var ta in trisA) + { + foreach (var tb in trisB) + { + var sum = ConvexMinkowskiSum(ta, tb); + + if (sum.Vertices.Count >= 3) + partialSums.Add(sum); + } + } + + if (partialSums.Count == 0) + return new Polygon(); + + if (partialSums.Count == 1) + return partialSums[0]; + + return UnionPolygons(partialSums); + } + + /// + /// Computes the Minkowski sum of two convex polygons by merging their + /// edge vectors sorted by angle. O(n+m) where n and m are vertex counts. + /// Both polygons must have CCW winding. + /// + internal static Polygon ConvexMinkowskiSum(Polygon a, Polygon b) + { + var edgesA = GetEdgeVectors(a); + var edgesB = GetEdgeVectors(b); + + // Find bottom-most (then left-most) vertex for each polygon as starting point. + var startA = FindBottomLeft(a); + var startB = FindBottomLeft(b); + + var result = new Polygon(); + var current = new Vector( + a.Vertices[startA].X + b.Vertices[startB].X, + a.Vertices[startA].Y + b.Vertices[startB].Y); + result.Vertices.Add(current); + + var ia = 0; + var ib = 0; + var na = edgesA.Count; + var nb = edgesB.Count; + + // Reorder edges to start from the bottom-left vertex. + var orderedA = ReorderEdges(edgesA, startA); + var orderedB = ReorderEdges(edgesB, startB); + + while (ia < na || ib < nb) + { + Vector edge; + + if (ia >= na) + { + edge = orderedB[ib++]; + } + else if (ib >= nb) + { + edge = orderedA[ia++]; + } + else + { + var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X); + var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X); + + if (angleA < angleB) + { + edge = orderedA[ia++]; + } + else if (angleB < angleA) + { + edge = orderedB[ib++]; + } + else + { + // Same angle — merge both edges. + edge = new Vector( + orderedA[ia].X + orderedB[ib].X, + orderedA[ia].Y + orderedB[ib].Y); + ia++; + ib++; + } + } + + current = new Vector(current.X + edge.X, current.Y + edge.Y); + result.Vertices.Add(current); + } + + result.Close(); + return result; + } + + /// + /// Gets edge vectors for a polygon (each edge as a direction vector). + /// Assumes the polygon is closed (last vertex == first vertex) or handles open polygons. + /// + private static List GetEdgeVectors(Polygon polygon) + { + var verts = polygon.Vertices; + var n = verts.Count; + + // If closed, skip last duplicate vertex. + if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y) + n--; + + var edges = new List(n); + + for (var i = 0; i < n; i++) + { + var next = (i + 1) % n; + edges.Add(new Vector(verts[next].X - verts[i].X, verts[next].Y - verts[i].Y)); + } + + return edges; + } + + /// + /// Finds the index of the bottom-most (then left-most) vertex. + /// + private static int FindBottomLeft(Polygon polygon) + { + var verts = polygon.Vertices; + var n = verts.Count; + + if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y) + n--; + + var best = 0; + + for (var i = 1; i < n; i++) + { + if (verts[i].Y < verts[best].Y || + (verts[i].Y == verts[best].Y && verts[i].X < verts[best].X)) + best = i; + } + + return best; + } + + /// + /// Reorders edge vectors to start from the given vertex index. + /// + private static List ReorderEdges(List edges, int startIndex) + { + var n = edges.Count; + var result = new List(n); + + for (var i = 0; i < n; i++) + result.Add(edges[(startIndex + i) % n]); + + return result; + } + + /// + /// Unions multiple polygons using Clipper2. + /// Returns the outer boundary of the union as a single polygon. + /// + internal static Polygon UnionPolygons(List polygons) + { + var paths = new PathsD(); + + foreach (var poly in polygons) + { + var path = ToClipperPath(poly); + + if (path.Count >= 3) + paths.Add(path); + } + + if (paths.Count == 0) + return new Polygon(); + + var result = Clipper.Union(paths, FillRule.NonZero); + + if (result.Count == 0) + return new Polygon(); + + // Find the largest polygon (by area) as the outer boundary. + var largest = result[0]; + var largestArea = System.Math.Abs(Clipper.Area(largest)); + + for (var i = 1; i < result.Count; i++) + { + var area = System.Math.Abs(Clipper.Area(result[i])); + + if (area > largestArea) + { + largest = result[i]; + largestArea = area; + } + } + + return FromClipperPath(largest); + } + + /// + /// Converts an OpenNest Polygon to a Clipper2 PathD. + /// + internal static PathD ToClipperPath(Polygon polygon) + { + var path = new PathD(); + var verts = polygon.Vertices; + var n = verts.Count; + + // Skip closing vertex if present. + if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y) + n--; + + for (var i = 0; i < n; i++) + path.Add(new PointD(verts[i].X, verts[i].Y)); + + return path; + } + + /// + /// Converts a Clipper2 PathD to an OpenNest Polygon. + /// + internal static Polygon FromClipperPath(PathD path) + { + var polygon = new Polygon(); + + foreach (var pt in path) + polygon.Vertices.Add(new Vector(pt.x, pt.y)); + + polygon.Close(); + return polygon; + } + } +} diff --git a/OpenNest.Core/OpenNest.Core.csproj b/OpenNest.Core/OpenNest.Core.csproj index 64b68e2..c3e24a7 100644 --- a/OpenNest.Core/OpenNest.Core.csproj +++ b/OpenNest.Core/OpenNest.Core.csproj @@ -5,6 +5,7 @@ OpenNest.Core + diff --git a/OpenNest.Engine/BottomLeftFill.cs b/OpenNest.Engine/BottomLeftFill.cs new file mode 100644 index 0000000..192560b --- /dev/null +++ b/OpenNest.Engine/BottomLeftFill.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// NFP-based Bottom-Left Fill (BLF) placement engine. + /// Places parts one at a time using feasible regions computed from + /// the Inner-Fit Polygon minus the union of No-Fit Polygons. + /// + public class BottomLeftFill + { + private readonly Box workArea; + private readonly NfpCache nfpCache; + + public BottomLeftFill(Box workArea, NfpCache nfpCache) + { + this.workArea = workArea; + this.nfpCache = nfpCache; + } + + /// + /// Places parts according to the given sequence using NFP-based BLF. + /// Each entry is (drawingId, rotation) determining what to place and how. + /// Returns the list of successfully placed parts with their positions. + /// + public List Fill(List<(int drawingId, double rotation, Drawing drawing)> sequence) + { + var placedParts = new List(); + + foreach (var (drawingId, rotation, drawing) in sequence) + { + var polygon = nfpCache.GetPolygon(drawingId, rotation); + + if (polygon == null || polygon.Vertices.Count < 3) + continue; + + // Compute IFP for this part inside the work area. + var ifp = InnerFitPolygon.Compute(workArea, polygon); + + if (ifp.Vertices.Count < 3) + continue; + + // Compute NFPs against all already-placed parts. + var nfps = new Polygon[placedParts.Count]; + + for (var i = 0; i < placedParts.Count; i++) + { + var placed = placedParts[i]; + var nfp = nfpCache.Get(placed.DrawingId, placed.Rotation, drawingId, rotation); + + // Translate NFP to the placed part's position. + var translated = TranslatePolygon(nfp, placed.Position); + nfps[i] = translated; + } + + // Compute feasible region and find bottom-left point. + var feasible = InnerFitPolygon.ComputeFeasibleRegion(ifp, nfps); + var point = InnerFitPolygon.FindBottomLeftPoint(feasible); + + if (double.IsNaN(point.X)) + continue; + + placedParts.Add(new PlacedPart + { + DrawingId = drawingId, + Rotation = rotation, + Position = point, + Drawing = drawing + }); + } + + return placedParts; + } + + /// + /// Converts placed parts to OpenNest Part instances positioned on the plate. + /// + public static List ToNestParts(List placedParts) + { + var parts = new List(placedParts.Count); + + foreach (var placed in placedParts) + { + var part = new Part(placed.Drawing); + + if (placed.Rotation != 0) + part.Rotate(placed.Rotation); + + part.Location = placed.Position; + parts.Add(part); + } + + return parts; + } + + /// + /// Creates a translated copy of a polygon. + /// + private static Polygon TranslatePolygon(Polygon polygon, Vector offset) + { + var result = new Polygon(); + + foreach (var v in polygon.Vertices) + result.Vertices.Add(new Vector(v.X + offset.X, v.Y + offset.Y)); + + return result; + } + } + + /// + /// Represents a part that has been placed by the BLF algorithm. + /// + public class PlacedPart + { + public int DrawingId { get; set; } + public double Rotation { get; set; } + public Vector Position { get; set; } + public Drawing Drawing { get; set; } + } +} diff --git a/OpenNest.Engine/INestOptimizer.cs b/OpenNest.Engine/INestOptimizer.cs new file mode 100644 index 0000000..42dce81 --- /dev/null +++ b/OpenNest.Engine/INestOptimizer.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// Result of a nest optimization run. + /// + public class NestResult + { + /// + /// The best sequence found: (drawingId, rotation, drawing) tuples in placement order. + /// + public List<(int drawingId, double rotation, Drawing drawing)> Sequence { get; set; } + + /// + /// The score achieved by the best sequence. + /// + public FillScore Score { get; set; } + + /// + /// Number of iterations performed. + /// + public int Iterations { get; set; } + } + + /// + /// Interface for nest optimization algorithms that search for the best + /// part ordering and rotation to maximize plate utilization. + /// + public interface INestOptimizer + { + NestResult Optimize(List items, Box workArea, NfpCache cache, + Dictionary> candidateRotations, + CancellationToken cancellation = default); + } +} diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index a0eca98..ea5d92a 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -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 }); } + + /// + /// Mixed-part geometry-aware nesting using NFP-based collision avoidance + /// and simulated annealing optimization. + /// + public List AutoNest(List items, CancellationToken cancellation = default) + { + return AutoNest(items, Plate, cancellation); + } + + /// + /// Mixed-part geometry-aware nesting using NFP-based collision avoidance + /// and simulated annealing optimization. + /// + public static List AutoNest(List items, Plate plate, + CancellationToken cancellation = default) + { + var workArea = plate.WorkArea(); + var halfSpacing = plate.PartSpacing / 2.0; + var nfpCache = new NfpCache(); + var candidateRotations = new Dictionary>(); + + // Extract perimeter polygons for each unique drawing. + foreach (var item in items) + { + var drawing = item.Drawing; + + if (candidateRotations.ContainsKey(drawing.Id)) + continue; + + var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing); + + if (perimeterPolygon == null) + { + Debug.WriteLine($"[AutoNest] Skipping drawing '{drawing.Name}': no valid perimeter"); + continue; + } + + // Compute candidate rotations for this drawing. + var rotations = ComputeCandidateRotations(item, perimeterPolygon, workArea); + candidateRotations[drawing.Id] = rotations; + + // Register polygons at each candidate rotation. + foreach (var rotation in rotations) + { + var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation); + nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon); + } + } + + if (candidateRotations.Count == 0) + return new List(); + + // Pre-compute all NFPs. + nfpCache.PreComputeAll(); + + Debug.WriteLine($"[AutoNest] NFP cache: {nfpCache.Count} entries for {candidateRotations.Count} drawings"); + + // Run simulated annealing optimizer. + var optimizer = new SimulatedAnnealing(); + var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation); + + if (result.Sequence == null || result.Sequence.Count == 0) + return new List(); + + // Final BLF placement with the best solution. + var blf = new BottomLeftFill(workArea, nfpCache); + var placedParts = blf.Fill(result.Sequence); + var parts = BottomLeftFill.ToNestParts(placedParts); + + Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations"); + + return parts; + } + + /// + /// Extracts the perimeter polygon from a drawing, inflated by half-spacing. + /// + private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing) + { + var entities = ConvertProgram.ToGeometry(drawing.Program) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + + if (entities.Count == 0) + return null; + + var definedShape = new 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; + } + + /// + /// Computes candidate rotation angles for a drawing. + /// + private static List ComputeCandidateRotations(NestItem item, + Polygon perimeterPolygon, Box workArea) + { + var rotations = new List { 0 }; + + // Add hull-edge angles from the polygon itself. + var hullAngles = ComputeHullEdgeAngles(perimeterPolygon); + + foreach (var angle in hullAngles) + { + if (!rotations.Any(r => r.IsEqualTo(angle))) + rotations.Add(angle); + } + + // Add 90-degree rotation. + if (!rotations.Any(r => r.IsEqualTo(Angle.HalfPI))) + rotations.Add(Angle.HalfPI); + + // For narrow work areas, add sweep angles. + var partBounds = perimeterPolygon.BoundingBox; + var partLongest = System.Math.Max(partBounds.Width, partBounds.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; + } + + /// + /// Computes convex hull edge angles from a polygon for candidate rotations. + /// + private static List ComputeHullEdgeAngles(Polygon polygon) + { + var angles = new List(); + + if (polygon.Vertices.Count < 3) + return angles; + + var hull = ConvexHull.Compute(polygon.Vertices); + var verts = hull.Vertices; + var n = hull.IsClosed() ? verts.Count - 1 : verts.Count; + + for (var i = 0; i < n; i++) + { + var next = (i + 1) % n; + var dx = verts[next].X - verts[i].X; + var dy = verts[next].Y - verts[i].Y; + + if (dx * dx + dy * dy < Tolerance.Epsilon) + continue; + + var angle = -System.Math.Atan2(dy, dx); + + if (!angles.Any(a => a.IsEqualTo(angle))) + angles.Add(angle); + } + + return angles; + } + + /// + /// Creates a rotated copy of a polygon around the origin. + /// + private static Polygon RotatePolygon(Polygon polygon, double angle) + { + if (angle.IsEqualTo(0)) + return polygon; + + var result = new Polygon(); + var cos = System.Math.Cos(angle); + var sin = System.Math.Sin(angle); + + foreach (var v in polygon.Vertices) + { + result.Vertices.Add(new Vector( + v.X * cos - v.Y * sin, + v.X * sin + v.Y * cos)); + } + + // Re-normalize to origin. + result.UpdateBounds(); + var bb = result.BoundingBox; + result.Offset(-bb.Left, -bb.Bottom); + + return result; + } + } } diff --git a/OpenNest.Engine/NfpCache.cs b/OpenNest.Engine/NfpCache.cs new file mode 100644 index 0000000..bfcabeb --- /dev/null +++ b/OpenNest.Engine/NfpCache.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// Caches computed No-Fit Polygons keyed by (DrawingA.Id, RotationA, DrawingB.Id, RotationB). + /// NFPs are computed on first access and stored for reuse during optimization. + /// Thread-safe for concurrent reads after pre-computation. + /// + public class NfpCache + { + private readonly Dictionary cache = new Dictionary(); + private readonly Dictionary> polygonCache + = new Dictionary>(); + + /// + /// Registers a pre-computed polygon for a drawing at a specific rotation. + /// Call this during initialization before computing NFPs. + /// + public void RegisterPolygon(int drawingId, double rotation, Polygon polygon) + { + if (!polygonCache.TryGetValue(drawingId, out var rotations)) + { + rotations = new Dictionary(); + polygonCache[drawingId] = rotations; + } + + rotations[rotation] = polygon; + } + + /// + /// Gets the polygon for a drawing at a specific rotation. + /// + public Polygon GetPolygon(int drawingId, double rotation) + { + if (polygonCache.TryGetValue(drawingId, out var rotations)) + { + if (rotations.TryGetValue(rotation, out var polygon)) + return polygon; + } + + return null; + } + + /// + /// Gets or computes the NFP between two drawings at their respective rotations. + /// The NFP is computed from the stationary polygon (drawingA at rotationA) and + /// the orbiting polygon (drawingB at rotationB). + /// + public Polygon Get(int drawingIdA, double rotationA, int drawingIdB, double rotationB) + { + var key = new NfpKey(drawingIdA, rotationA, drawingIdB, rotationB); + + if (cache.TryGetValue(key, out var nfp)) + return nfp; + + var polyA = GetPolygon(drawingIdA, rotationA); + var polyB = GetPolygon(drawingIdB, rotationB); + + if (polyA == null || polyB == null) + return new Polygon(); + + nfp = NoFitPolygon.Compute(polyA, polyB); + cache[key] = nfp; + return nfp; + } + + /// + /// Pre-computes all NFPs for every combination of registered polygons. + /// Call after all polygons are registered to front-load computation. + /// + public void PreComputeAll() + { + var entries = new List<(int drawingId, double rotation)>(); + + foreach (var kvp in polygonCache) + { + foreach (var rot in kvp.Value) + entries.Add((kvp.Key, rot.Key)); + } + + for (var i = 0; i < entries.Count; i++) + { + for (var j = 0; j < entries.Count; j++) + { + Get(entries[i].drawingId, entries[i].rotation, + entries[j].drawingId, entries[j].rotation); + } + } + } + + /// + /// Number of cached NFP entries. + /// + public int Count => cache.Count; + + private readonly struct NfpKey : IEquatable + { + public readonly int DrawingIdA; + public readonly double RotationA; + public readonly int DrawingIdB; + public readonly double RotationB; + + public NfpKey(int drawingIdA, double rotationA, int drawingIdB, double rotationB) + { + DrawingIdA = drawingIdA; + RotationA = rotationA; + DrawingIdB = drawingIdB; + RotationB = rotationB; + } + + public bool Equals(NfpKey other) + { + return DrawingIdA == other.DrawingIdA + && RotationA == other.RotationA + && DrawingIdB == other.DrawingIdB + && RotationB == other.RotationB; + } + + public override bool Equals(object obj) => obj is NfpKey key && Equals(key); + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + DrawingIdA; + hash = hash * 31 + RotationA.GetHashCode(); + hash = hash * 31 + DrawingIdB; + hash = hash * 31 + RotationB.GetHashCode(); + return hash; + } + } + } + } +} diff --git a/OpenNest.Engine/SimulatedAnnealing.cs b/OpenNest.Engine/SimulatedAnnealing.cs new file mode 100644 index 0000000..d2dc4b9 --- /dev/null +++ b/OpenNest.Engine/SimulatedAnnealing.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// Simulated annealing optimizer for NFP-based nesting. + /// Searches for the best part ordering and rotation to maximize plate utilization. + /// + public class SimulatedAnnealing : INestOptimizer + { + private const double DefaultCoolingRate = 0.995; + private const double DefaultMinTemperature = 0.1; + private const int DefaultMaxNoImprovement = 500; + + public NestResult Optimize(List items, Box workArea, NfpCache cache, + Dictionary> candidateRotations, + CancellationToken cancellation = default) + { + var random = new Random(); + + // Build initial sequence: expand NestItems into individual (drawingId, rotation, drawing) entries, + // sorted by area descending. + var sequence = BuildInitialSequence(items, candidateRotations); + + if (sequence.Count == 0) + return new NestResult { Sequence = sequence, Score = default, Iterations = 0 }; + + // Evaluate initial solution. + var blf = new BottomLeftFill(workArea, cache); + var bestPlaced = blf.Fill(sequence); + var bestScore = FillScore.Compute(BottomLeftFill.ToNestParts(bestPlaced), workArea); + var bestSequence = new List<(int, double, Drawing)>(sequence); + + var currentSequence = new List<(int, double, Drawing)>(sequence); + var currentScore = bestScore; + + // Calibrate initial temperature so ~80% of worse moves are accepted. + var initialTemp = CalibrateTemperature(currentSequence, workArea, cache, + candidateRotations, random); + var temperature = initialTemp; + var noImprovement = 0; + var iteration = 0; + + Debug.WriteLine($"[SA] Initial: {bestScore.Count} parts, density={bestScore.Density:P1}, temp={initialTemp:F2}"); + + while (temperature > DefaultMinTemperature + && noImprovement < DefaultMaxNoImprovement + && !cancellation.IsCancellationRequested) + { + iteration++; + + var candidate = new List<(int drawingId, double rotation, Drawing drawing)>(currentSequence); + Mutate(candidate, candidateRotations, random); + + var candidatePlaced = blf.Fill(candidate); + var candidateScore = FillScore.Compute(BottomLeftFill.ToNestParts(candidatePlaced), workArea); + + var delta = candidateScore.CompareTo(currentScore); + + if (delta > 0) + { + // Better solution — always accept. + currentSequence = candidate; + currentScore = candidateScore; + + if (currentScore > bestScore) + { + bestScore = currentScore; + bestSequence = new List<(int, double, Drawing)>(currentSequence); + noImprovement = 0; + + Debug.WriteLine($"[SA] New best at iter {iteration}: {bestScore.Count} parts, density={bestScore.Density:P1}"); + } + else + { + noImprovement++; + } + } + else if (delta < 0) + { + // Worse solution — accept with probability based on temperature. + var scoreDiff = ScoreDifference(currentScore, candidateScore); + var acceptProb = System.Math.Exp(-scoreDiff / temperature); + + if (random.NextDouble() < acceptProb) + { + currentSequence = candidate; + currentScore = candidateScore; + } + + noImprovement++; + } + else + { + noImprovement++; + } + + temperature *= DefaultCoolingRate; + } + + Debug.WriteLine($"[SA] Done: {iteration} iters, best={bestScore.Count} parts, density={bestScore.Density:P1}"); + + return new NestResult + { + Sequence = bestSequence, + Score = bestScore, + Iterations = iteration + }; + } + + /// + /// Builds the initial placement sequence sorted by drawing area descending. + /// Each NestItem is expanded by its quantity. + /// + private static List<(int drawingId, double rotation, Drawing drawing)> BuildInitialSequence( + List items, Dictionary> candidateRotations) + { + var sequence = new List<(int drawingId, double rotation, Drawing drawing)>(); + + // Sort items by area descending. + var sorted = items.OrderByDescending(i => i.Drawing.Area).ToList(); + + foreach (var item in sorted) + { + var qty = item.Quantity > 0 ? item.Quantity : 1; + var rotation = 0.0; + + if (candidateRotations.TryGetValue(item.Drawing.Id, out var rotations) && rotations.Count > 0) + rotation = rotations[0]; + + for (var i = 0; i < qty; i++) + sequence.Add((item.Drawing.Id, rotation, item.Drawing)); + } + + return sequence; + } + + /// + /// Applies a random mutation to the sequence. + /// + private static void Mutate(List<(int drawingId, double rotation, Drawing drawing)> sequence, + Dictionary> candidateRotations, Random random) + { + if (sequence.Count < 2) + return; + + var op = random.Next(3); + + switch (op) + { + case 0: // Swap + MutateSwap(sequence, random); + break; + case 1: // Rotate + MutateRotate(sequence, candidateRotations, random); + break; + case 2: // Segment reverse + MutateReverse(sequence, random); + break; + } + } + + /// + /// Swaps two random parts in the sequence. + /// + private static void MutateSwap(List<(int, double, Drawing)> sequence, Random random) + { + var i = random.Next(sequence.Count); + var j = random.Next(sequence.Count); + + while (j == i && sequence.Count > 1) + j = random.Next(sequence.Count); + + (sequence[i], sequence[j]) = (sequence[j], sequence[i]); + } + + /// + /// Changes a random part's rotation to another candidate angle. + /// + private static void MutateRotate(List<(int drawingId, double rotation, Drawing drawing)> sequence, + Dictionary> candidateRotations, Random random) + { + var idx = random.Next(sequence.Count); + var entry = sequence[idx]; + + if (!candidateRotations.TryGetValue(entry.drawingId, out var rotations) || rotations.Count <= 1) + return; + + var newRotation = rotations[random.Next(rotations.Count)]; + sequence[idx] = (entry.drawingId, newRotation, entry.drawing); + } + + /// + /// Reverses a random contiguous subsequence. + /// + private static void MutateReverse(List<(int, double, Drawing)> sequence, Random random) + { + var i = random.Next(sequence.Count); + var j = random.Next(sequence.Count); + + if (i > j) + (i, j) = (j, i); + + while (i < j) + { + (sequence[i], sequence[j]) = (sequence[j], sequence[i]); + i++; + j--; + } + } + + /// + /// Calibrates the initial temperature by sampling random mutations and + /// measuring score differences. Sets temperature so ~80% of worse moves + /// are accepted initially. + /// + private static double CalibrateTemperature( + List<(int drawingId, double rotation, Drawing drawing)> sequence, + Box workArea, NfpCache cache, + Dictionary> candidateRotations, Random random) + { + const int samples = 20; + var deltas = new List(); + var blf = new BottomLeftFill(workArea, cache); + + var basePlaced = blf.Fill(sequence); + var baseScore = FillScore.Compute(BottomLeftFill.ToNestParts(basePlaced), workArea); + + for (var i = 0; i < samples; i++) + { + var candidate = new List<(int, double, Drawing)>(sequence); + Mutate(candidate, candidateRotations, random); + + var placed = blf.Fill(candidate); + var score = FillScore.Compute(BottomLeftFill.ToNestParts(placed), workArea); + + var diff = ScoreDifference(baseScore, score); + + if (diff > 0) + deltas.Add(diff); + } + + if (deltas.Count == 0) + return 1.0; + + // T = -avgDelta / ln(0.8) ≈ avgDelta * 4.48 + var avgDelta = deltas.Average(); + return -avgDelta / System.Math.Log(0.8); + } + + /// + /// Computes a numeric difference between two scores for SA acceptance probability. + /// Uses a weighted combination of count and density. + /// + private static double ScoreDifference(FillScore better, FillScore worse) + { + // Weight count heavily (each part is worth 10 density points). + var countDiff = better.Count - worse.Count; + var densityDiff = better.Density - worse.Density; + + return countDiff * 10.0 + densityDiff; + } + } +} diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 76cc3ca..cdd96ce 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Text; +using System.Threading; using ModelContextProtocol.Server; using OpenNest.Geometry; @@ -190,5 +191,62 @@ namespace OpenNest.Mcp.Tools return sb.ToString(); } + + [McpServerTool(Name = "autonest_plate")] + [Description("NFP-based mixed-part autonesting. Places multiple different drawings on a plate with geometry-aware collision avoidance and simulated annealing optimization. Produces tighter layouts than pack_plate by allowing parts to interlock.")] + public string AutoNestPlate( + [Description("Index of the plate")] int plateIndex, + [Description("Comma-separated drawing names")] string drawingNames, + [Description("Comma-separated quantities for each drawing")] string quantities) + { + var plate = _session.GetPlate(plateIndex); + if (plate == null) + return $"Error: plate {plateIndex} not found"; + + if (string.IsNullOrWhiteSpace(drawingNames)) + return "Error: drawingNames is required"; + + if (string.IsNullOrWhiteSpace(quantities)) + return "Error: quantities is required"; + + var names = drawingNames.Split(',').Select(n => n.Trim()).ToArray(); + var qtyStrings = quantities.Split(',').Select(q => q.Trim()).ToArray(); + var qtys = new int[qtyStrings.Length]; + + for (var i = 0; i < qtyStrings.Length; i++) + { + if (!int.TryParse(qtyStrings[i], out qtys[i])) + return $"Error: '{qtyStrings[i]}' is not a valid quantity"; + } + + if (names.Length != qtys.Length) + return $"Error: drawing names count ({names.Length}) does not match quantities count ({qtys.Length})"; + + var items = new List(); + + for (var i = 0; i < names.Length; i++) + { + var drawing = _session.GetDrawing(names[i]); + if (drawing == null) + return $"Error: drawing '{names[i]}' not found"; + + items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); + } + + var parts = NestEngine.AutoNest(items, plate); + plate.Parts.AddRange(parts); + + var sb = new StringBuilder(); + sb.AppendLine($"AutoNest plate {plateIndex}: {(parts.Count > 0 ? "success" : "no parts placed")}"); + sb.AppendLine($" Parts placed: {parts.Count}"); + sb.AppendLine($" Total parts: {plate.Parts.Count}"); + sb.AppendLine($" Utilization: {plate.Utilization():P1}"); + + var groups = parts.GroupBy(p => p.BaseDrawing.Name); + foreach (var group in groups) + sb.AppendLine($" {group.Key}: {group.Count()}"); + + return sb.ToString(); + } } } diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 5f948c3..d98568b 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -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(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; diff --git a/OpenNest/Forms/NestProgressForm.resx b/OpenNest/Forms/NestProgressForm.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/OpenNest/Forms/NestProgressForm.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file