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:
2026-03-13 09:40:38 -04:00
14 changed files with 1610 additions and 56 deletions

View File

@@ -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");

View File

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

View File

@@ -0,0 +1,154 @@
using System.Collections.Generic;
namespace OpenNest.Geometry
{
/// <summary>
/// Decomposes concave polygons into convex sub-polygons using ear-clipping
/// triangulation. Produces O(n-2) triangles per polygon.
/// </summary>
public static class ConvexDecomposition
{
/// <summary>
/// Decomposes a polygon into a list of convex triangles using ear-clipping.
/// The input polygon must be simple (non-self-intersecting).
/// Returns a list of triangles, each represented as a Polygon with 3 vertices (closed).
/// </summary>
public static List<Polygon> Triangulate(Polygon polygon)
{
var triangles = new List<Polygon>();
var verts = new List<Vector>(polygon.Vertices);
// Remove closing vertex if polygon is closed.
if (verts.Count > 1 && verts[0].X == verts[verts.Count - 1].X
&& verts[0].Y == verts[verts.Count - 1].Y)
verts.RemoveAt(verts.Count - 1);
if (verts.Count < 3)
return triangles;
// Ensure counter-clockwise winding for ear detection.
if (SignedArea(verts) < 0)
verts.Reverse();
// Build a linked list of vertex indices.
var indices = new List<int>(verts.Count);
for (var i = 0; i < verts.Count; i++)
indices.Add(i);
var n = indices.Count;
// Safety counter to avoid infinite loop on degenerate polygons.
var maxIterations = n * n;
var iterations = 0;
var i0 = 0;
while (n > 2 && iterations < maxIterations)
{
iterations++;
var prevIdx = (i0 + n - 1) % n;
var currIdx = i0 % n;
var nextIdx = (i0 + 1) % n;
var prev = verts[indices[prevIdx]];
var curr = verts[indices[currIdx]];
var next = verts[indices[nextIdx]];
if (IsEar(prev, curr, next, verts, indices, n))
{
var tri = new Polygon();
tri.Vertices.Add(prev);
tri.Vertices.Add(curr);
tri.Vertices.Add(next);
tri.Close();
triangles.Add(tri);
indices.RemoveAt(currIdx);
n--;
i0 = 0;
}
else
{
i0++;
if (i0 >= n)
i0 = 0;
}
}
return triangles;
}
/// <summary>
/// Tests whether the vertex at curr forms an ear (a convex vertex whose
/// triangle contains no other polygon vertices).
/// </summary>
private static bool IsEar(Vector prev, Vector curr, Vector next,
List<Vector> verts, List<int> indices, int n)
{
// Must be convex (CCW turn).
if (Cross(prev, curr, next) <= 0)
return false;
// Check that no other vertex lies inside the triangle.
for (var i = 0; i < n; i++)
{
var v = verts[indices[i]];
if (v.X == prev.X && v.Y == prev.Y)
continue;
if (v.X == curr.X && v.Y == curr.Y)
continue;
if (v.X == next.X && v.Y == next.Y)
continue;
if (PointInTriangle(v, prev, curr, next))
return false;
}
return true;
}
/// <summary>
/// Returns positive value if A→B→C is a CCW (left) turn.
/// </summary>
internal static double Cross(Vector a, Vector b, Vector c)
{
return (b.X - a.X) * (c.Y - a.Y) - (b.Y - a.Y) * (c.X - a.X);
}
/// <summary>
/// Returns true if point p is strictly inside triangle (a, b, c).
/// Assumes CCW winding.
/// </summary>
private static bool PointInTriangle(Vector p, Vector a, Vector b, Vector c)
{
var d1 = Cross(a, b, p);
var d2 = Cross(b, c, p);
var d3 = Cross(c, a, p);
var hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
var hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(hasNeg && hasPos);
}
/// <summary>
/// Signed area of a polygon. Positive = CCW, negative = CW.
/// </summary>
private static double SignedArea(List<Vector> verts)
{
var area = 0.0;
for (var i = 0; i < verts.Count; i++)
{
var j = (i + 1) % verts.Count;
area += verts[i].X * verts[j].Y;
area -= verts[j].X * verts[i].Y;
}
return area * 0.5;
}
}
}

