feat(qwen38flashnext): add the finished engine

Qwen3.8-Flash-Next's final version after a 14.5-hour optimization run
(its commit 7d7fca3): cost-first sheet trials, largest-area-first
demand order, and a cached-triangulation exact gate that brought a
219-part production job from timeout to ~106 s. 13/13 tests pass
against OpenNest master.

README cleaned for publishing: the model-facing template rules are
replaced by a one-line independence statement, the production job is
described generically instead of by its PEP job/file name (also in a
JobSolver comment), results show both sheet pools as re-measured here
(the 9-size claim in its report didn't reproduce: it grabs 96x240 and
under-fills them), and the stale StockLadder-crash note is gone now
that core leaves etch marks out of nesting.

Also drops a stale Aurora plugin reference from Opus55's README and
lists the engine in the repo README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 06:57:11 -04:00
co-authored by Claude Opus 5.5
parent ffd0187fbb
commit 90f07603e2
14 changed files with 3785 additions and 2 deletions
@@ -0,0 +1,544 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
using Math = System.Math;
/// <summary>
/// A closed polygon pre-triangulated into flat arrays for allocation-free overlap
/// tests. The ear-clip of <see cref="ConvexDecomposition"/> runs ONCE per
/// (shape, orientation); per-pair tests then clip cached triangles directly. The
/// built-in <see cref="Collision"/> gate re-triangulates both polygons per call and
/// allocates a Polygon per clipped region - at the engine's fine collision
/// flattening (thousands of edges) that dominated solve time.
/// <para>
/// Overlap semantics replicate <see cref="Collision.Check"/> exactly: triangle-pair
/// half-space clipping (same >=0 inside test, same strict-crossing interpolation,
/// same dedupe), the same 2 * Tolerance.Epsilon twice-area floor measured from
/// vertex 0, then per-edge outside-piece hole subtraction from both polygons' hole
/// sets. Translation is a parameter, so moving a part to a candidate anchor copies
/// nothing. When geometry exceeds the scratch bounds the test returns null ("cannot
/// decide") and the caller must fall back to the Polygon gate - never a guess.
/// </para>
/// </summary>
internal sealed class TriSet
{
// Flat vertex pool (local frame) and triangle index triples (CCW).
public readonly double[] X;
public readonly double[] Y;
private readonly int[] _ia;
private readonly int[] _ib;
private readonly int[] _ic;
private readonly double[] _tMinX;
private readonly double[] _tMinY;
private readonly double[] _tMaxX;
private readonly double[] _tMaxY;
public double MinX { get; }
public double MinY { get; }
public double MaxX { get; }
public double MaxY { get; }
/// <summary>Triangulated holes in the same local frame (empty array when none).</summary>
public readonly TriSet[] Holes;
// Scratch bound: clipped convex pieces stay small; anything larger bails.
private const int MaxClipVertices = 48;
private const int MaxPieces = 2048;
private TriSet(
double[] x,
double[] y,
int[] ia,
int[] ib,
int[] ic,
double[] tMinX,
double[] tMinY,
double[] tMaxX,
double[] tMaxY,
TriSet[] holes
)
{
X = x;
Y = y;
_ia = ia;
_ib = ib;
_ic = ic;
_tMinX = tMinX;
_tMinY = tMinY;
_tMaxX = tMaxX;
_tMaxY = tMaxY;
Holes = holes;
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
for (var i = 0; i < x.Length; i++)
{
if (x[i] < minX)
minX = x[i];
if (x[i] > maxX)
maxX = x[i];
if (y[i] < minY)
minY = y[i];
if (y[i] > maxY)
maxY = y[i];
}
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
}
/// <summary>
/// Ear-clips a polygon ring into cached triangles. Returns null when
/// triangulation yields nothing usable - the caller falls back to Polygon gates.
/// </summary>
public static TriSet? Build(Polygon polygon, IReadOnlyList<Polygon>? holes = null)
{
try
{
var tris = ConvexDecomposition.Triangulate(polygon);
var count = tris.Count;
if (count == 0)
return null;
var xs = new double[count * 3];
var ys = new double[count * 3];
var ia = new int[count];
var ib = new int[count];
var ic = new int[count];
var minXA = new double[count];
var minYA = new double[count];
var maxXA = new double[count];
var maxYA = new double[count];
var k = 0;
for (var t = 0; t < count; t++)
{
var v = tris[t].Vertices; // closed: prev, curr, next, prev
ia[t] = k;
xs[k] = v[0].X;
ys[k] = v[0].Y;
k++;
ib[t] = k;
xs[k] = v[1].X;
ys[k] = v[1].Y;
k++;
ic[t] = k;
xs[k] = v[2].X;
ys[k] = v[2].Y;
k++;
minXA[t] = Math.Min(v[0].X, Math.Min(v[1].X, v[2].X));
minYA[t] = Math.Min(v[0].Y, Math.Min(v[1].Y, v[2].Y));
maxXA[t] = Math.Max(v[0].X, Math.Max(v[1].X, v[2].X));
maxYA[t] = Math.Max(v[0].Y, Math.Max(v[1].Y, v[2].Y));
}
TriSet[]? holeSets = null;
if (holes != null && holes.Count > 0)
{
holeSets = new TriSet[holes.Count];
for (var h = 0; h < holes.Count; h++)
{
var holeTris = ConvexDecomposition.Triangulate(holes[h]);
if (holeTris.Count == 0)
continue;
var hx = new double[holeTris.Count * 3];
var hy = new double[holeTris.Count * 3];
var hia = new int[holeTris.Count];
var hib = new int[holeTris.Count];
var hic = new int[holeTris.Count];
var hminX = new double[holeTris.Count];
var hminY = new double[holeTris.Count];
var hmaxX = new double[holeTris.Count];
var hmaxY = new double[holeTris.Count];
var hk = 0;
for (var t = 0; t < holeTris.Count; t++)
{
var v = holeTris[t].Vertices;
hia[t] = hk;
hx[hk] = v[0].X;
hy[hk] = v[0].Y;
hk++;
hib[t] = hk;
hx[hk] = v[1].X;
hy[hk] = v[1].Y;
hk++;
hic[t] = hk;
hx[hk] = v[2].X;
hy[hk] = v[2].Y;
hk++;
hminX[t] = Math.Min(v[0].X, Math.Min(v[1].X, v[2].X));
hminY[t] = Math.Min(v[0].Y, Math.Min(v[1].Y, v[2].Y));
hmaxX[t] = Math.Max(v[0].X, Math.Max(v[1].X, v[2].X));
hmaxY[t] = Math.Max(v[0].Y, Math.Max(v[1].Y, v[2].Y));
}
holeSets[h] = new TriSet(hx, hy, hia, hib, hic, hminX, hminY, hmaxX, hmaxY, null);
}
}
return new TriSet(xs, ys, ia, ib, ic, minXA, minYA, maxXA, maxYA, holeSets);
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Positive shared area (surviving both polygons' hole sets) between this
/// translated by (adx, ady) and other translated by (bdx, bdy). Returns null
/// when the scratch bounds are exceeded and the question cannot be decided.
/// </summary>
public bool? HasOverlap(TriSet other, double adx, double ady, double bdx, double bdy)
{
// Same bbox rule as Collision.BoundingBoxesOverlap: overlap must exceed
// Tolerance.Epsilon on both axes, so a hairline box overlap never reaches the
// clip stage.
var eps = OpenNest.Math.Tolerance.Epsilon;
var overlapX =
Math.Min(MaxX + adx, other.MaxX + bdx) - Math.Max(MinX + adx, other.MinX + bdx);
var overlapY =
Math.Min(MaxY + ady, other.MaxY + bdy) - Math.Max(MinY + ady, other.MinY + bdy);
if (overlapX <= eps || overlapY <= eps)
return false;
var areaFloor = 2 * OpenNest.Math.Tolerance.Epsilon;
var clipA = new double[MaxClipVertices * 2];
var clipB = new double[MaxClipVertices * 2];
var piece = new double[MaxClipVertices * 2];
for (var ta = 0; ta < _ia.Length; ta++)
{
var aMinX = _tMinX[ta] + adx;
var aMaxX = _tMaxX[ta] + adx;
var aMinY = _tMinY[ta] + ady;
var aMaxY = _tMaxY[ta] + ady;
for (var tb = 0; tb < other._ia.Length; tb++)
{
var bMinX = other._tMinX[tb] + bdx;
var bMaxX = other._tMaxX[tb] + bdx;
var bMinY = other._tMinY[tb] + bdy;
var bMaxY = other._tMaxY[tb] + bdy;
if (
Math.Min(aMaxX, bMaxX) - Math.Max(aMinX, bMinX) <= eps
|| Math.Min(aMaxY, bMaxY) - Math.Max(aMinY, bMinY) <= eps
)
continue;
var count = ClipTriangle(
ta, adx, ady, other, tb, bdx, bdy, clipA, clipB, piece
);
if (count < 3 || count >= MaxClipVertices)
continue;
if (TwiceArea(piece, count) <= areaFloor)
continue;
var (hasHoles, undecided, survived) = SubtractAllHoles(
other, adx, ady, bdx, bdy, piece, count, areaFloor
);
if (undecided)
return null;
if (hasHoles)
{
if (survived)
return true;
}
else
{
return true; // no holes on either side: the clipped region is overlap
}
}
}
return false;
}
/// <summary>
/// Subtracts both polygons' hole triangles from one clipped region, mirroring
/// Collision.SubtractHoles: for every hole triangle, every surviving piece is
/// split per edge into outside pieces (survivors) and the inside remainder
/// (consumed). True means a positive-area piece survived ALL holes.
/// </summary>
[ThreadStatic]
private static List<double[]>? s_pool;
[ThreadStatic]
private static double[]? s_tmpA;
[ThreadStatic]
private static double[]? s_tmpB;
private static double[] AcquireBuffer()
{
var pool = s_pool ??= new List<double[]>();
var n = pool.Count;
if (n == 0)
return new double[MaxClipVertices * 2];
var buf = pool[n - 1];
pool.RemoveAt(n - 1);
return buf;
}
private static void ReleaseBuffer(double[] buf)
{
var pool = s_pool ??= new List<double[]>();
if (pool.Count < 64)
pool.Add(buf);
}
private static (double[] Tmp, double[] Inside) ScratchPair()
{
s_tmpA ??= new double[MaxClipVertices * 2];
s_tmpB ??= new double[MaxClipVertices * 2];
return (s_tmpA, s_tmpB);
}
private (bool hasHoles, bool undecided, bool survived) SubtractAllHoles(
TriSet other,
double adx,
double ady,
double bdx,
double bdy,
double[] piece,
int count,
double areaFloor
)
{
var allHoles = 0;
if (Holes != null)
allHoles += Holes.Length;
if (other.Holes != null)
allHoles += other.Holes.Length;
if (allHoles == 0)
return (false, false, false);
// pieces[0] is the caller's own buffer - never release it back to the pool.
var pieces = new List<(double[] Buf, int Count)> { (piece, count) };
var owned = new HashSet<double[]>();
bool SubtractOwner(TriSet owner, double odx, double ody)
{
if (owner.Holes == null)
return true;
for (var h = 0; h < owner.Holes.Length && pieces.Count > 0; h++)
{
var hole = owner.Holes[h];
if (hole == null)
continue; // untriangulatable hole: nothing to subtract
for (var t = 0; t < hole._ia.Length && pieces.Count > 0; t++)
{
var hMinX = hole._tMinX[t] + odx;
var hMaxX = hole._tMaxX[t] + odx;
var hMinY = hole._tMinY[t] + ody;
var hMaxY = hole._tMaxY[t] + ody;
var next = new List<(double[], int)>();
for (var p = 0; p < pieces.Count; p++)
{
var (buf, pc) = pieces[p];
// Piece bbox (built-in uses <=: touching skips subtraction).
var pMinX = double.MaxValue;
var pMinY = double.MaxValue;
var pMaxX = double.MinValue;
var pMaxY = double.MinValue;
for (var v = 0; v < pc; v++)
{
var px = buf[v * 2];
var py = buf[v * 2 + 1];
if (px < pMinX)
pMinX = px;
if (px > pMaxX)
pMaxX = px;
if (py < pMinY)
pMinY = py;
if (py > pMaxY)
pMaxY = py;
}
if (pMaxX <= hMinX || hMaxX <= pMinX || pMaxY <= hMinY || hMaxY <= pMinY)
{
next.Add((buf, pc));
continue;
}
// Clip the piece against the hole triangle's three edges: the
// outside of each edge survives as its own piece; the inside
// remainder continues into the next edge. The remainder inside
// all three edges is consumed (the hole ate it).
var rem = AcquireBuffer();
Array.Copy(buf, rem, pc * 2);
var remCount = pc;
var (tmp, insideBuf) = ScratchPair();
for (var e = 0; e < 3 && remCount >= 3; e++)
{
var ei = e == 0 ? hole._ia[t] : e == 1 ? hole._ib[t] : hole._ic[t];
var ej = e == 0 ? hole._ib[t] : e == 1 ? hole._ic[t] : hole._ia[t];
var sx = hole.X[ei] + odx;
var sy = hole.Y[ei] + ody;
var ex = hole.X[ej] + odx;
var ey = hole.Y[ej] + ody;
var outCount =
ClipHalfSpace(rem, remCount, sx, sy, ex, ey, false, tmp);
if (outCount >= 3 && TwiceArea(tmp, outCount) > areaFloor)
{
if (next.Count >= MaxPieces)
return false; // undecided
var keep = AcquireBuffer();
owned.Add(keep);
Array.Copy(tmp, keep, outCount * 2);
next.Add((keep, outCount));
}
remCount =
ClipHalfSpace(rem, remCount, sx, sy, ex, ey, true, insideBuf);
if (remCount >= MaxClipVertices)
return false; // undecided
Array.Copy(insideBuf, rem, remCount * 2);
}
// The inside-all-edges remainder is consumed by the hole: drop it.
ReleaseBuffer(rem);
if (owned.Remove(buf))
ReleaseBuffer(buf);
}
pieces = next;
}
}
return true;
}
if (!SubtractOwner(this, adx, ady) || !SubtractOwner(other, bdx, bdy))
return (true, true, false);
foreach (var (buf, pc) in pieces)
if (pc >= 3 && TwiceArea(buf, pc) > areaFloor)
return (true, false, true);
return (true, false, false);
}
private static IEnumerable<(double[] Buf, int Count)> Enumerate(
List<double[]> bufs,
List<int> counts
)
{
for (var i = 0; i < bufs.Count; i++)
yield return (bufs[i], counts[i]);
}
/// <summary>Clip this' triangle against other's triangle; returns count into piece.</summary>
private int ClipTriangle(
int ta,
double adx,
double ady,
TriSet other,
int tb,
double bdx,
double bdy,
double[] bufA,
double[] bufB,
double[] piece
)
{
var ia = _ia[ta];
var ib = _ib[ta];
var ic = _ic[ta];
bufA[0] = X[ia] + adx;
bufA[1] = Y[ia] + ady;
bufA[2] = X[ib] + adx;
bufA[3] = Y[ib] + ady;
bufA[4] = X[ic] + adx;
bufA[5] = Y[ic] + ady;
var count = 3;
for (var e = 0; e < 3 && count >= 3; e++)
{
var ei = e == 0 ? other._ia[tb] : e == 1 ? other._ib[tb] : other._ic[tb];
var ej = e == 0 ? other._ib[tb] : e == 1 ? other._ic[tb] : other._ia[tb];
var sx = other.X[ei] + bdx;
var sy = other.Y[ei] + bdy;
var ex = other.X[ej] + bdx;
var ey = other.Y[ej] + bdy;
count = ClipHalfSpace(bufA, count, sx, sy, ex, ey, true, bufB);
if (count >= MaxClipVertices)
return count;
for (var v = 0; v < count * 2; v++)
bufA[v] = bufB[v];
}
for (var v = 0; v < Math.Min(count, MaxClipVertices) * 2; v++)
piece[v] = bufA[v];
return count;
}
/// <summary>
/// Sutherland-Hodgman clip against one directed edge's half-plane; identical
/// classification, interpolation and dedupe to Collision.ClipHalfSpace.
/// </summary>
private static int ClipHalfSpace(
double[] verts,
int count,
double sx,
double sy,
double ex,
double ey,
bool inside,
double[] outBuf
)
{
var kept = 0;
var cap = outBuf.Length / 2;
var edgeX = ex - sx;
var edgeY = ey - sy;
for (var i = 0; i < count; i++)
{
var j = (i + 1) % count;
var cx = verts[i * 2];
var cy = verts[i * 2 + 1];
var nx = verts[j * 2];
var ny = verts[j * 2 + 1];
var cd = edgeX * (cy - sy) - edgeY * (cx - sx);
var nd = edgeX * (ny - sy) - edgeY * (nx - sx);
if (inside ? cd >= 0 : cd <= 0)
{
if (kept >= cap)
return cap; // overflow: caller treats as undecided
kept = AddDistinct(outBuf, kept, cx, cy);
}
if ((cd < 0 && nd > 0) || (cd > 0 && nd < 0))
{
if (kept >= cap)
return cap; // overflow
var t = cd / (cd - nd);
kept = AddDistinct(
outBuf, kept, cx + t * (nx - cx), cy + t * (ny - cy)
);
}
}
if (kept > 1 && outBuf[0] == outBuf[(kept - 1) * 2] && outBuf[1] == outBuf[(kept - 1) * 2 + 1])
kept--;
return kept;
}
private static int AddDistinct(double[] buf, int count, double x, double y)
{
if (count > 0 && buf[(count - 1) * 2] == x && buf[(count - 1) * 2 + 1] == y)
return count;
buf[count * 2] = x;
buf[count * 2 + 1] = y;
return count + 1;
}
/// <summary>Twice the area, relative to vertex 0 (cancellation-safe).</summary>
private static double TwiceArea(double[] verts, int count)
{
var twiceArea = 0.0;
for (var i = 1; i + 1 < count; i++)
twiceArea +=
(verts[i * 2] - verts[0]) * (verts[(i + 1) * 2 + 1] - verts[1])
- (verts[i * 2 + 1] - verts[1]) * (verts[(i + 1) * 2] - verts[0]);
return Math.Abs(twiceArea);
}
}
@@ -0,0 +1,383 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
using Math = System.Math;
/// <summary>
/// Axis-aligned bounding box with no allocation and inclusive intersection tests.
/// </summary>
internal readonly struct Bounds
{
public Bounds(double minX, double minY, double maxX, double maxY)
{
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
}
public double MinX { get; }
public double MinY { get; }
public double MaxX { get; }
public double MaxY { get; }
public bool Intersects(in Bounds other, double margin = 0) =>
other.MinX <= MaxX + margin
&& MinX <= other.MaxX + margin
&& other.MinY <= MaxY + margin
&& MinY <= other.MaxY + margin;
}
/// <summary>
/// A convex contour as flat coordinate arrays (closed: last point != first), with
/// O(log n) strict-inside and exact vertical/horizontal span queries. This is the
/// engine's own working representation for No-Fit-Polygon geometry; nothing here is
/// shared with the built-in nesters.
/// </summary>
internal sealed class ConvexContour
{
// Numerical inset: points within this depth of the boundary count as outside, so a
// placement resting on the NFP (hull contact) is accepted.
public const double Surface = 1e-6;
private readonly double[] _x;
private readonly double[] _y;
private ConvexContour(double[] x, double[] y, Bounds bounds)
{
_x = x;
_y = y;
Bounds = bounds;
_ = FindStart();
}
public Bounds Bounds { get; }
public int Count => _x.Length;
/// <summary>Index of the lexicographic (Y, X) minimum vertex.</summary>
public int Start { get; private set; }
public double X(int i) => _x[i];
public double Y(int i) => _y[i];
public static ConvexContour FromVertices(IList<Vector> points)
{
var n = points.Count;
if (n > 1 && points[0].Equals(points[n - 1]))
n--;
if (n < 3)
throw new ArgumentException("Convex contour needs at least three vertices.");
var x = new double[n];
var y = new double[n];
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
for (var i = 0; i < n; i++)
{
x[i] = points[i].X;
y[i] = points[i].Y;
if (x[i] < minX)
minX = x[i];
if (x[i] > maxX)
maxX = x[i];
if (y[i] < minY)
minY = y[i];
if (y[i] > maxY)
maxY = y[i];
}
return new ConvexContour(x, y, new Bounds(minX, minY, maxX, maxY));
}
/// <summary>Regular 2^k-gon approximating a disk of the given radius (convex CCW).</summary>
public static ConvexContour Disk(double radius, int segments = 32)
{
var x = new double[segments];
var y = new double[segments];
for (var i = 0; i < segments; i++)
{
var angle = 2 * Math.PI * i / segments;
x[i] = radius * Math.Cos(angle);
y[i] = radius * Math.Sin(angle);
}
return new ConvexContour(x, y, new Bounds(-radius, -radius, radius, radius));
}
public ConvexContour Translated(double dx, double dy)
{
var n = _x.Length;
var x = new double[n];
var y = new double[n];
for (var i = 0; i < n; i++)
{
x[i] = _x[i] + dx;
y[i] = _y[i] + dy;
}
return new ConvexContour(x, y, new Bounds(Bounds.MinX + dx, Bounds.MinY + dy, Bounds.MaxX + dx, Bounds.MaxY + dy));
}
public double MinX => Bounds.MinX;
public double MinY => Bounds.MinY;
public double MaxX => Bounds.MaxX;
public double MaxY => Bounds.MaxY;
/// <summary>
/// Containment with a <see cref="Surface"/> band: points strictly outside return
/// false; points inside - OR within the band of an edge - return true, so anchors
/// resting on the NFP (the usual corner-candidate case) fall through to the exact
/// material gate instead of being certified by the fast path. The inset may never
/// exceed the circumscribed spacing disk's chord slack (Disk radius r/cos(pi/24)),
/// so a hull contact that still clears the true spacing passes the gate.
/// </summary>
public bool ContainsPoint(double px, double py)
{
var n = _x.Length;
var sx = _x[Start];
var sy = _y[Start];
// Polar-angle wedge from the start vertex (CCW order: first -> last).
var first = Mod(Start + 1, n);
var last = Mod(Start - 1, n);
var head = Cross(sx, sy, _x[first], _y[first], px, py);
if (head < -Surface)
return false;
var tail = Cross(sx, sy, _x[last], _y[last], px, py);
if (tail > Surface)
return false;
// Within the band of the two wedge rays: conservative inside.
if (head <= Surface || tail >= -Surface)
return true;
// Binary search for the fan triangle (start, vk, vk+1) bracketing the ray
// start->p; vk is CCW-ordered so polar angle rises monotonically first->last.
var lo = 0; // offset (from first) of the last vertex at-or-before p's angle
var hi = n - 2; // offset of last
while (hi - lo > 1)
{
var mid = (lo + hi) / 2;
var index = Mod(Start + 1 + mid, n);
if (Cross(sx, sy, _x[index], _y[index], px, py) >= -Surface)
lo = mid;
else
hi = mid;
}
var a = Mod(Start + 1 + lo, n);
var b = Mod(Start + 1 + lo + 1, n);
var edgeAB = Cross(_x[a], _y[a], _x[b], _y[b], px, py);
if (edgeAB < -Surface)
return false;
// Strictly inside the fan triangle, or inside the band of the far edge.
return edgeAB <= Surface
|| Cross(sx, sy, _x[a], _y[a], px, py) >= -Surface
&& Cross(_x[b], _y[b], sx, sy, px, py) >= -Surface;
}
/// <summary>
/// The vertical span [lo, hi] of the contour's cross-section at x, when x is
/// strictly inside its x-range (inset by <see cref="Surface"/>); false otherwise.
/// </summary>
public bool VerticalSpanAt(double x, out double lo, out double hi)
{
lo = 0;
hi = 0;
if (x < MinX + Surface || x > MaxX - Surface)
return false;
lo = double.MaxValue;
hi = double.MinValue;
var n = _x.Length;
var j = n - 1;
for (var i = 0; i < n; i++)
{
var x0 = _x[j];
var x1 = _x[i];
if ((x0 <= x && x1 >= x) || (x1 <= x && x0 >= x))
{
var y0 = _y[j];
var y1 = _y[i];
double y;
if (x1 == x0)
y = Math.Min(y0, y1);
else
y = y0 + (y1 - y0) * (x - x0) / (x1 - x0);
if (y < lo)
lo = y;
if (y > hi)
hi = y;
}
j = i;
}
return lo <= hi;
}
/// <summary>The horizontal span at y, inset like <see cref="VerticalSpanAt"/>.</summary>
public bool HorizontalSpanAt(double y, out double lo, out double hi)
{
lo = 0;
hi = 0;
if (y < MinY + Surface || y > MaxY - Surface)
return false;
lo = double.MaxValue;
hi = double.MinValue;
var n = _x.Length;
var j = n - 1;
for (var i = 0; i < n; i++)
{
var y0 = _y[j];
var y1 = _y[i];
if ((y0 <= y && y1 >= y) || (y1 <= y && y0 >= y))
{
var x0 = _x[j];
var x1 = _x[i];
double x;
if (y1 == y0)
x = Math.Min(x0, x1);
else
x = x0 + (x1 - x0) * (y - y0) / (y1 - y0);
if (x < lo)
lo = x;
if (x > hi)
hi = x;
}
j = i;
}
return lo <= hi;
}
private int Mod(int i, int n)
{
var m = i % n;
return m < 0 ? m + n : m;
}
private int FindStart()
{
var best = 0;
for (var i = 1; i < _y.Length; i++)
if (
_y[i] < _y[best] - 1e-12
|| (Math.Abs(_y[i] - _y[best]) <= 1e-12 && _x[i] < _x[best])
)
best = i;
Start = best;
return best;
}
private static double Cross(double ax, double ay, double bx, double by, double px, double py) =>
(bx - ax) * (py - ay) - (by - ay) * (px - ax);
}
/// <summary>
/// No-Fit-Polygon geometry for this engine: the Minkowski sum of two convex contours
/// (the classic linear edge-merge), used to build convex NFPs as
/// placedHull (+) disk(spacing) (+) reflect(candidateHull). The engine's placement
/// search consumes these contours directly; it never tessellates part material or
/// delegates to the built-in NFP machinery.
/// </summary>
internal static class NfpGeometry
{
/// <summary>
/// Point-symmetric reflection (rotation by 180 degrees). Negating every vertex
/// preserves CCW winding, so the vertex order must NOT be reversed - reversing it
/// would hand the edge-merge a CW contour and corrupt the NFP.
/// </summary>
public static ConvexContour Reflect(ConvexContour contour)
{
var n = contour.Count;
var points = new List<Vector>(n);
for (var i = 0; i < n; i++)
points.Add(new Vector(-contour.X(i), -contour.Y(i)));
return ConvexContour.FromVertices(points);
}
/// <summary>
/// Minkowski sum of two convex CCW contours via angular edge merge, starting from
/// the sum of each contour's lexicographic (Y, X) minimum vertex. Edges are chosen
/// by relative angle (cross product); the invariant that the two frontier edges are
/// always less than 180 degrees apart holds because both walks start at the lowest
/// vertex and each convex polygon turns by less than 180 degrees per vertex.
/// </summary>
public static ConvexContour Minkowski(ConvexContour a, ConvexContour b)
{
var na = a.Count;
var nb = b.Count;
var edges = new List<(double x, double y)>(na + nb);
// Edge vectors walking CCW from each start vertex.
var edgeA = new (double x, double y)[na];
for (var k = 0; k < na; k++)
{
var p = (a.Start + k) % na;
var q = (a.Start + k + 1) % na;
edgeA[k] = (a.X(q) - a.X(p), a.Y(q) - a.Y(p));
}
var edgeB = new (double x, double y)[nb];
for (var k = 0; k < nb; k++)
{
var p = (b.Start + k) % nb;
var q = (b.Start + k + 1) % nb;
edgeB[k] = (b.X(q) - b.X(p), b.Y(q) - b.Y(p));
}
var ka = 0;
var kb = 0;
while (ka < na || kb < nb)
{
if (ka >= na)
{
edges.Add(edgeB[kb++]);
continue;
}
if (kb >= nb)
{
edges.Add(edgeA[ka++]);
continue;
}
var ea = edgeA[ka];
var eb = edgeB[kb];
var cross = ea.x * eb.y - ea.y * eb.x;
var scale =
(ea.x * ea.x + ea.y * ea.y) * (eb.x * eb.x + eb.y * eb.y) + 1e-300;
if (Math.Abs(cross) <= 1e-9 * Math.Sqrt(scale))
{
// Same direction: emit the summed edge.
edges.Add((ea.x + eb.x, ea.y + eb.y));
ka++;
kb++;
}
else if (cross > 0)
{
// cross(ea, eb) > 0: eb is CCW-after ea, so ea is the more clockwise
// edge and must be emitted first to keep the merge in angular order.
edges.Add(ea);
ka++;
}
else
{
edges.Add(eb);
kb++;
}
}
var result = new List<Vector>(edges.Count + 1);
var px = a.X(a.Start) + b.X(b.Start);
var py = a.Y(a.Start) + b.Y(b.Start);
result.Add(new Vector(px, py));
foreach (var (ex, ey) in edges)
{
px += ex;
py += ey;
result.Add(new Vector(px, py));
}
if (result.Count > 1 && result[0].Equals(result[^1]))
result.RemoveAt(result.Count - 1);
return ConvexContour.FromVertices(result);
}
}
@@ -0,0 +1,461 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
using Math = System.Math;
/// <summary>
/// Flat-array polygon with a uniform edge grid, used as the engine's fast outer-shell
/// clearance test. Two closed polygons share positive area only when an edge pair
/// crosses/touches or one polygon's vertex lies strictly inside the other; neither
/// happening certifies the two closed regions (hence any materials inside them) are
/// clear. <see cref="Clears"/> returns true only in that certified case and false
/// whenever anything touches, so it can only ever skip the exact <see cref="Collision"/>
/// gate when the exact gate would also find no overlap - the exact gate triangulates
/// both polygons per call and dominates runtime on finely flattened arc geometry.
/// <para>
/// A <see cref="FastPolyTemplate"/> holds the shared geometry; <see cref="Translated"/>
/// produces a placement in world coordinates in O(1) - translation leaves the grid and
/// all cell indices unchanged, only the predicate coordinates shift.
/// </para>
/// </summary>
internal sealed class FastPoly
{
/// <summary>Vertex-on-segment / collinearity tolerance for conservative touches.</summary>
private const double TouchEps = 1e-9;
private readonly FastPolyTemplate _template;
/// <summary>Translation applied to the shared template geometry.</summary>
public readonly double Dx;
public readonly double Dy;
private FastPoly(FastPolyTemplate template, double dx, double dy)
{
_template = template;
Dx = dx;
Dy = dy;
}
public double MinX => _template.MinX + Dx;
public double MinY => _template.MinY + Dy;
public double MaxX => _template.MaxX + Dx;
public double MaxY => _template.MaxY + Dy;
/// <summary>
/// Builds from a closed <see cref="Polygon"/> (last vertex may repeat the first).
/// Returns null when the polygon has no usable ring - callers treat that as
/// "no information" and fall through to the exact gate.
/// </summary>
public static FastPoly? From(Polygon polygon)
{
var template = FastPolyTemplate.Build(polygon);
return template == null ? null : new FastPoly(template, 0, 0);
}
public FastPoly Translated(double dx, double dy) => new(_template, Dx + dx, Dy + dy);
private double X(int i) => _template.X[i] + Dx;
private double Y(int i) => _template.Y[i] + Dy;
/// <summary>
/// True when this and <paramref name="other"/> are CERTIFIED clear: their
/// boundaries neither cross nor touch (within <see cref="TouchEps"/>) and neither
/// contains a vertex of the other, so the closed regions share no area. Any touch,
/// crossing, or containment reports false and defers to the exact gate.
/// </summary>
public static bool Clears(FastPoly a, FastPoly b) => Relate(a, b) == FastRelation.Clear;
/// <summary>
/// Outer-shell relation between two closed polygons: crossing or containment means
/// the shells share positive area; a boundary touch alone or disjoint shells means
/// they do not. The Overlap verdict is about SHELLS only - callers with holes must
/// still consult the exact gate, because holes can cancel shell overlap.
/// </summary>
public static FastRelation Relate(FastPoly a, FastPoly b)
{
if (
a.MaxX <= b.MinX
|| b.MaxX <= a.MinX
|| a.MaxY <= b.MinY
|| b.MaxY <= a.MinY
)
return FastRelation.Clear; // disjoint bounding boxes
// One walk per direction reports the strongest edge relation: a transversal
// crossing shares a positive-area wedge (overlap); a mere touch shares zero
// area but may hide a crossing in near-degenerate coordinates (unknown).
var edge = EdgeRelation(a, b);
if (edge < 2)
{
var back = EdgeRelation(b, a);
if (back > edge)
edge = back;
}
if (edge == 2)
return FastRelation.Overlap;
// No transversal crossing. Cases:
// 0 = boundaries fully disjoint: containment (hence positive overlap) is
// decided by one vertex test per direction.
// 1 = point touches only (zero shared area by themselves): positive overlap
// requires a vertex strictly inside the other polygon; a tangency - the
// spacing-exact contact a bottom-left packer lives on - has none.
// 3 = collinear/near-degenerate contact: a shared boundary strip can hide a
// same-side positive overlap with no strict-interior vertex anywhere, so
// it defers to the exact gate.
switch (edge)
{
case 0:
if (ContainsPointStrictly(a, b.X(0), b.Y(0)))
return FastRelation.Overlap;
if (ContainsPointStrictly(b, a.X(0), a.Y(0)))
return FastRelation.Overlap;
return FastRelation.Clear;
case 1:
if (AnyVertexStrictlyInside(b, a) || AnyVertexStrictlyInside(a, b))
return FastRelation.Overlap;
return FastRelation.Clear;
default:
return FastRelation.Unknown;
}
}
/// <summary>
/// True when any vertex of <paramref name="vertexSource"/> lies strictly inside
/// <paramref name="poly"/>, or any edge interior sample point does. The samples
/// close the inscribed-polygon hole: positive shared area with boundaries meeting
/// only at clean points, no strict-interior vertex, and no collinear contact
/// requires an edge to run through the interior - its quarter points catch that.
/// </summary>
private static bool AnyVertexStrictlyInside(FastPoly poly, FastPoly vertexSource)
{
var n = vertexSource._template.Count;
for (var i = 0; i < n; i++)
{
var vx = vertexSource.X(i);
var vy = vertexSource.Y(i);
if (ContainsPointStrictly(poly, vx, vy))
return true;
var i2 = (i + 1) % n;
var wx = vertexSource.X(i2);
var wy = vertexSource.Y(i2);
if (wx == vx && wy == vy)
continue;
for (var k = 1; k <= 3; k++)
{
var t = k * 0.25;
if (ContainsPointStrictly(poly, vx + (wx - vx) * t, vy + (wy - vy) * t))
return true;
}
}
return false;
}
/// <summary>Three-state outcome of <see cref="Relate"/>.</summary>
public enum FastRelation
{
/// <summary>Shells certified disjoint: any materials inside them are clear.</summary>
Clear,
/// <summary>Shells share positive area (crossing or containment).</summary>
Overlap,
/// <summary>Boundary touch too close to classify: consult the exact gate.</summary>
Unknown,
}
/// <summary>
/// True when any edge of <paramref name="q"/> crosses or touches the boundary of
/// <paramref name="p"/>. Walks p's grid using each query edge's own bbox cells.
/// p's grid lives in p's LOCAL frame (the template's own coordinates), so the
/// query edge is converted by subtracting p's translation first.
/// </summary>
private static int EdgeRelation(FastPoly p, FastPoly q)
{
var t = p._template;
var n = t.Count;
var seen = t.Seen;
var head = t.Head;
var nodeEdge = t.NodeEdge;
var nodeNext = t.NodeNext;
var no = q._template.Count;
var strongest = 0;
for (var e = 0; e < no; e++)
{
// Stamp per QUERY edge: a grid edge may need testing against every query
// edge; the dedupe only collapses cells an individual query edge crosses
// more than once.
var stamp = ++t.Stamp;
var i2 = (e + 1) % no;
var p0x = q.X(e) - p.Dx;
var p0y = q.Y(e) - p.Dy;
var p1x = q.X(i2) - p.Dx;
var p1y = q.Y(i2) - p.Dy;
var c0 = ColLow(t, p0x, p1x);
if (c0 > ColHigh(t, p0x, p1x))
continue;
var c1 = ColHigh(t, p0x, p1x);
var r0 = RowLow(t, p0y, p1y);
if (r0 > RowHigh(t, p0y, p1y))
continue;
var r1 = RowHigh(t, p0y, p1y);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
for (var nIdx = head[r * t.Cols + c]; nIdx >= 0; nIdx = nodeNext[nIdx])
{
var ea = nodeEdge[nIdx];
if (seen[ea] == stamp)
continue;
seen[ea] = stamp;
var a2 = (ea + 1) % n;
var relation = SegmentRelation(
t.X[ea], t.Y[ea], t.X[a2], t.Y[a2], p0x, p0y, p1x, p1y
);
if (relation == 2)
return 2; // transversal crossing
if (relation > strongest)
strongest = relation;
}
}
return strongest;
}
/// <summary>
/// Segment-pair relation: 2 = transversal crossing (strict sign flips on both
/// orientations - the regions share a positive-area wedge); 1 = a clean endpoint
/// touch (zero shared area by itself; callers decide via interior-vertex tests);
/// 3 = collinear or near-degenerate contact (a shared boundary segment can hide
/// either a same-side positive overlap or an opposite-side tangency, so it must
/// defer to the exact gate); 0 = disjoint.
/// </summary>
private static int SegmentRelation(
double ax, double ay, double bx, double by, double cx, double cy, double dx, double dy
)
{
var rx = bx - ax;
var ry = by - ay;
var sx = dx - cx;
var sy = dy - cy;
var d1 = rx * (cy - ay) - ry * (cx - ax);
var d2 = rx * (dy - ay) - ry * (dx - ax);
var d3 = sx * (ay - cy) - sy * (ax - cx);
var d4 = sx * (by - cy) - sy * (bx - cx);
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
return 2; // proper crossing
// A near-zero orientation means the configuration is collinear or too close to
// classify; only exact-zero orientations get the clean point-touch verdict.
var scale = Math.Max(
1e-30,
Math.Max(Math.Abs(rx) + Math.Abs(ry), Math.Abs(sx) + Math.Abs(sy))
);
var eps = TouchEps * scale;
var nearDegenerate =
(Math.Abs(d1) <= eps && d1 != 0)
|| (Math.Abs(d2) <= eps && d2 != 0)
|| (Math.Abs(d3) <= eps && d3 != 0)
|| (Math.Abs(d4) <= eps && d4 != 0);
var exactDegenerate = d1 == 0 || d2 == 0 || d3 == 0 || d4 == 0;
var touch =
(d1 == 0 && PointOnSegment(cx, cy, ax, ay, bx, by))
|| (d2 == 0 && PointOnSegment(dx, dy, ax, ay, bx, by))
|| (d3 == 0 && PointOnSegment(ax, ay, cx, cy, dx, dy))
|| (d4 == 0 && PointOnSegment(bx, by, cx, cy, dx, dy));
if (nearDegenerate)
return 3;
if (exactDegenerate)
// Collinear: contact along a segment (or too close to tell) must defer to
// the exact gate; collinear but disjoint edges simply do not touch.
return touch ? 3 : 0;
if (touch)
return 1;
return 0;
}
private static bool PointOnSegment(
double px, double py, double ax, double ay, double bx, double by
) =>
Math.Min(ax, bx) - TouchEps <= px
&& px <= Math.Max(ax, bx) + TouchEps
&& Math.Min(ay, by) - TouchEps <= py
&& py <= Math.Max(ay, by) + TouchEps;
/// <summary>Strict ray-cast containment (boundary touches are excluded upstream).</summary>
private static bool ContainsPointStrictly(FastPoly poly, double px, double py)
{
var t = poly._template;
var inside = false;
var n = t.Count;
for (var i = 0; i < n; i++)
{
var j = (i + 1) % n;
var yi = poly.Y(i);
var yj = poly.Y(j);
if ((yi > py) != (yj > py))
{
var xAt = poly.X(i) + (py - yi) / (yj - yi) * (poly.X(j) - poly.X(i));
if (px < xAt)
inside = !inside;
}
}
return inside;
}
private static int ColLow(FastPolyTemplate t, double a, double b) =>
Math.Clamp((int)Math.Floor((Math.Min(a, b) - t.MinX) / t.CellSize), 0, t.Cols);
private static int ColHigh(FastPolyTemplate t, double a, double b) =>
Math.Clamp((int)Math.Floor((Math.Max(a, b) - t.MinX) / t.CellSize), -1, t.Cols - 1);
private static int RowLow(FastPolyTemplate t, double a, double b) =>
Math.Clamp((int)Math.Floor((Math.Min(a, b) - t.MinY) / t.CellSize), 0, t.Rows);
private static int RowHigh(FastPolyTemplate t, double a, double b) =>
Math.Clamp((int)Math.Floor((Math.Max(a, b) - t.MinY) / t.CellSize), -1, t.Rows - 1);
}
/// <summary>
/// Shared, immutable grid geometry for <see cref="FastPoly"/>; the grid is defined
/// relative to the shape's own local coordinates, so translated instances reuse it.
/// Stamp/Seen are mutable single-threaded scratch for the edge-walk dedupe.
/// </summary>
internal sealed class FastPolyTemplate
{
public readonly double[] X;
public readonly double[] Y;
public readonly int Count;
public readonly double MinX;
public readonly double MinY;
public readonly double MaxX;
public readonly double MaxY;
public readonly double CellSize;
public readonly int Cols;
public readonly int Rows;
public readonly int[] Head;
/// <summary>
/// Grid nodes as parallel (edge, next) arrays: an edge spanning several cells gets
/// one node PER cell - a single next-per-edge chain would corrupt the other cells'
/// chains and silently drop edges from the walk.
/// </summary>
public readonly int[] NodeEdge;
public readonly int[] NodeNext;
public readonly int NodeCount;
public int Stamp;
public readonly int[] Seen;
private FastPolyTemplate(
double[] x,
double[] y,
int count,
double minX,
double minY,
double maxX,
double maxY
)
{
X = x;
Y = y;
Count = count;
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
Seen = new int[count];
var extentX = Math.Max(maxX - minX, 1e-9);
var extentY = Math.Max(maxY - minY, 1e-9);
CellSize = Math.Max(Math.Max(extentX, extentY) / 16.0, 1e-9);
Cols = Math.Clamp((int)Math.Ceiling(extentX / CellSize) + 1, 1, 48);
Rows = Math.Clamp((int)Math.Ceiling(extentY / CellSize) + 1, 1, 48);
Head = new int[Cols * Rows];
Array.Fill(Head, -1);
// Pass 1: count nodes; pass 2: fill (edge, next) node arrays.
var cellsPerEdge = new int[count];
var total = 0;
for (var e = 0; e < count; e++)
{
var i2 = (e + 1) % count;
var c0 = ClampCol(Math.Min(x[e], x[i2]) - minX);
var c1 = ClampCol(Math.Max(x[e], x[i2]) - minX);
var r0 = ClampRow(Math.Min(y[e], y[i2]) - minY);
var r1 = ClampRow(Math.Max(y[e], y[i2]) - minY);
cellsPerEdge[e] = (c1 - c0 + 1) * (r1 - r0 + 1);
total += cellsPerEdge[e];
}
NodeEdge = new int[total];
NodeNext = new int[total];
var node = 0;
for (var e = 0; e < count; e++)
{
var i2 = (e + 1) % count;
var c0 = ClampCol(Math.Min(x[e], x[i2]) - minX);
var c1 = ClampCol(Math.Max(x[e], x[i2]) - minX);
var r0 = ClampRow(Math.Min(y[e], y[i2]) - minY);
var r1 = ClampRow(Math.Max(y[e], y[i2]) - minY);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
{
var cell = r * Cols + c;
NodeEdge[node] = e;
NodeNext[node] = Head[cell];
Head[cell] = node;
node++;
}
}
NodeCount = node;
}
private int ClampCol(double dx) =>
Math.Clamp((int)Math.Floor(dx / CellSize), 0, Cols - 1);
private int ClampRow(double dy) =>
Math.Clamp((int)Math.Floor(dy / CellSize), 0, Rows - 1);
public static FastPolyTemplate? Build(Polygon polygon)
{
var vertices = polygon.Vertices;
var n = vertices.Count;
if (n >= 2 && vertices[0].Equals(vertices[n - 1]))
n--;
if (n < 3)
return null;
var xs = new double[n];
var ys = new double[n];
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
for (var i = 0; i < n; i++)
{
var vx = vertices[i].X;
var vy = vertices[i].Y;
xs[i] = vx;
ys[i] = vy;
if (vx < minX)
minX = vx;
if (vx > maxX)
maxX = vx;
if (vy < minY)
minY = vy;
if (vy > maxY)
maxY = vy;
}
return new FastPolyTemplate(xs, ys, n, minX, minY, maxX, maxY);
}
}
@@ -0,0 +1,439 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
using Math = System.Math;
internal sealed record SheetAttempt(SheetPacker Packer, int StockIndex);
/// <summary>
/// Whole-job decision layer: which stock the next sheet uses, the order parts are
/// demanded in, when a sheet is finished, and when the job stops. Every placement
/// inside a sheet comes from <see cref="SheetPacker"/>; nothing here delegates to a
/// built-in engine, nester, filler, or runner.
/// </summary>
internal sealed class JobSolver
{
private readonly NestJob _job;
private readonly PartPreparation _prep;
private readonly Dictionary<string, int> _remaining;
private readonly Dictionary<string, int> _placed;
private readonly Dictionary<string, int> _used;
private readonly List<CommittedSheet> _sheets = new();
private sealed record CommittedSheet(int StockIndex, List<PlacedPart> Placements);
public JobSolver(NestJob job, PartPreparation prep)
{
_job = job;
_prep = prep;
_remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
_placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal);
_used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
}
private static readonly bool _diag =
Environment.GetEnvironmentVariable("QWEN_NEST_DIAG") == "1";
private void Diag(string message)
{
if (_diag)
Console.Error.WriteLine(
$"[qwen] sheets={_sheets.Count} placed={_placed.Values.Sum()} " +
$"mem={GC.GetTotalMemory(false) / 1048576}MB gc0={GC.CollectionCount(0)} " +
$"gc2={GC.CollectionCount(2)} {message}"
);
}
public NestJobResult Solve(IProgress<NestJobProgress>? progress, CancellationToken token)
{
var reason = NestJobStopReason.Completed;
while (true)
{
token.ThrowIfCancellationRequested();
var outstanding = OutstandingDemands();
if (outstanding.Count == 0)
break;
if (_job.Options.MaxPlates is int cap && _sheets.Count >= cap)
{
reason = NestJobStopReason.PlateLimitReached;
break;
}
var attempt = BestNextSheet(outstanding, progress, token);
Diag($"nextSheet -> {(attempt == null ? "none" : $"stock {_job.Plates[attempt.StockIndex].Id} placed {attempt.Packer.Placed.Count}")}");
if (attempt == null)
{
reason = AnyStockAvailable()
? NestJobStopReason.NoPlacementFound
: NestJobStopReason.StockExhausted;
break;
}
CommitSheet(attempt.Packer);
progress?.Report(
new NestJobProgress(
NestJobStage.PlateCommitted,
_job.Plates[attempt.StockIndex].Id,
_sheets.Count - 1,
_sheets.Count,
_placed.Values.Sum()
)
);
}
return BuildResult(reason);
}
private List<PartModel> OutstandingDemands()
{
var demands = new List<PartModel>();
foreach (var model in _prep.Models)
if (_remaining[model.Id] > 0)
demands.Add(model);
// This engine's own ordering: priority first, then the tallest-then-largest
// part first (a part's thinnest orientation extent), then id for determinism.
demands.Sort(
(a, b) =>
{
var byPriority = a.Priority.CompareTo(b.Priority);
if (byPriority != 0)
return byPriority;
if (DemandOrderMode == 1)
{
var byArea = b.Area.CompareTo(a.Area);
if (byArea != 0)
return byArea;
}
else if (DemandOrderMode == 2)
{
// Biggest footprint first (worst-case largest extent, descending).
var byMaxSpan = MaximumMaxSpan(b).CompareTo(MaximumMaxSpan(a));
if (byMaxSpan != 0)
return -byMaxSpan;
var byArea2 = b.Area.CompareTo(a.Area);
if (byArea2 != 0)
return byArea2;
}
else
{
var bySpan = MinimumMaxSpan(b).CompareTo(MinimumMaxSpan(a));
if (bySpan != 0)
return bySpan;
var byArea = b.Area.CompareTo(a.Area);
if (byArea != 0)
return byArea;
}
return string.CompareOrdinal(a.Id, b.Id);
}
);
return demands;
}
private double MinimumMaxSpan(PartModel model)
{
if (!_minimumSpan.TryGetValue(model.Id, out var span))
{
span = double.MaxValue;
foreach (var angle in PartPreparation.CandidateAngles(model))
{
var orientation = _prep.Oriented(model, angle, 0);
var worst = Math.Max(orientation.Width, orientation.Height);
if (worst < span)
span = worst;
}
_minimumSpan[model.Id] = span;
}
return span;
}
private readonly Dictionary<string, double> _minimumSpan = new(StringComparer.Ordinal);
private double MaximumMaxSpan(PartModel model)
{
if (!_maximumSpan.TryGetValue(model.Id, out var span))
{
span = 0;
foreach (var angle in PartPreparation.CandidateAngles(model))
{
var orientation = _prep.Oriented(model, angle, 0);
var worst = Math.Max(orientation.Width, orientation.Height);
if (worst > span)
span = worst;
}
_maximumSpan[model.Id] = span;
}
return span;
}
private readonly Dictionary<string, double> _maximumSpan = new(StringComparer.Ordinal);
/// <summary>
/// Packs every available stock size independently and commits the best trial:
/// most instances first, then highest priority coverage, then the smallest sheet
/// area (the cost function the benchmark scores), then input order.
/// </summary>
private SheetAttempt? BestNextSheet(
List<PartModel> outstanding,
IProgress<NestJobProgress>? progress,
CancellationToken token
)
{
SheetAttempt? best = null;
TrialScore bestScore = default;
for (var index = 0; index < _job.Plates.Count; index++)
{
var stock = _job.Plates[index];
if (stock.Quantity is int quantity && _used[stock.Id] >= quantity)
continue;
token.ThrowIfCancellationRequested();
progress?.Report(
new NestJobProgress(
NestJobStage.EvaluatingCandidate,
stock.Id,
_sheets.Count,
_sheets.Count,
_placed.Values.Sum()
)
);
var packer = SheetPacker.Create(stock, _prep, index);
var fillWatch = System.Diagnostics.Stopwatch.StartNew();
FillSheet(packer, outstanding, token);
fillWatch.Stop();
if (_diag)
Diag(
$"trial stock {stock.Id}: placed={packer.Placed.Count} " +
$"{fillWatch.ElapsedMilliseconds}ms {packer.DiagStats()}"
);
if (packer.Placed.Count == 0)
continue;
var score = ScoreTrial(packer);
bool Better(TrialScore s)
{
if (CostFirstScoring)
{
// Benchmark cost is total plate AREA, so prefer the trial that
// delivers the cheapest material per unit of part area placed;
// priority coverage still outranks, and count breaks cost ties.
if (best == null)
return true;
if (s.priorityHits != bestScore.priorityHits)
return s.priorityHits > bestScore.priorityHits;
if (Math.Abs(s.costPerArea - bestScore.costPerArea) > 1e-9)
return s.costPerArea < bestScore.costPerArea;
if (s.count != bestScore.count)
return s.count > bestScore.count;
return s.area < bestScore.area;
}
return best == null
|| s.count > bestScore.count
|| (s.count == bestScore.count && s.priorityHits > bestScore.priorityHits)
|| (
s.count == bestScore.count
&& s.priorityHits == bestScore.priorityHits
&& s.area < bestScore.area
);
}
if (Better(score))
{
best = new SheetAttempt(packer, index);
bestScore = score;
}
}
return best;
}
/// <summary>
/// This engine's fill policy for one sheet: walk the demand order and drain each
/// requirement greedily; a requirement that cannot place any more instances is
/// skipped (never aborts the sheet) and retried on the next sheet. Consumes a
/// local copy of demand - losing this trial must not change job state.
/// </summary>
private void FillSheet(SheetPacker packer, List<PartModel> outstanding, CancellationToken token)
{
var available = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var model in outstanding)
available[model.Id] = _remaining[model.Id];
// One drain pass per requirement, in demand order. Gap-filling retries are
// deliberately NOT an unbounded loop: a sheet's failed-insert scans get more
// expensive as it fills, so an unbounded retry loop blows the benchmark's
// 5-minute wall (observed 2-3x on a 69-drawing job). Pass two runs only with
// the explicit retry budget below.
foreach (var model in outstanding)
{
if (available[model.Id] <= 0)
continue;
if (!packer.CanEverFit(model))
continue;
var modelWatch = System.Diagnostics.Stopwatch.StartNew();
while (available[model.Id] > 0 && !packer.IsFull)
{
token.ThrowIfCancellationRequested();
if (!packer.TryInsert(model, out _))
break;
available[model.Id]--;
}
modelWatch.Stop();
if (_diag && modelWatch.ElapsedMilliseconds > 200)
Diag($" fill model {model.Id}: placed={packer.Placed.Count} {modelWatch.ElapsedMilliseconds}ms {packer.DiagStats()}");
}
// Gap-fill pass: a part that could not fit between two early placements may
// fit the gaps a later model leaves behind. Failed-insert scans cost about a
// full candidate sweep each, so the pass is hard time-boxed - on a crowded
// sheet the untried budget was measured at minutes per job, well over the
// benchmark's wall; the box keeps worst case near the first pass's cost.
var retryWatch = System.Diagnostics.Stopwatch.StartNew();
var retryAgain = true;
while (retryAgain && retryWatch.ElapsedMilliseconds < GapFillMilliseconds)
{
retryAgain = false;
foreach (var model in outstanding)
{
if (available[model.Id] <= 0 || packer.IsFull)
continue;
if (retryWatch.ElapsedMilliseconds >= GapFillMilliseconds)
break;
while (available[model.Id] > 0)
{
token.ThrowIfCancellationRequested();
if (!packer.TryInsert(model, out _))
break;
available[model.Id]--;
retryAgain = true;
}
}
}
}
/// <summary>
/// Demand ordering within the priority sort: 1 = largest material area first
/// (measured best: big parts establish the sheet skeleton, small ones then fill
/// the seams; 12% lower job cost than span-first on a real production job), 2 = largest footprint
/// first, 0 = smallest worst-case extent first (original).
/// </summary>
private static readonly int DemandOrderMode =
int.TryParse(Environment.GetEnvironmentVariable("QWEN_DEMAND_ORDER"), out var m)
? m
: 1;
/// <summary>Wall-clock budget for one sheet's gap-fill pass.</summary>
private static readonly int GapFillMilliseconds =
int.TryParse(Environment.GetEnvironmentVariable("QWEN_GAPFILL_MS"), out var ms)
? ms
: 120;
/// <summary>Trial-sheet metrics; costPerArea = plate area / part area placed.</summary>
private readonly record struct TrialScore(
int count,
int priorityHits,
double area,
double costPerArea
);
/// <summary>
/// Greedy trial-comparison mode. Cost-first optimizes the benchmark's cost
/// function (total plate area); count-first is the conservative fill policy.
/// Env override exists for A/B measurement.
/// </summary>
private static readonly bool CostFirstScoring =
Environment.GetEnvironmentVariable("QWEN_COST_FIRST") != "0";
private TrialScore ScoreTrial(SheetPacker packer)
{
var count = packer.Placed.Count;
var bestPriority = int.MaxValue;
foreach (var placed in packer.Placed)
if (placed.Model.Priority < bestPriority)
bestPriority = placed.Model.Priority;
var priorityHits = packer.Placed.Count(p => p.Model.Priority == bestPriority);
var area = packer.Stock.Size.Width * packer.Stock.Size.Length;
var placedArea = 0.0;
foreach (var placed in packer.Placed)
placedArea += placed.Model.Area;
var costPerArea = placedArea > 1e-9 ? area / placedArea : double.MaxValue;
return new TrialScore(count, priorityHits, area, costPerArea);
}
private void CommitSheet(SheetPacker packer)
{
_sheets.Add(new CommittedSheet(packer.StockIndex, packer.Placed));
_used[packer.Stock.Id]++;
foreach (var placed in packer.Placed)
{
_placed[placed.Model.Id]++;
_remaining[placed.Model.Id]--;
}
}
private bool AnyStockAvailable()
{
foreach (var stock in _job.Plates)
if (stock.Quantity is null || _used[stock.Id] < stock.Quantity.Value)
return true;
return false;
}
private NestJobResult BuildResult(NestJobStopReason reason)
{
var instanceIndex = new Dictionary<string, int>(StringComparer.Ordinal);
var plates = new List<NestJobPlateResult>();
foreach (var sheet in _sheets)
{
var placements = new List<NestJobPlacement>(sheet.Placements.Count);
foreach (var placed in sheet.Placements)
{
instanceIndex.TryGetValue(placed.Model.Id, out var next);
instanceIndex[placed.Model.Id] = next + 1;
placements.Add(
new NestJobPlacement(
placed.Model.Id,
next,
placed.X,
placed.Y,
placed.Orientation.Angle
)
);
}
plates.Add(
new NestJobPlateResult(sheet.StockIndex, _job.Plates[sheet.StockIndex], placements)
);
}
var fulfillment = _job.Parts
.Select(part => new PartFulfillment(
part.Id,
part.Quantity,
_placed[part.Id],
_remaining[part.Id]
))
.ToList();
var stockUsage = _job.Plates
.Select(stock => new StockUsage(
stock.Id,
_used[stock.Id],
stock.Quantity is int quantity ? quantity - _used[stock.Id] : null
))
.ToList();
return new NestJobResult(
reason == NestJobStopReason.Completed
? NestJobStatus.Complete
: NestJobStatus.Incomplete,
reason,
plates,
fulfillment,
stockUsage
);
}
}
@@ -0,0 +1,524 @@
using System;
using System.Collections.Generic;
using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
using Math = System.Math;
/// <summary>
/// A job requirement prepared once per solve: snapshot motions rebuilt into an owned
/// closed contour topology (perimeter + cutouts; rapid/layer-mark geometry dropped),
/// flattened collision polygons, and material area.
/// </summary>
internal sealed class PartModel
{
private PartModel(
string id,
int quantity,
int priority,
RotationPolicy rotation,
ShapeProfile profile,
Shape perimeterShape,
List<Shape> cutoutShapes,
double area
)
{
Id = id;
Quantity = quantity;
Priority = priority;
Rotation = rotation;
Profile = profile;
PerimeterShape = perimeterShape;
CutoutShapes = cutoutShapes;
Area = area;
}
public string Id { get; }
public int Quantity { get; }
public int Priority { get; }
public RotationPolicy Rotation { get; }
/// <summary>Closed contour topology (perimeter CCW, cutouts) used for region offsets.</summary>
public ShapeProfile Profile { get; }
/// <summary>Analytic closed perimeter (arcs preserved) for conservative flattening.</summary>
public Shape PerimeterShape { get; }
public List<Shape> CutoutShapes { get; }
/// <summary>Material area (perimeter minus holes), from the analytic shapes.</summary>
public double Area { get; }
/// <summary>
/// Chord tolerance for the engine's internal collision polygons. Must stay FINER
/// than the validator's OutlineTolerance (0.001): any chord cuts the cap off a
/// concave arc, and a coarser polygon cuts MORE - so a coarse flattening is a
/// subset of the validator's material in notched regions and admits real spacing
/// violations (observed on arc-heavy PEP parts at 0.02). Finer than the validator,
/// every engine polygon contains the validator's, so a cleared gate is conservative.
/// </summary>
public const double CollisionTolerance = 0.0005;
/// <summary>
/// Returns null when the snapshot has no usable closed contour - such a part can
/// never be placed and is reported unplaced rather than failing the whole job.
/// </summary>
public static PartModel? TryCreate(NestJobPart part)
{
try
{
var entities = new List<Entity>();
foreach (
var entity in ConvertProgram.ToGeometry(
DrawingJobMapper.ToProgram(part.Geometry)
)
)
if (!ReferenceEquals(entity.Layer, SpecialLayers.Rapid))
entities.Add(entity);
if (entities.Count == 0)
return null;
var profile = new ShapeProfile(entities);
if (profile.Perimeter == null)
return null;
profile.NormalizeWinding();
var area = Math.Abs(profile.Perimeter.Area());
foreach (var cutout in profile.Cutouts)
area -= Math.Abs(cutout.Area());
if (!double.IsFinite(area) || area <= Tolerance.Epsilon)
return null;
return new PartModel(
part.Id,
part.Quantity,
part.Priority,
part.Rotation,
profile,
profile.Perimeter,
new List<Shape>(profile.Cutouts),
area
);
}
catch (Exception)
{
// Malformed snapshots are unplaceable, not fatal: report them unplaced so
// the rest of the job still nests.
return null;
}
}
}
/// <summary>
/// One part contour rotated about the snapshot origin - exactly the frame a
/// <see cref="NestJobPlacement"/> produces (rotate, then translate by X/Y). Bounds,
/// convex hull, and the spacing-inflated outline are computed once and reused.
/// </summary>
internal sealed class OrientationModel
{
internal OrientationModel(
double angle,
Polygon perimeter,
List<Polygon> holes,
Polygon? inflatedPerimeter,
List<Polygon> inflatedHoles,
double spacing
)
{
Angle = angle;
Perimeter = perimeter;
Holes = holes;
InflatedPerimeter = inflatedPerimeter;
InflatedHoles = inflatedHoles;
Spacing = spacing;
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var v in perimeter.Vertices)
{
if (v.X < minX)
minX = v.X;
if (v.X > maxX)
maxX = v.X;
if (v.Y < minY)
minY = v.Y;
if (v.Y > maxY)
maxY = v.Y;
}
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
var hullPoints = new List<Vector>();
try
{
var hull = ConvexHull.Compute(perimeter.Vertices);
foreach (var v in hull.Vertices)
{
if (hullPoints.Count > 0 && v.Equals(hullPoints[^1]))
continue;
hullPoints.Add(v);
}
if (hullPoints.Count > 1 && hullPoints[0].Equals(hullPoints[^1]))
hullPoints.RemoveAt(hullPoints.Count - 1);
}
catch (Exception)
{
hullPoints.Clear();
}
Hull = hullPoints.Count >= 3 ? hullPoints : perimeter.Vertices;
// True when the flattened perimeter is itself convex (no concavities) and the
// part has no cutouts: for two such parts the convex NFP is EXACT - material
// equals hull - so an anchor inside it is forbidden with no material test.
var convex = holes.Count == 0;
if (convex)
{
var verts = perimeter.Vertices;
var m = verts.Count;
if (m > 2 && verts[0].Equals(verts[m - 1]))
m--;
for (var i = 0; i < m && convex; i++)
{
var ax = verts[i].X;
var ay = verts[i].Y;
var bx = verts[(i + 1) % m].X;
var by = verts[(i + 1) % m].Y;
var cx = verts[(i + 2) % m].X;
var cy = verts[(i + 2) % m].Y;
if ((bx - ax) * (cy - by) - (by - ay) * (cx - bx) < -1e-9)
convex = false;
}
}
IsConvexSolid = convex;
}
/// <summary>No cutouts and a convex perimeter: material equals hull.</summary>
public bool IsConvexSolid { get; }
public double Angle { get; }
/// <summary>Circumscribed flattened perimeter in the rotated frame (pre-translation).</summary>
public Polygon Perimeter { get; }
public List<Polygon> Holes { get; }
/// <summary>Material outline inflated by <see cref="Spacing"/> (null when spacing is zero).</summary>
public Polygon? InflatedPerimeter { get; }
/// <summary>Cutouts shrunk by <see cref="Spacing"/>; holes that close up are dropped (treated solid).</summary>
public List<Polygon> InflatedHoles { get; }
public double Spacing { get; }
public double MinX { get; }
public double MinY { get; }
public double MaxX { get; }
public double MaxY { get; }
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
/// <summary>Convex hull of the perimeter (open vertex list, at least 3 points).</summary>
public List<Vector> Hull { get; }
/// <summary>
/// Fast clearance outline of the raw perimeter in this orientation's local frame
/// (lazily built; translated per anchor in O(1) via <see cref="FastPoly.Translated"/>).
/// </summary>
public FastPoly? PerimeterFast => _perimeterFast ??= FastPoly.From(Perimeter);
private FastPoly? _perimeterFast;
/// <summary>
/// Fast clearance outline of the gate material (spacing-inflated when positive) in
/// this orientation's local frame.
/// </summary>
public FastPoly? GateFast =>
_gateFast ??= FastPoly.From(InflatedPerimeter ?? Perimeter);
private FastPoly? _gateFast;
/// <summary>
/// Cached triangulation of the raw material (perimeter + holes) in this
/// orientation's local frame for the allocation-free exact gate.
/// </summary>
public TriSet? MaterialTris => _materialTris ??= TriSet.Build(Perimeter, Holes);
private TriSet? _materialTris;
/// <summary>
/// Cached triangulation of the gate material (spacing-inflated perimeter with
/// shrunk holes) in this orientation's local frame.
/// </summary>
public TriSet? GateTris =>
_gateTris ??= TriSet.Build(InflatedPerimeter ?? Perimeter, InflatedPerimeter != null ? InflatedHoles : Holes);
private TriSet? _gateTris;
}
/// <summary>Builds and caches per-(part, orientation, spacing) geometry for one engine run.</summary>
internal sealed class PartPreparation
{
private readonly List<PartModel> models = new();
private readonly Dictionary<string, int> indexById = new(StringComparer.Ordinal);
private readonly Dictionary<(string, double, double), OrientationModel> orientations = new();
/// <summary>
/// Cross-packer memo of exact material overlap: (placed orientation, placed anchor,
/// candidate orientation, candidate anchor) -> overlap. Sheet trials rebuild greedy
/// placement deterministically, so identical world poses recur across trials and
/// across sheets; the memo collapses the repeated polygon-clipping work. Bounded so
/// it can never grow unboundedly on pathological jobs.
/// </summary>
private readonly Dictionary<OverlapKey, bool> overlaps = new();
internal sealed class OverlapKey : IEquatable<OverlapKey>
{
private readonly int _placedHash;
private readonly long _px;
private readonly long _py;
private readonly int _candHash;
private readonly long _cx;
private readonly long _cy;
public OverlapKey(int placedHash, double px, double py, int candHash, double cx, double cy)
{
_placedHash = placedHash;
_px = (long)Math.Round(px * 1e6);
_py = (long)Math.Round(py * 1e6);
_candHash = candHash;
_cx = (long)Math.Round(cx * 1e6);
_cy = (long)Math.Round(cy * 1e6);
}
public bool Equals(OverlapKey? other) =>
other != null
&& _placedHash == other._placedHash
&& _px == other._px
&& _py == other._py
&& _candHash == other._candHash
&& _cx == other._cx
&& _cy == other._cy;
public override bool Equals(object? obj) => Equals(obj as OverlapKey);
public override int GetHashCode()
{
var hash = _placedHash;
hash = unchecked(hash * 397 + _px.GetHashCode());
hash = unchecked(hash * 397 + _py.GetHashCode());
hash = unchecked(hash * 397 + _candHash);
hash = unchecked(hash * 397 + _cx.GetHashCode());
hash = unchecked(hash * 397 + _cy.GetHashCode());
return hash;
}
}
private const int OverlapMemoCap = 500_000;
public bool MaterialOverlapMemo(
OrientationModel placed,
double placedX,
double placedY,
OrientationModel candidate,
double candidateX,
double candidateY,
Func<bool> compute
)
{
var key = new OverlapKey(
System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(placed),
placedX,
placedY,
System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(candidate),
candidateX,
candidateY
);
if (overlaps.TryGetValue(key, out var known))
return known;
if (overlaps.Count >= OverlapMemoCap)
overlaps.Clear();
var value = compute();
overlaps[key] = value;
return value;
}
public IReadOnlyList<PartModel> Models => models;
public PartPreparation(IReadOnlyList<NestJobPart> parts)
{
foreach (var part in parts)
{
var model = PartModel.TryCreate(part);
if (model == null)
{
InvalidIds.Add(part.Id);
continue;
}
indexById[model.Id] = models.Count;
models.Add(model);
}
}
/// <summary>Requirements whose snapshot geometry could not be interpreted at all.</summary>
public List<string> InvalidIds { get; } = new();
public bool TryGetModel(string partId, out PartModel model)
{
model = null!;
if (!indexById.TryGetValue(partId, out var index))
return false;
model = models[index];
return true;
}
public OrientationModel Oriented(PartModel model, double angle, double spacing)
{
// Round keys so policy-equivalent angles (0 vs 2pi) share one cached orientation.
var key = (model.Id, Math.Round(angle, 9), Math.Round(spacing, 9));
if (orientations.TryGetValue(key, out var cached))
return cached;
var perimeterShape = (Shape)model.PerimeterShape.Clone();
perimeterShape.Rotate(angle);
var perimeter = perimeterShape.ToPolygonWithTolerance(
PartModel.CollisionTolerance,
circumscribe: true
);
var holes = new List<Polygon>(model.CutoutShapes.Count);
foreach (var cutout in model.CutoutShapes)
{
var shape = (Shape)cutout.Clone();
shape.Rotate(angle);
holes.Add(
shape.ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true)
);
}
Polygon? inflated = null;
var inflatedHoles = new List<Polygon>();
if (spacing > Tolerance.Epsilon)
{
// Conservative (circumscribed, padded) region offset: a superset of the
// validator's inflation, so accepted clearances never fall short. The
// offset commutes with rotation, so inflate the unrotated profile once and
// rotate the result into this orientation's frame - an unrotated inflation
// would test the candidate against the material of a different angle.
var region = ClipperBridge.Offset(model.Profile, spacing, 0.02, circumscribe: true);
var outer = region.LargestOuter();
if (outer != null)
{
outer.Rotate(angle);
outer.UpdateBounds();
inflated = outer;
}
foreach (var hole in region.Holes)
if (hole != null)
{
hole.Rotate(angle);
hole.UpdateBounds();
inflatedHoles.Add(hole);
}
}
var result = new OrientationModel(angle, perimeter, holes, inflated, inflatedHoles, spacing);
orientations[key] = result;
return result;
}
/// <summary>
/// Legal orientations for a requirement: exactly the policy angles when the policy
/// enumerates them, otherwise 0/90/180/270 degrees plus the minimum-area bounding
/// rectangle angle (rotating-calipers), with 180-degree equivalents included.
/// </summary>
public static List<double> CandidateAngles(PartModel model)
{
var angles = new List<double>();
var policy = model.Rotation;
if (policy.Kind == RotationPolicyKind.Automatic)
{
angles.Add(0);
angles.Add(Math.PI / 2);
angles.Add(Math.PI);
angles.Add(3 * Math.PI / 2);
try
{
var hull = ConvexHull.Compute(
model
.PerimeterShape
.ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true)
.Vertices
);
var obb = RotatingCalipers.MinimumBoundingRectangle(hull);
var normalized = OpenNest.Math.Angle.NormalizeRad(obb.Angle);
if (normalized > 0.001 && normalized < Math.PI - 0.001)
{
angles.Add(normalized);
angles.Add(OpenNest.Math.Angle.NormalizeRad(normalized + Math.PI));
}
}
catch (Exception)
{
// A calipers failure only costs candidate angles, never correctness.
}
}
else if (policy.Kind == RotationPolicyKind.Fixed)
{
angles.Add(policy.Start);
if (policy.Allow180Equivalent)
angles.Add(policy.Start + Math.PI);
}
else
{
// BoundedSweep: enumerate the exact step grid the policy allows.
var count = (int)Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9);
if (count < 0)
count = 0;
if (count > 4000)
count = 4000;
for (var i = 0; i <= count; i++)
{
angles.Add(policy.Start + i * policy.Step);
if (policy.Allow180Equivalent)
angles.Add(policy.Start + i * policy.Step + Math.PI);
}
}
// Normalize to [0, 2pi), deduplicate, preserve first-seen order (deterministic).
var unique = new List<double>();
foreach (var angle in angles)
{
var normalized = OpenNest.Math.Angle.NormalizeRad(angle);
if (normalized < 0)
normalized += 2 * Math.PI;
var duplicate = false;
foreach (var existing in unique)
if (Math.Abs(SignedDelta(existing, normalized)) < 1e-9)
{
duplicate = true;
break;
}
if (!duplicate)
unique.Add(normalized);
}
return unique;
}
private static double SignedDelta(double a, double b)
{
var delta = (a - b) % (2 * Math.PI);
if (delta > Math.PI)
delta -= 2 * Math.PI;
if (delta < -Math.PI)
delta += 2 * Math.PI;
return delta;
}
}
@@ -0,0 +1,930 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
using Math = System.Math;
/// <summary>A committed placement: model, orientation, and origin position on the sheet.</summary>
internal readonly struct PlacedPart
{
public PlacedPart(PartModel model, OrientationModel orientation, double x, double y)
{
Model = model;
Orientation = orientation;
X = x;
Y = y;
}
public PartModel Model { get; }
public OrientationModel Orientation { get; }
public double X { get; }
public double Y { get; }
}
internal readonly struct PlacementResult
{
public PlacementResult(PlacedPart part)
{
Part = part;
}
public PlacedPart Part { get; }
}
/// <summary>
/// One sheet packed by this engine's own algorithm: bottom-left greedy insertion of
/// convex NFP corner candidates.
/// <para>
/// For each candidate orientation the packer builds a convex No-Fit-Polygon per
/// already-placed part as placedHull (+) disk(spacing) (+) reflect(candidateHull) -
/// a superset of the true NFP because hulls ignore concavities and cutouts, so an
/// anchor outside every NFP plus inside the anchor work-box is always legal ("strict"
/// certification). An anchor inside an NFP is still accepted when the exact material
/// gate says the parts clear: that gate inflates the placed part's material by the
/// spacing (holes shrunk, closed holes treated solid) and tests it against the
/// candidate's raw material with holes subtracted - the same inflation rule the
/// benchmark validator uses, so interlocking concave parts are recovered without ever
/// accepting an overlap or a spacing violation.
/// </para>
/// <para>
/// Candidate anchors are the corner points of the feasible region: the four anchor
/// work-box corners, every NFP vertex, and every NFP-edge/box-line crossing (slides).
/// Candidates are tried in ascending bottom-left order and the first legal one wins.
/// </para>
/// </summary>
internal sealed class SheetPacker
{
private const int MaxHullVertices = 40;
private readonly double _workLeft;
private readonly double _workBottom;
private readonly double _workRight;
private readonly double _workTop;
// Per-placed-part caches (indexed by placement order).
private readonly List<Bounds> _inflatedBounds = new();
private readonly List<(Polygon Perimeter, List<Polygon> Holes)> _placedGate = new();
/// <summary>
/// Fast-path outlines of the placed parts' gate material in world coordinates,
/// parallel to <see cref="_placedGate"/> (O(1) translation of the shared per-
/// orientation template). A <see cref="FastPoly.Clears"/> hit certifies the two
/// outer shells - hence both materials - are clear and skips the exact
/// <see cref="Collision"/> gate, which at fine flattening triangulates thousands
/// of edges per call. Null when the outline has no usable ring.
/// </summary>
private readonly List<FastPoly?> _placedGateFast = new();
// NFP caches: (placedIndex, orientationId) -> forbidden-anchor contour.
private readonly Dictionary<(int, int), ConvexContour?> _nfpCache = new();
private readonly Dictionary<OrientationModel, int> _orientationIds = new();
/// <summary>
/// Per-orientation cache of NFP/NFP valley anchors (anchors touching two placed
/// parts at once). A committed part's NFP never changes and <see cref="Placed"/>
/// only grows, so each (i, j) pair is intersected exactly once per orientation
/// instead of once per candidate enumeration - the re-sweep was the dominant cost
/// on crowded sheets (O(placed^2 * edges^2) per insert attempt).
/// </summary>
private sealed class ValleyCache
{
public int BuiltThrough;
public readonly List<(double X, double Y)> Valleys = new();
}
private readonly Dictionary<OrientationModel, ValleyCache> _valleyCaches = new();
private readonly Dictionary<OrientationModel, ConvexContour> _reflectedHulls = new();
private readonly Dictionary<int, ConvexContour> _placedHullDisk = new();
private ConvexContour? _disk;
// Uniform spatial grid over placed parts' inflated bounds: IsLegal only tests the
// parts whose cells touch the candidate's cells, so legality stays near-constant
// as a sheet fills instead of scanning every placed part.
private readonly double _cellSize;
private readonly int _gridCols;
private readonly int _gridRows;
private readonly List<int>[] _grid;
private SheetPacker(NestPlateStock stock, PartPreparation prep, int stockIndex)
{
Stock = stock;
StockIndex = stockIndex;
Preparation = prep;
Spacing = stock.PartSpacing;
var left = stock.Quadrant is 1 or 4 ? 0.0 : -stock.Size.Length;
var bottom = stock.Quadrant is 1 or 2 ? 0.0 : -stock.Size.Width;
_workLeft = left + stock.EdgeSpacing.Left;
_workBottom = bottom + stock.EdgeSpacing.Bottom;
_workRight = left + stock.Size.Length - stock.EdgeSpacing.Right;
_workTop = bottom + stock.Size.Width - stock.EdgeSpacing.Top;
WorkWidth = _workRight - _workLeft;
WorkHeight = _workTop - _workBottom;
// Cells roughly the size of a mid-range part: a candidate usually touches 2-6.
_cellSize = System.Math.Max(1.0, System.Math.Min(WorkWidth, WorkHeight) / 12.0);
_gridCols = System.Math.Max(1, (int)System.Math.Ceiling(WorkWidth / _cellSize));
_gridRows = System.Math.Max(1, (int)System.Math.Ceiling(WorkHeight / _cellSize));
_grid = new List<int>[_gridCols * _gridRows];
for (var i = 0; i < _grid.Length; i++)
_grid[i] = new List<int>();
}
public static SheetPacker Create(NestPlateStock stock, PartPreparation prep, int stockIndex) =>
new(stock, prep, stockIndex);
public NestPlateStock Stock { get; }
public int StockIndex { get; }
public PartPreparation Preparation { get; }
public double Spacing { get; }
public double WorkWidth { get; }
public double WorkHeight { get; }
public List<PlacedPart> Placed { get; } = new();
public bool IsFull => Placed.Count >= MaxPartsPerSheet;
/// <summary>
/// Safety cap on parts per sheet: real sheets never exceed this, and it bounds
/// per-insert NFP work and the validator's area budget on pathological jobs.
/// </summary>
public const int MaxPartsPerSheet = 500;
/// <summary>True when the part's bounds can never fit this sheet in any orientation.</summary>
public bool CanEverFit(PartModel model)
{
foreach (var angle in PartPreparation.CandidateAngles(model))
{
var orientation = Preparation.Oriented(model, angle, 0);
if (orientation.Width <= WorkWidth + 1e-9 && orientation.Height <= WorkHeight + 1e-9)
return true;
}
return false;
}
internal long DiagInsertAttempts;
internal long DiagCandidateChecks;
internal long DiagGateCalls;
internal long DiagConvexRejections;
internal long DiagFastClears;
internal long DiagFastNull;
internal long DiagFastOverlapWithHoles;
internal long DiagFastOverlaps;
internal long DiagFastUnknowns;
public string DiagStats() =>
$"inserts={DiagInsertAttempts} checks={DiagCandidateChecks} gates={DiagGateCalls} " +
$"convexRej={DiagConvexRejections} fastClear={DiagFastClears} fastOver={DiagFastOverlaps} fastUnk={DiagFastUnknowns} fastNull={DiagFastNull} fastOverHoles={DiagFastOverlapWithHoles} triNull={DiagTriNull} triNullOut={DiagTriNullOut} triFallback={DiagTriFallback}";
/// <summary>
/// Greedily insert an instance: best (bottom-left) legal corner over all candidate
/// orientations. Returns false (and changes nothing) when no legal position exists.
/// </summary>
public bool TryInsert(PartModel model, out PlacementResult result)
{
result = default;
var bestScore = double.MaxValue;
PlacedPart? best = null;
DiagInsertAttempts++;
foreach (var angle in PartPreparation.CandidateAngles(model))
{
var orientation = Preparation.Oriented(model, angle, Spacing);
if (orientation.Width > WorkWidth + 1e-9 || orientation.Height > WorkHeight + 1e-9)
continue;
foreach (var (x, y) in OrderedCandidates(orientation))
{
var score = Score(orientation, x, y);
if (score >= bestScore)
continue; // no later candidate (same sort) can beat it
if (!IsLegal(orientation, x, y))
continue;
bestScore = score;
best = new PlacedPart(model, orientation, x, y);
break; // first legal in ascending-score order is this orientation's best
}
}
if (best == null)
return false;
Commit(best.Value);
result = new PlacementResult(best.Value);
return true;
}
private double Score(OrientationModel orientation, double x, double y) =>
x + orientation.MinX + (y + orientation.MinY) * 1.0001;
/// <summary>
/// Corner candidates in deterministic ascending bottom-left order: anchor work-box
/// corners, NFP vertices, and NFP-edge/box-line crossings.
/// </summary>
private List<(double x, double y)> OrderedCandidates(OrientationModel orientation)
{
var boxLeft = _workLeft - orientation.MinX;
var boxRight = _workRight - orientation.MaxX;
var boxBottom = _workBottom - orientation.MinY;
var boxTop = _workTop - orientation.MaxY;
var seen = new HashSet<(long, long)>();
var candidates = new List<(double, double)>(128);
// Math.Clamp throws when min > max, and a part that fits the work area to
// within floating-point noise can invert the anchor box by ~1e-14. Order the
// bounds so a degenerate box collapses to its single legal point.
var anchorMinX = Math.Min(boxLeft, boxRight);
var anchorMaxX = Math.Max(boxLeft, boxRight);
var anchorMinY = Math.Min(boxBottom, boxTop);
var anchorMaxY = Math.Max(boxBottom, boxTop);
void Add(double x, double y)
{
if (x < anchorMinX - 1e-9 || x > anchorMaxX + 1e-9 || y < anchorMinY - 1e-9 || y > anchorMaxY + 1e-9)
return;
x = Math.Clamp(x, anchorMinX, anchorMaxX);
y = Math.Clamp(y, anchorMinY, anchorMaxY);
if (!seen.Add(((long)Math.Round(x * 1e6), (long)Math.Round(y * 1e6))))
return;
candidates.Add((x, y));
}
Add(boxLeft, boxBottom);
Add(boxRight, boxBottom);
Add(boxLeft, boxTop);
Add(boxRight, boxTop);
for (var i = 0; i < Placed.Count; i++)
{
var nfp = NfpFor(i, orientation);
if (nfp == null)
continue;
var n = nfp.Count;
for (var v = 0; v < n; v++)
Add(nfp.X(v), nfp.Y(v));
// Slides: NFP edges crossing the anchor box border lines.
for (var v = 0; v < n; v++)
{
var ax = nfp.X(v);
var ay = nfp.Y(v);
var bx = nfp.X((v + 1) % n);
var by = nfp.Y((v + 1) % n);
CrossLine(ax, ay, bx, by, boxLeft, true, Add);
CrossLine(ax, ay, bx, by, boxRight, true, Add);
CrossLine(ax, ay, bx, by, boxBottom, false, Add);
CrossLine(ax, ay, bx, by, boxTop, false, Add);
}
}
// Valleys between two neighbors: NFP/NFP edge intersections are the anchors
// where the candidate touches two placed parts at once - the classic
// bottom-left stable corners the single-NFP candidates cannot produce.
foreach (var (vx, vy) in ValleysFor(orientation))
Add(vx, vy);
candidates.Sort(
(p, q) =>
{
var byY = p.Item2.CompareTo(q.Item2);
return byY != 0 ? byY : p.Item1.CompareTo(q.Item1);
}
);
return candidates;
}
/// <summary>
/// Cached NFP/NFP valley anchors for one orientation, extended in place with the
/// pairs involving placements committed since the last call. Each (i, j) pair is
/// intersected once per orientation for the packer's lifetime.
/// </summary>
private List<(double X, double Y)> ValleysFor(OrientationModel orientation)
{
if (!_valleyCaches.TryGetValue(orientation, out var cache))
{
cache = new ValleyCache();
_valleyCaches[orientation] = cache;
}
var count = Placed.Count;
for (var j = cache.BuiltThrough; j < count; j++)
{
var nfpB = NfpFor(j, orientation);
if (nfpB == null)
continue;
for (var i = 0; i < j; i++)
{
var nfpA = NfpFor(i, orientation);
if (nfpA == null || !nfpA.Bounds.Intersects(nfpB.Bounds))
continue;
var na = nfpA.Count;
var nb = nfpB.Count;
for (var va = 0; va < na; va++)
{
var a0x = nfpA.X(va);
var a0y = nfpA.Y(va);
var a1x = nfpA.X((va + 1) % na);
var a1y = nfpA.Y((va + 1) % na);
for (var vb = 0; vb < nb; vb++)
{
var b0x = nfpB.X(vb);
var b0y = nfpB.Y(vb);
var b1x = nfpB.X((vb + 1) % nb);
var b1y = nfpB.Y((vb + 1) % nb);
if (
Math.Max(a0x, a1x) < Math.Min(b0x, b1x)
|| Math.Max(b0x, b1x) < Math.Min(a0x, a1x)
|| Math.Max(a0y, a1y) < Math.Min(b0y, b1y)
|| Math.Max(b0y, b1y) < Math.Min(a0y, a1y)
)
continue;
var r = SegmentIntersect(
a0x, a0y, a1x, a1y,
b0x, b0y, b1x, b1y
);
if (r.HasValue)
cache.Valleys.Add((r.Value.X, r.Value.Y));
}
}
}
}
cache.BuiltThrough = count;
return cache.Valleys;
}
/// <summary>Proper or endpoint intersection of two segments, if any.</summary>
private static Vector? SegmentIntersect(
double ax,
double ay,
double bx,
double by,
double cx,
double cy,
double dx,
double dy
)
{
var rx = bx - ax;
var ry = by - ay;
var sx = dx - cx;
var sy = dy - cy;
var denom = rx * sy - ry * sx;
if (Math.Abs(denom) < 1e-12)
return null; // parallel
var t = ((cx - ax) * sy - (cy - ay) * sx) / denom;
var u = ((cx - ax) * ry - (cy - ay) * rx) / denom;
if (t < -1e-9 || t > 1 + 1e-9 || u < -1e-9 || u > 1 + 1e-9)
return null;
return new Vector(ax + t * rx, ay + t * ry);
}
private static void CrossLine(
double ax,
double ay,
double bx,
double by,
double at,
bool vertical,
Action<double, double> add
)
{
var (ua, ub) = vertical ? (ax, bx) : (ay, by);
if (ua == ub)
return;
var t = (at - ua) / (ub - ua);
if (t < 0 || t > 1)
return;
var along = vertical ? ay + (by - ay) * t : ax + (bx - ax) * t;
if (vertical)
add(at, along);
else
add(along, at);
}
private void Commit(PlacedPart part)
{
Placed.Add(part);
var pad = Spacing;
_inflatedBounds.Add(
new Bounds(
part.X + part.Orientation.MinX - pad,
part.Y + part.Orientation.MinY - pad,
part.X + part.Orientation.MaxX + pad,
part.Y + part.Orientation.MaxY + pad
)
);
// Gate geometry: material inflated by spacing (holes shrunk) when positive,
// raw material at zero spacing; already in world coordinates.
var gatePerimeter = part.Orientation.InflatedPerimeter ?? part.Orientation.Perimeter;
var gateHoles = part.Orientation.InflatedPerimeter != null
? part.Orientation.InflatedHoles
: part.Orientation.Holes;
var worldPerimeter = (Polygon)gatePerimeter.Clone();
worldPerimeter.Offset(part.X, part.Y);
worldPerimeter.UpdateBounds();
var worldHoles = new List<Polygon>(gateHoles.Count);
foreach (var hole in gateHoles)
{
var h = (Polygon)hole.Clone();
h.Offset(part.X, part.Y);
h.UpdateBounds();
worldHoles.Add(h);
}
_placedGate.Add((worldPerimeter, worldHoles));
_placedGateFast.Add(part.Orientation.GateFast?.Translated(part.X, part.Y));
GridAdd(Placed.Count - 1, _inflatedBounds[^1]);
}
// ---- uniform spatial grid (cell -> placed indices) --------------------------
private void GridAdd(int placedIndex, in Bounds bounds)
{
var c0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var c1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var r0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
var r1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
_grid[r * _gridCols + c].Add(placedIndex);
}
private readonly HashSet<int> _nearScratch = new();
private HashSet<int> Near(in Bounds bounds)
{
_nearScratch.Clear();
var c0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var c1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var r0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
var r1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
foreach (var index in _grid[r * _gridCols + c])
_nearScratch.Add(index);
return _nearScratch;
}
/// <summary>
/// Legality of one anchor. Outside every overlapping NFP is strict certification;
/// inside one still passes when the exact material gate clears (interlocking
/// concaves and cutouts that the convex NFP cannot represent).
/// </summary>
private bool IsLegal(OrientationModel orientation, double x, double y)
{
if (
x + orientation.MinX < _workLeft - 1e-9
|| x + orientation.MaxX > _workRight + 1e-9
|| y + orientation.MinY < _workBottom - 1e-9
|| y + orientation.MaxY > _workTop + 1e-9
)
return false;
var pad = Spacing;
var candidate = new Bounds(
x + orientation.MinX - pad,
y + orientation.MinY - pad,
x + orientation.MaxX + pad,
y + orientation.MaxY + pad
);
// World-space candidate material, built at most once per anchor and only when
// a convex-NFP hit actually needs the exact gate; freed with the anchor.
(Polygon Perimeter, List<Polygon> Holes)? gate = null;
foreach (var i in Near(candidate))
{
var bounds = _inflatedBounds[i];
if (
candidate.MinX >= bounds.MaxX
|| candidate.MaxX <= bounds.MinX
|| candidate.MinY >= bounds.MaxY
|| candidate.MaxY <= bounds.MinY
)
continue;
// Fast rejection: outside the hull-based NFP the placed and candidate
// HULLS are at least spacing apart, and hulls contain materials, so the
// materials clear - a valid certification for any shape, holed or
// concave. Inside the NFP decides nothing by itself (the hull sum
// over-approximates for concaves and holes), but when both materials are
// convex solids with uncapped hulls the sum is exact (modulo the
// circumscribed disk's chord error, which only ever rejects a hair too
// much), so interior means overlap. Everything else pays the exact
// material gate.
DiagCandidateChecks++;
var nfp = NfpFor(i, orientation);
if (nfp == null)
{
if (!TryPairVerdict(orientation, x, y, i, out var nullNfpOverlap))
{
DiagGateCalls++;
gate ??= BuildCandidateGate(orientation, x, y);
nullNfpOverlap = MaterialOverlap(gate.Value, orientation, x, y, i);
}
if (nullNfpOverlap)
return false;
continue;
}
if (!nfp.ContainsPoint(x, y))
continue; // outside the conservative forbidden sum: certified clear
if (
orientation.IsConvexSolid
&& Placed[i].Orientation.IsConvexSolid
&& orientation.Hull.Count <= MaxHullVertices
&& Placed[i].Orientation.Hull.Count <= MaxHullVertices
)
{
DiagConvexRejections++;
return false; // exact convex-convex NFP interior: overlap
}
// Cheap world-bbox test against the placed gate material before paying
// for candidate gate construction or the clipper.
if (
!_placedGate[i]
.Perimeter.BoundingBox
.Intersects(orientation.Perimeter.BoundingBox.Translate(x, y))
)
continue;
// Fast shell relation against the placed gate outline: a certified clear
// skips the exact gate entirely (no Polygon clones, no triangulation), a
// certified overlap rejects without it. Hole-bearing pairs and touches fall
// through to the exact gate.
if (TryPairVerdict(orientation, x, y, i, out var fastOverlap))
{
if (fastOverlap)
return false;
continue;
}
DiagGateCalls++;
gate ??= BuildCandidateGate(orientation, x, y);
if (MaterialOverlap(gate.Value, orientation, x, y, i))
return false;
}
return true;
}
/// <summary>
/// Fast outer-shell relation for one (candidate, placed) pair against the placed
/// part's spacing-inflated gate outline, deciding whether the exact material gate
/// must run. A CERTIFIED verdict skips it: disjoint shells mean no material overlap
/// (holes only remove material), and a shell crossing/containment between two
/// hole-free polygons IS a positive-area material overlap. Hole-bearing pairs whose
/// shells overlap and near-degenerate touches fall through to the exact gate.
/// </summary>
private bool TryPairVerdict(
OrientationModel orientation,
double x,
double y,
int placedIndex,
out bool overlap
)
{
overlap = false;
var placedFast = _placedGateFast[placedIndex];
var candidateFast = orientation.PerimeterFast;
if (placedFast == null || candidateFast == null)
{
DiagFastNull++;
return false;
}
var relation = FastPoly.Relate(candidateFast.Translated(x, y), placedFast);
if (relation == FastPoly.FastRelation.Overlap)
DiagFastOverlapWithHoles++;
switch (relation)
{
case FastPoly.FastRelation.Clear:
DiagFastClears++;
return true; // certified clear (spacing included in the placed gate)
case FastPoly.FastRelation.Overlap
when orientation.Holes.Count == 0 && _placedGate[placedIndex].Holes.Count == 0:
DiagFastOverlaps++;
overlap = true; // certified overlap: shells share area, nothing to subtract
return true;
default:
DiagFastUnknowns++;
return false; // exact gate must decide
}
}
private static readonly bool VerifyFastClear =
Environment.GetEnvironmentVariable("QWEN_VERIFY_FASTCLEAR") == "1";
internal long DiagFastClearMismatch;
internal long DiagTriMismatch;
internal long DiagTriNull;
internal long DiagTriNullOut;
internal long DiagTriFallback;
/// <summary>
/// Exact clearance gate against one placed part: placed gate material (inflated by
/// spacing when positive) versus the candidate's raw material with holes subtracted.
/// </summary>
private bool MaterialOverlap(
(Polygon Perimeter, List<Polygon> Holes) gate,
OrientationModel orientation,
double x,
double y,
int placedIndex
)
{
var placed = _placedGate[placedIndex];
if (!placed.Perimeter.BoundingBox.Intersects(orientation.Perimeter.BoundingBox.Translate(x, y)))
return false;
if (!gate.Perimeter.BoundingBox.Intersects(placed.Perimeter.BoundingBox))
return false;
var placedPart = Placed[placedIndex];
// Allocation-free path: both sides carry cached triangulations in their local
// frames, so the test clips triangles with the anchors as plain translations.
var candTris = orientation.MaterialTris;
var placedTris = placedPart.Orientation.GateTris;
if (candTris == null || placedTris == null)
DiagTriNull++;
if (candTris != null && placedTris != null)
{
var cached = candTris.HasOverlap(
placedTris, x, y, placedPart.X, placedPart.Y
);
if (cached.HasValue)
{
if (VerifyFastClear)
{
var truth = Collision.HasOverlap(
gate.Perimeter, placed.Perimeter, gate.Holes, placed.Holes
);
if (truth != cached.Value)
{
DiagTriMismatch++;
System.IO.File.AppendAllText(
"/tmp/triset_mismatch.log",
$"cached={cached.Value} truth={truth} candAng={orientation.Angle:F4} at ({x:F8},{y:F8}) " +
$"placedAng={placedPart.Orientation.Angle:F4} at ({placedPart.X:F8},{placedPart.Y:F8}) " +
$"candTris={candTris} candVerts={orientation.Perimeter.Vertices.Count} holes={orientation.Holes.Count} " +
$"placedVerts={placedPart.Orientation.Perimeter.Vertices.Count} placedHoles={placedPart.Orientation.Holes.Count}\n"
);
}
}
return cached.Value;
}
DiagTriNullOut++;
// Scratch overflow: fall through to the Polygon gate.
}
DiagTriFallback++;
return Preparation.MaterialOverlapMemo(
placedPart.Orientation,
placedPart.X,
placedPart.Y,
orientation,
x,
y,
() => Collision.HasOverlap(
gate.Perimeter,
placed.Perimeter,
gate.Holes,
placed.Holes
)
);
}
private (Polygon, List<Polygon>) BuildCandidateGate(OrientationModel orientation, double x, double y)
{
var perimeter = (Polygon)orientation.Perimeter.Clone();
perimeter.Offset(x, y);
perimeter.UpdateBounds();
var holes = new List<Polygon>(orientation.Holes.Count);
foreach (var hole in orientation.Holes)
{
var h = (Polygon)hole.Clone();
h.Offset(x, y);
h.UpdateBounds();
holes.Add(h);
}
return (perimeter, holes);
}
private int OrientationId(OrientationModel orientation)
{
if (!_orientationIds.TryGetValue(orientation, out var id))
{
id = _orientationIds.Count;
_orientationIds[orientation] = id;
}
return id;
}
/// <summary>
/// Convex NFP of forbidden anchors: placedHull (+) disk(spacing) (+) reflect(candidateHull).
/// </summary>
private ConvexContour? NfpFor(int placedIndex, OrientationModel orientation)
{
var key = (placedIndex, OrientationId(orientation));
if (_nfpCache.TryGetValue(key, out var cached))
return cached;
ConvexContour? result;
try
{
if (!_placedHullDisk.TryGetValue(placedIndex, out var placedDisk))
{
var placed = Placed[placedIndex];
var hull = placed.Orientation.Hull;
var capped = CapHull(hull, placed.X, placed.Y);
var placedHull = ConvexContour.FromVertices(capped);
placedDisk = Spacing > Tolerance.Epsilon
? NfpGeometry.Minkowski(placedHull, Disk())
: placedHull;
_placedHullDisk[placedIndex] = placedDisk;
}
if (!_reflectedHulls.TryGetValue(orientation, out var reflected))
{
var capped = CapHull(orientation.Hull, 0, 0);
reflected = NfpGeometry.Reflect(ConvexContour.FromVertices(capped));
_reflectedHulls[orientation] = reflected;
}
result = NfpGeometry.Minkowski(placedDisk, reflected);
}
catch (Exception)
{
// A degenerate Minkowski sum removes the fast rejection for this pair;
// the material gate still enforces correctness.
result = null;
}
_nfpCache[key] = result;
return result;
}
/// <summary>
/// Bounded-size convex SUPERSET of <paramref name="hull"/> (translated by dx/dy).
/// When the hull is dense, keep every k-th vertex, then shift each chord's
/// supporting line outward by the chord's maximum sagitta (the largest distance of
/// any dropped vertex to its chord). Every dropped vertex lies within the sagitta
/// of its chord, so the offset half-plane intersection contains the original hull
/// and the NFP built from it stays a conservative superset of the forbidden anchors.
/// </summary>
private static List<Vector> CapHull(List<Vector> hull, double dx, double dy)
{
var n = hull.Count;
var shifted = new List<Vector>(n);
for (var i = 0; i < n; i++)
shifted.Add(new Vector(hull[i].X + dx, hull[i].Y + dy));
if (n <= MaxHullVertices)
return shifted;
// Chord (v_i, v_{i+k}) for i in steps of k, with each chord's outward shift:
// the max perpendicular distance from any vertex it spans to the chord line.
var k = (int)Math.Ceiling(n / (double)MaxHullVertices);
var lines = new List<(double ax, double ay, double bx, double by, double shift)>();
for (var i = 0; i < n; i += k)
{
var a = shifted[i];
var b = shifted[(i + k) % n];
var span = Math.Min(k, n - i);
var sagitta = 0.0;
var length = Math.Sqrt((b.X - a.X) * (b.X - a.X) + (b.Y - a.Y) * (b.Y - a.Y));
if (length > 1e-12)
for (var j = 1; j < span; j++)
{
var p = shifted[i + j];
var distance = Math.Abs(Cross(a.X, a.Y, b.X, b.Y, p)) / length;
if (distance > sagitta)
sagitta = distance;
}
lines.Add((a.X, a.Y, b.X, b.Y, sagitta));
}
// Sutherland-Hodgman from a generous bounding box; the interior of each chord
// is the CCW left side, shifted outward (left) by the sagitta.
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var v in shifted)
{
if (v.X < minX)
minX = v.X;
if (v.X > maxX)
maxX = v.X;
if (v.Y < minY)
minY = v.Y;
if (v.Y > maxY)
maxY = v.Y;
}
var margin = Math.Max(1.0, Math.Max(maxX - minX, maxY - minY));
var polygon = new List<Vector>
{
new(minX - margin, minY - margin),
new(maxX + margin, minY - margin),
new(maxX + margin, maxY + margin),
new(minX - margin, maxY + margin),
};
foreach (var (ax, ay, bx, by, shift) in lines)
{
if (polygon.Count == 0)
return shifted; // degenerate; fall back to full hull
// Shift the line perpendicular away from the interior (CCW: interior is left).
var edgeX = bx - ax;
var edgeY = by - ay;
var length = Math.Sqrt(edgeX * edgeX + edgeY * edgeY);
if (length <= 1e-12)
continue;
var nx = edgeY / length;
var ny = -edgeX / length;
var ox = ax + nx * shift;
var oy = ay + ny * shift;
var input = polygon;
polygon = new List<Vector>();
for (var i = 0; i < input.Count; i++)
{
var current = input[i];
var next = input[(i + 1) % input.Count];
var currentInside = Cross(ox, oy, ox + edgeX, oy + edgeY, current) >= 0;
var nextInside = Cross(ox, oy, ox + edgeX, oy + edgeY, next) >= 0;
if (currentInside)
{
polygon.Add(current);
if (!nextInside)
polygon.Add(Intersect(ox, oy, ox + edgeX, oy + edgeY, current, next));
}
else if (nextInside)
{
polygon.Add(Intersect(ox, oy, ox + edgeX, oy + edgeY, current, next));
}
}
}
return polygon.Count >= 3 ? polygon : shifted;
}
private static double Cross(double ax, double ay, double bx, double by, Vector p) =>
(bx - ax) * (p.Y - ay) - (by - ay) * (p.X - ax);
private static Vector Intersect(
double ax,
double ay,
double bx,
double by,
Vector p,
Vector q
)
{
var dx1 = bx - ax;
var dy1 = by - ay;
var dx2 = q.X - p.X;
var dy2 = q.Y - p.Y;
var cross = dx1 * dy2 - dy1 * dx2;
if (Math.Abs(cross) < 1e-300)
return p;
var t = ((p.X - ax) * dy2 - (p.Y - ay) * dx2) / cross;
return new Vector(ax + t * dx1, ay + t * dy1);
}
private ConvexContour Disk() =>
// Circumscribed so the polygon contains the true spacing disk: the NFP stays a
// conservative superset of the forbidden-anchor region.
_disk ??= ConvexContour.Disk(Spacing / Math.Cos(Math.PI / 24), 24);
}
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Shared settings and the OpenNest.Engine reference come from Directory.Build.props. -->
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Engine.Qwen38FlashNext.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
using System;
using System.Threading;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Qwen38FlashNext.Engine;
namespace OpenNest.Engine.Qwen38FlashNext;
/// <summary>
/// Independent whole-job nesting engine: bottom-left-first placement over convex
/// No-Fit-Polygons with an exact material-clearance gate, driven sheet by sheet by a
/// greedy demand scheduler.
/// <para>
/// Per sheet, parts are demanded in the engine's own order (priority, then the
/// largest material area, then id) and each requirement is drained greedily. For a part instance the engine enumerates its
/// legal orientations (policy angles, or 0/90/180/270 plus the rotating-calipers
/// minimum bounding rectangle for automatic rotation), builds for every placed part a
/// convex NFP as placedHull (+) disk(spacing) (+) reflect(candidateHull) via its own
/// Minkowski edge-merge, generates the corner-point feasible-region candidates (anchor
/// box corners, NFP vertices, NFP-edge/box-line slides), and places the instance at the
/// lowest-leftmost candidate whose exact material clearance the engine's collision gate
/// accepts (cached-triangulation clip with a sound fast-shell prefilter). Which stock
/// the next sheet uses is chosen by re-packing each available size and committing the
/// trial that delivers the cheapest plate area per unit of part area placed; the
/// job stops when demand is met, stock runs out, nothing further can be placed, or the
/// plate cap is hit. See Engine/ for the placement core and README.md for the design
/// write-up.
/// </para>
/// <para>
/// The engine is self-contained: it calls no built-in <see cref="INestingEngine"/>,
/// nester, filler, or runner, and is deterministic - identical input, identical layout.
/// </para>
/// </summary>
public sealed class Qwen38FlashNextNestingEngine : INestingEngine
{
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
var preparation = new PartPreparation(job.Parts);
var solver = new JobSolver(job, preparation);
return solver.Solve(progress, token);
}
}
+109
View File
@@ -0,0 +1,109 @@
# OpenNest.Engine.Qwen38FlashNext
An independent whole-job `INestingEngine` built by Qwen3.8-Flash-Next: **bottom-left greedy
insertion over convex no-fit polygons with an exact clearance gate**. It does not call, wrap,
or select over any built-in engine, nester, filler, or runner.
## Algorithm
Bottom-left greedy insertion over convex No-Fit-Polygons, with an exact material-clearance
gate, driven sheet by sheet by a greedy demand scheduler. All geometry math is the engine's
own (`Engine/`); it calls no built-in nester, filler, or runner.
- **`PartPreparation`** rebuilds each snapshot into a closed contour topology (perimeter +
cutouts; rapids/scribe marks dropped), flattens it circumscribed (the collision polygon
always contains the true material), and caches per-(part, angle, spacing) geometry: bounds,
convex hull, and the spacing-inflated outline **rotated into that orientation's frame**
(offset commutes with rotation; an unrotated inflation tests the candidate against the
material of a different angle - this was a real overlap bug, caught by
`RotatedConcavePartsKeepSpacingAtFixedAngles`). Candidate angles are the policy angles, or
0/90/180/270 plus the rotating-calipers minimum-bounding-rectangle angle for automatic
rotation.
- **`SheetPacker`** places one part instance at a time. Per already-placed part it builds a
convex NFP as `placedHull (+) disk(spacing) (+) reflect(candidateHull)` via its own
Minkowski edge-merge (`Convex.cs`; the merge picks the more-clockwise frontier edge, an
inverted comparison here corrupts every non-parallel sum into a self-intersecting contour),
then enumerates corner-point candidates: anchor work-box corners, NFP vertices, and
NFP-edge/box-line slides, tried in ascending bottom-left order. Because the NFP is
hull-based it only *certifies* clearance when both parts are convex solids with uncapped
hulls; everything else falls through to the exact gate - placed material inflated by the
spacing (holes shrunk, closed holes treated solid) versus the candidate's raw material with
holes subtracted, the same inflation rule the benchmark validator uses, so interlocking
concave parts are placed legally where the convex NFP alone would reject them. A uniform
spatial grid keeps the pair tests near-constant as the sheet fills, and an overlap memo
keyed by world pose collapses repeated clipper work across stock trials.
- **`FastPoly` / `CachedCollision` (`TriSet`)** make the exact gate cheap. Each orientation
caches flat-array triangulations of its raw material and its spacing-inflated gate
material; a candidate-vs-placed pair then runs the built-in clipper algorithm on plain
double arrays with the anchor offsets as translations - no `Polygon` clones, no
per-check re-triangulation, no LINQ. A uniform edge grid on the gate outlines certifies
disjoint shell pairs (clear) and convex-solid crossings (overlap) before any clip work;
the certification is three-state (touch and collinear contact defer to the clip,
containment is decided by sampled interior tests) so it can never report a false clear -
cross-validated against `Collision.HasOverlap` over ~2.5M decisions per job run with
zero verdict mismatches, and hole-clipping overflow falls back to the exact `Polygon`
gate (0.2% of checks on the production job below).
- **`JobSolver`** walks demands in its own order (priority, then largest material area -
big parts first lay down the sheet skeleton the small parts fill against; measured 12%
lower job cost than smallest-extent-first on the production job below) and drains each greedily,
then a time-boxed gap-fill pass. For the next sheet it trials *every* available stock
size independently and commits the trial delivering the cheapest plate area per unit of
part area placed (the benchmark's cost function), breaking ties by priority coverage,
instance count, then plate area; lost trials change no job state. The job stops on met
demand, exhausted stock, no further placement, or the plate cap. Deterministic:
identical input, identical layout.
Trade-offs: greedy BLFG insertion leaves some of the density interlocking-pair and
compaction pipelines find on regular jobs, and every stock size is trialled per sheet
(O(sheets x stocks x fill)); on the 69-drawing/219-part production job below that costs
~110 s against the benchmark's 5-minute per-solve timeout. In exchange it places arcs,
concaves, and holed parts under one uniform gate with no per-shape-class special cases.
## Benchmark results
A real laser-cutting production job: 69 drawings, 219 parts, 3/16 mild steel, spacing 0.3,
`--parallel 1`, same OpenNest build for every engine.
| Sheet sizes offered | Result | Sheets | Utilization | Cost | Time |
|---|---|---|---|---|---|
| The job's own 4 sizes (60x96, 60x120, 72x120, 48x144) | valid, 219/219 | 28 | 78.4% | 219,744 | ~106 s |
| OpenNest's standard 9-size catalog | valid, 219/219 | 14 | 56.6% | 304,128 | ~132 s |
It uses the fewest sheets of any engine tested, but not the least material. The shop's
original hand layout used 29 sheets (191,232 sq in). **Known weakness:** sheet choice is
greedy one sheet at a time, so with large stock available it grabs 96x240 sheets and
under-fills them.
Optimization history on this job (all valid, 219/219): count-first trial scoring and
span-first demand order cost 258048/39 plates; cost-first trial scoring brought it to
249696 (39); area-first demand order to 219744 (28). Wall time went from timeout (>400 s)
to ~110 s via the cached-triangulation exact gate and the fast shell prefilter.
## Tests
`tests/` holds acceptance tests whose layouts are checked by the benchmark's own
`NestValidator` (bounds, spacing, quantities, stock, rotation), plus NFP geometry tests and a
rotated-concave spacing regression test.
```bash
dotnet test OpenNest.Engine.Qwen38FlashNext/tests/OpenNest.Engine.Qwen38FlashNext.Tests.csproj
```
## Build and benchmark
The project is a plugin outside `OpenNest.sln`. `OpenNest.Benchmark` loads plugin engines
from an `Engines/` folder next to its own build output:
```bash
dotnet build OpenNest.Engine.Qwen38FlashNext/OpenNest.Engine.Qwen38FlashNext.csproj -c Release
dotnet build <OpenNest>/OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
mkdir -p <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/Engines
cp OpenNest.Engine.Qwen38FlashNext/bin/Release/net8.0/OpenNest.Engine.Qwen38FlashNext.dll <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/Engines/
dotnet <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <path-to-.nest-or-folder> --parallel 1
```
`<OpenNest>` is the OpenNest checkout root. Or build and deploy in one step with
`./Build-Engines.ps1 -Engines Qwen38FlashNext`. The engine appears in reports as
`Qwen38FlashNextNestingEngine`.
@@ -0,0 +1,151 @@
using System;
using Xunit;
using OpenNest.Geometry;
using OpenNest.Engine.Qwen38FlashNext.Engine;
namespace OpenNest.Engine.Qwen38FlashNext.Tests;
/// <summary>
/// These tests target the engine's internal NFP math through its public surface
/// (SheetPacker via reflection is overkill; ConvexContour/NfpGeometry are internal,
/// so InternalsVisibleTo is required).
/// </summary>
public class NfpGeometryTests
{
private static ConvexContour Square(double x0, double y0, double x1, double y1) =>
ConvexContour.FromVertices(
new[]
{
new Vector(x0, y0),
new Vector(x1, y0),
new Vector(x1, y1),
new Vector(x0, y1),
}
);
[Fact]
public void MinkowskiOfTwoSquaresIsTheExpectedRectangle()
{
var a = Square(0, 0, 10, 10);
var b = Square(-5, -5, 5, 5); // centered square, side 10
var sum = NfpGeometry.Minkowski(a, b);
// [0,10]^2 + [-5,5]^2 = [-5,15]^2
Assert.Equal(-5, sum.MinX, 6);
Assert.Equal(-5, sum.MinY, 6);
Assert.Equal(15, sum.MaxX, 6);
Assert.Equal(15, sum.MaxY, 6);
// Strict containment sanity: center inside, far corner outside.
Assert.True(sum.ContainsPoint(0, 0));
Assert.True(sum.ContainsPoint(14.9, 14.9));
Assert.False(sum.ContainsPoint(20, 20));
var n = sum.Count;
for (var i = 0; i < n; i++)
{
var ax = sum.X(i);
var ay = sum.Y(i);
var bx = sum.X((i + 1) % n);
var by = sum.Y((i + 1) % n);
var cx = sum.X((i + 2) % n);
var cy = sum.Y((i + 2) % n);
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
Assert.True(cross >= -1e-9, $"non-convex (clockwise) turn at vertex {i} of Minkowski result");
}
}
[Fact]
public void MinkowskiOfTrianglesIsConvexAndContainsTheSums()
{
var a = ConvexContour.FromVertices(
new[] { new Vector(0, 0), new Vector(10, 0), new Vector(0, 10) }
);
var b = ConvexContour.FromVertices(
new[] { new Vector(0, 0), new Vector(4, 0), new Vector(0, 4) }
);
var sum = NfpGeometry.Minkowski(a, b);
// Vertex sums must lie on the boundary of the true Minkowski sum.
Assert.True(sum.ContainsPoint(1, 1));
Assert.True(sum.ContainsPoint(9, 1));
Assert.True(sum.ContainsPoint(1, 12));
var n = sum.Count;
for (var i = 0; i < n; i++)
{
var ax = sum.X(i);
var ay = sum.Y(i);
var bx = sum.X((i + 1) % n);
var by = sum.Y((i + 1) % n);
var cx = sum.X((i + 2) % n);
var cy = sum.Y((i + 2) % n);
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
Assert.True(cross >= -1e-9, $"non-convex turn at vertex {i}");
}
}
[Fact]
public void ReflectPreservesCcwWinding()
{
var a = Square(0, 0, 10, 10);
var r = NfpGeometry.Reflect(a);
Assert.Equal(-10, r.MinX, 6);
Assert.Equal(-10, r.MinY, 6);
Assert.Equal(0, r.MaxX, 6);
Assert.Equal(0, r.MaxY, 6);
var n = r.Count;
for (var i = 0; i < n; i++)
{
var ax = r.X(i);
var ay = r.Y(i);
var bx = r.X((i + 1) % n);
var by = r.Y((i + 1) % n);
var cx = r.X((i + 2) % n);
var cy = r.Y((i + 2) % n);
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
Assert.True(cross >= -1e-9, $"Reflect produced a non-CCW contour at vertex {i}");
}
}
[Fact]
public void NfpOfTwoSquaresIsTheForbiddenAnchorSquare()
{
// Placed [0,10]^2, candidate [0,10]^2, zero spacing: NFP of forbidden
// anchors = placed (+) reflect(candidate) = (-10,10)^2. Anchors strictly
// inside it overlap; anchors outside it clear.
var placed = Square(0, 0, 10, 10);
var candidate = Square(0, 0, 10, 10);
var nfp = NfpGeometry.Minkowski(placed, NfpGeometry.Reflect(candidate));
Assert.Equal(-10, nfp.MinX, 6);
Assert.Equal(-10, nfp.MinY, 6);
Assert.Equal(10, nfp.MaxX, 6);
Assert.Equal(10, nfp.MaxY, 6);
Assert.True(nfp.ContainsPoint(5, 5)); // overlap
Assert.True(nfp.ContainsPoint(-5, -5)); // overlap
// Boundary contact counts as forbidden (conservative): the fast-path
// certification only accepts anchors CLEAR of the NFP; contact defers to
// the exact material gate.
Assert.True(nfp.ContainsPoint(10, 0));
Assert.False(nfp.ContainsPoint(0, 10.001)); // beyond top, legal
var n = nfp.Count;
for (var i = 0; i < n; i++)
{
var ax = nfp.X(i);
var ay = nfp.Y(i);
var bx = nfp.X((i + 1) % n);
var by = nfp.Y((i + 1) % n);
var cx = nfp.X((i + 2) % n);
var cy = nfp.Y((i + 2) % n);
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
Assert.True(cross >= -1e-9, $"non-convex turn at vertex {i}");
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Engine.Qwen38FlashNext.csproj" />
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
<ProjectReference Include="$(OpenNestRoot)OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Benchmark;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Qwen38FlashNext.Tests;
/// <summary>
/// Starter acceptance tests. Every layout is checked by the same NestValidator the benchmark
/// scores with, so a passing test means the benchmark will accept the layout. They fail until
/// Solve() is implemented; add engine-specific tests alongside them.
/// </summary>
public class Qwen38FlashNextNestingEngineTests
{
[Fact]
public void HasPublicParameterlessConstructorForPluginDiscovery()
{
var engine = Activator.CreateInstance(typeof(Qwen38FlashNextNestingEngine));
Assert.IsAssignableFrom<INestingEngine>(engine);
}
[Fact]
public void RectanglesFitOnOneSheetWithSpacing()
{
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Assert.Equal(12, result.Plates[0].Placements.Count);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
{
var job = Job(
new[]
{
Part("disc", Disc(3), 10),
Part("ell", LShape(12, 8, 4), 10),
Part("tri", Triangle(9, 6), 10),
},
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
);
var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void RotatedConcavePartsKeepSpacingAtFixedAngles()
{
// Regression: the per-orientation spacing inflation must live in the rotated
// frame. L-shapes pinned to 90/270 degrees exercise exactly the orientations
// where an unrotated inflation misrepresents the material and lets parts
// rest closer than the spacing.
var l = Part(
"l90",
LShape(12, 8, 4),
8,
RotationPolicy.Fixed(System.Math.PI / 2, allow180Equivalent: true)
);
var job = Job(new[] { l }, new[] { Stock("sheet", 40, 60, spacing: 0.5) });
var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void OverflowSpillsOntoAdditionalSheets()
{
var job = Job(new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) });
var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Count > 1);
}
[Fact]
public void PartTooBigForAnySheetIsReportedUnplaced()
{
var job = Job(
new[] { Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(5, 5), 4) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) }
);
var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result);
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
Assert.Equal(1, huge.Unplaced);
}
// ---- helpers -------------------------------------------------------------------------
private static void AssertValid(NestJob job, NestJobResult result)
{
var materialized = NestResultMaterializer.Materialize(job, result);
var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList();
var requirements = job.Parts.ToDictionary<NestJobPart, Drawing, (string Name, int Quantity)>(
p => materialized.DrawingsByPartId[p.Id],
p => (p.Id, p.Quantity),
ReferenceEqualityComparer.Instance
);
var validation = NestValidator.Validate(runs, requirements);
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations));
foreach (var f in result.Fulfillment)
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
}
private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
new(parts, stock, options);
private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) =>
new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation);
/// <param name="width">Y extent.</param>
/// <param name="length">X extent.</param>
private static NestPlateStock Stock(
string id,
double width,
double length,
double spacing = 0,
Spacing edge = default,
int quadrant = 1,
int? quantity = null
) => new(id, new Size(width, length), quantity, spacing, edge, quadrant);
private static Program Polyline(params (double X, double Y)[] points)
{
var program = new Program();
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
foreach (var (x, y) in points.Skip(1))
program.Codes.Add(new LinearMove(x, y));
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
return program;
}
private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
private static Program Disc(double r)
{
var program = new Program();
program.Codes.Add(new RapidMove(r, 0));
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
return program;
}
}