View File

@@ -0,0 +1,144 @@
using Clipper2Lib;
namespace OpenNest.Geometry
{
/// <summary>
/// Computes the Inner-Fit Polygon (IFP) — the feasible region where a part's
/// reference point can be placed so the part stays entirely within the plate boundary.
/// For a rectangular plate, the IFP is the plate shrunk by the part's bounding dimensions.
/// </summary>
public static class InnerFitPolygon
{
/// <summary>
/// Computes the IFP for placing a part polygon inside a rectangular work area.
/// The result is a polygon representing all valid reference point positions.
/// </summary>
public static Polygon Compute(Box workArea, Polygon partPolygon)
{
// Get the part's bounding box relative to its reference point (origin).
var verts = partPolygon.Vertices;
if (verts.Count < 3)
return new Polygon();
var minX = verts[0].X;
var maxX = verts[0].X;
var minY = verts[0].Y;
var maxY = verts[0].Y;
for (var i = 1; i < verts.Count; i++)
{
if (verts[i].X < minX) minX = verts[i].X;
if (verts[i].X > maxX) maxX = verts[i].X;
if (verts[i].Y < minY) minY = verts[i].Y;
if (verts[i].Y > maxY) maxY = verts[i].Y;
}
// The IFP is the work area shrunk inward by the part's extent in each direction.
// The reference point can range from (workArea.Left - minX) to (workArea.Right - maxX)
// and (workArea.Bottom - minY) to (workArea.Top - maxY).
var ifpLeft = workArea.X - minX;
var ifpRight = workArea.Right - maxX;
var ifpBottom = workArea.Y - minY;
var ifpTop = workArea.Top - maxY;
// If the part doesn't fit, return an empty polygon.
if (ifpRight < ifpLeft || ifpTop < ifpBottom)
return new Polygon();
var result = new Polygon();
result.Vertices.Add(new Vector(ifpLeft, ifpBottom));
result.Vertices.Add(new Vector(ifpRight, ifpBottom));
result.Vertices.Add(new Vector(ifpRight, ifpTop));
result.Vertices.Add(new Vector(ifpLeft, ifpTop));
result.Close();
return result;
}
/// <summary>
/// Computes the feasible region for placing a part given already-placed parts.
/// FeasibleRegion = IFP(plate, part) - union(NFP(placed_i, part))
/// Returns the polygon representing valid placement positions, or an empty
/// polygon if no valid position exists.
/// </summary>
public static Polygon ComputeFeasibleRegion(Polygon ifp, Polygon[] nfps)
{
if (ifp.Vertices.Count < 3)
return new Polygon();
if (nfps == null || nfps.Length == 0)
return ifp;
var ifpPath = NoFitPolygon.ToClipperPath(ifp);
var ifpPaths = new PathsD { ifpPath };
// Union all NFPs.
var nfpPaths = new PathsD();
foreach (var nfp in nfps)
{
if (nfp.Vertices.Count >= 3)
{
var path = NoFitPolygon.ToClipperPath(nfp);
nfpPaths.Add(path);
}
}
if (nfpPaths.Count == 0)
return ifp;
var nfpUnion = Clipper.Union(nfpPaths, FillRule.NonZero);
// Subtract the NFP union from the IFP.
var feasible = Clipper.Difference(ifpPaths, nfpUnion, FillRule.NonZero);
if (feasible.Count == 0)
return new Polygon();
// Find the polygon with the bottom-left-most point.
// This ensures we pick the correct region for placement.
PathD bestPath = null;
var bestY = double.MaxValue;
var bestX = double.MaxValue;
foreach (var path in feasible)
{
foreach (var pt in path)
{
if (pt.y < bestY || (pt.y == bestY && pt.x < bestX))
{
bestY = pt.y;
bestX = pt.x;
bestPath = path;
}
}
}
return bestPath != null ? NoFitPolygon.FromClipperPath(bestPath) : new Polygon();
}
/// <summary>
/// Finds the bottom-left-most point on a polygon boundary.
/// "Bottom-left" means: minimize Y first, then minimize X.
/// Returns Vector.Invalid if the polygon has no vertices.
/// </summary>
public static Vector FindBottomLeftPoint(Polygon polygon)
{
if (polygon.Vertices.Count == 0)
return Vector.Invalid;
var best = polygon.Vertices[0];
for (var i = 1; i < polygon.Vertices.Count; i++)
{
var v = polygon.Vertices[i];
if (v.Y < best.Y || (v.Y == best.Y && v.X < best.X))
best = v;
}
return best;
}
}
}

View File

@@ -0,0 +1,286 @@
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
namespace OpenNest.Geometry
{
/// <summary>
/// Computes the No-Fit Polygon (NFP) between two polygons.
/// The NFP defines all positions where the orbiting polygon's reference point
/// would cause overlap with the stationary polygon.
/// </summary>
public static class NoFitPolygon
{
private const double ClipperScale = 1000.0;
/// <summary>
/// Computes the NFP between a stationary polygon A and an orbiting polygon B.
/// NFP(A, B) = Minkowski sum of A and -B (B reflected through its reference point).
/// </summary>
public static Polygon Compute(Polygon stationary, Polygon orbiting)
{
var reflected = Reflect(orbiting);
return MinkowskiSum(stationary, reflected);
}
/// <summary>
/// Reflects a polygon through the origin (negates all vertex coordinates).
/// </summary>
private static Polygon Reflect(Polygon polygon)
{
var result = new Polygon();
foreach (var v in polygon.Vertices)
result.Vertices.Add(new Vector(-v.X, -v.Y));
// Reflecting reverses winding order — reverse to maintain CCW.
result.Vertices.Reverse();
return result;
}
/// <summary>
/// Computes the Minkowski sum of two polygons using convex decomposition.
/// For convex polygons, uses the direct O(n+m) merge-sort of edge vectors.
/// For concave polygons, decomposes into triangles, computes pairwise
/// convex Minkowski sums, and unions the results with Clipper2.
/// </summary>
private static Polygon MinkowskiSum(Polygon a, Polygon b)
{
var trisA = ConvexDecomposition.Triangulate(a);
var trisB = ConvexDecomposition.Triangulate(b);
if (trisA.Count == 0 || trisB.Count == 0)
return new Polygon();
var partialSums = new List<Polygon>();
foreach (var ta in trisA)
{
foreach (var tb in trisB)
{
var sum = ConvexMinkowskiSum(ta, tb);
if (sum.Vertices.Count >= 3)
partialSums.Add(sum);
}
}
if (partialSums.Count == 0)
return new Polygon();
if (partialSums.Count == 1)
return partialSums[0];
return UnionPolygons(partialSums);
}
/// <summary>
/// Computes the Minkowski sum of two convex polygons by merging their
/// edge vectors sorted by angle. O(n+m) where n and m are vertex counts.
/// Both polygons must have CCW winding.
/// </summary>
internal static Polygon ConvexMinkowskiSum(Polygon a, Polygon b)
{
var edgesA = GetEdgeVectors(a);
var edgesB = GetEdgeVectors(b);
// Find bottom-most (then left-most) vertex for each polygon as starting point.
var startA = FindBottomLeft(a);
var startB = FindBottomLeft(b);
var result = new Polygon();
var current = new Vector(
a.Vertices[startA].X + b.Vertices[startB].X,
a.Vertices[startA].Y + b.Vertices[startB].Y);
result.Vertices.Add(current);
var ia = 0;
var ib = 0;
var na = edgesA.Count;
var nb = edgesB.Count;
// Reorder edges to start from the bottom-left vertex.
var orderedA = ReorderEdges(edgesA, startA);
var orderedB = ReorderEdges(edgesB, startB);
while (ia < na || ib < nb)
{
Vector edge;
if (ia >= na)
{
edge = orderedB[ib++];
}
else if (ib >= nb)
{
edge = orderedA[ia++];
}
else
{
var angleA = System.Math.Atan2(orderedA[ia].Y, orderedA[ia].X);
var angleB = System.Math.Atan2(orderedB[ib].Y, orderedB[ib].X);
if (angleA < angleB)
{
edge = orderedA[ia++];
}
else if (angleB < angleA)
{
edge = orderedB[ib++];
}
else
{
// Same angle — merge both edges.
edge = new Vector(
orderedA[ia].X + orderedB[ib].X,
orderedA[ia].Y + orderedB[ib].Y);
ia++;
ib++;
}
}
current = new Vector(current.X + edge.X, current.Y + edge.Y);
result.Vertices.Add(current);
}
result.Close();
return result;
}
/// <summary>
/// Gets edge vectors for a polygon (each edge as a direction vector).
/// Assumes the polygon is closed (last vertex == first vertex) or handles open polygons.
/// </summary>
private static List<Vector> GetEdgeVectors(Polygon polygon)
{
var verts = polygon.Vertices;
var n = verts.Count;
// If closed, skip last duplicate vertex.
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
n--;
var edges = new List<Vector>(n);
for (var i = 0; i < n; i++)
{
var next = (i + 1) % n;
edges.Add(new Vector(verts[next].X - verts[i].X, verts[next].Y - verts[i].Y));
}
return edges;
}
/// <summary>
/// Finds the index of the bottom-most (then left-most) vertex.
/// </summary>
private static int FindBottomLeft(Polygon polygon)
{
var verts = polygon.Vertices;
var n = verts.Count;
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
n--;
var best = 0;
for (var i = 1; i < n; i++)
{
if (verts[i].Y < verts[best].Y ||
(verts[i].Y == verts[best].Y && verts[i].X < verts[best].X))
best = i;
}
return best;
}
/// <summary>
/// Reorders edge vectors to start from the given vertex index.
/// </summary>
private static List<Vector> ReorderEdges(List<Vector> edges, int startIndex)
{
var n = edges.Count;
var result = new List<Vector>(n);
for (var i = 0; i < n; i++)
result.Add(edges[(startIndex + i) % n]);
return result;
}
/// <summary>
/// Unions multiple polygons using Clipper2.
/// Returns the outer boundary of the union as a single polygon.
/// </summary>
internal static Polygon UnionPolygons(List<Polygon> polygons)
{
var paths = new PathsD();
foreach (var poly in polygons)
{
var path = ToClipperPath(poly);
if (path.Count >= 3)
paths.Add(path);
}
if (paths.Count == 0)
return new Polygon();
var result = Clipper.Union(paths, FillRule.NonZero);
if (result.Count == 0)
return new Polygon();
// Find the largest polygon (by area) as the outer boundary.
var largest = result[0];
var largestArea = System.Math.Abs(Clipper.Area(largest));
for (var i = 1; i < result.Count; i++)
{
var area = System.Math.Abs(Clipper.Area(result[i]));
if (area > largestArea)
{
largest = result[i];
largestArea = area;
}
}
return FromClipperPath(largest);
}
/// <summary>
/// Converts an OpenNest Polygon to a Clipper2 PathD.
/// </summary>
internal static PathD ToClipperPath(Polygon polygon)
{
var path = new PathD();
var verts = polygon.Vertices;
var n = verts.Count;
// Skip closing vertex if present.
if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
n--;
for (var i = 0; i < n; i++)
path.Add(new PointD(verts[i].X, verts[i].Y));
return path;
}
/// <summary>
/// Converts a Clipper2 PathD to an OpenNest Polygon.
/// </summary>
internal static Polygon FromClipperPath(PathD path)
{
var polygon = new Polygon();
foreach (var pt in path)
polygon.Vertices.Add(new Vector(pt.x, pt.y));
polygon.Close();
return polygon;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

@@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// Simulated annealing optimizer for NFP-based nesting.
/// Searches for the best part ordering and rotation to maximize plate utilization.
/// </summary>
public class SimulatedAnnealing : INestOptimizer
{
private const double DefaultCoolingRate = 0.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;
}
}
}

View File

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

View File

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

View 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>