diff --git a/OpenNest.Engine.Opus55/README.md b/OpenNest.Engine.Opus55/README.md
index 9606d40..c63ba4a 100644
--- a/OpenNest.Engine.Opus55/README.md
+++ b/OpenNest.Engine.Opus55/README.md
@@ -65,8 +65,7 @@ dotnet build OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj -c Release
dotnet test OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj
```
-This project is intentionally **outside** `OpenNest.sln`, the same pattern as the
-`OpenNest.Engine.Aurora` plugin. It's discovered at runtime as a plugin.
+This project is intentionally **outside** `OpenNest.sln`. It's discovered at runtime as a plugin.
## Benchmark
diff --git a/OpenNest.Engine.Qwen38FlashNext/Engine/CachedCollision.cs b/OpenNest.Engine.Qwen38FlashNext/Engine/CachedCollision.cs
new file mode 100644
index 0000000..b1c1165
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/Engine/CachedCollision.cs
@@ -0,0 +1,544 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Qwen38FlashNext.Engine;
+
+using Math = System.Math;
+
+///
+/// A closed polygon pre-triangulated into flat arrays for allocation-free overlap
+/// tests. The ear-clip of runs ONCE per
+/// (shape, orientation); per-pair tests then clip cached triangles directly. The
+/// built-in 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.
+///
+/// Overlap semantics replicate 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.
+///
+///
+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; }
+
+ /// Triangulated holes in the same local frame (empty array when none).
+ 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;
+ }
+
+ ///
+ /// Ear-clips a polygon ring into cached triangles. Returns null when
+ /// triangulation yields nothing usable - the caller falls back to Polygon gates.
+ ///
+ public static TriSet? Build(Polygon polygon, IReadOnlyList? 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ [ThreadStatic]
+ private static List? s_pool;
+
+ [ThreadStatic]
+ private static double[]? s_tmpA;
+
+ [ThreadStatic]
+ private static double[]? s_tmpB;
+
+ private static double[] AcquireBuffer()
+ {
+ var pool = s_pool ??= new List();
+ 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();
+ 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();
+
+ 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 bufs,
+ List counts
+ )
+ {
+ for (var i = 0; i < bufs.Count; i++)
+ yield return (bufs[i], counts[i]);
+ }
+
+ /// Clip this' triangle against other's triangle; returns count into piece.
+ 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;
+ }
+
+ ///
+ /// Sutherland-Hodgman clip against one directed edge's half-plane; identical
+ /// classification, interpolation and dedupe to Collision.ClipHalfSpace.
+ ///
+ 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;
+ }
+
+ /// Twice the area, relative to vertex 0 (cancellation-safe).
+ 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);
+ }
+}
diff --git a/OpenNest.Engine.Qwen38FlashNext/Engine/Convex.cs b/OpenNest.Engine.Qwen38FlashNext/Engine/Convex.cs
new file mode 100644
index 0000000..2d9893b
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/Engine/Convex.cs
@@ -0,0 +1,383 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Qwen38FlashNext.Engine;
+
+using Math = System.Math;
+
+///
+/// Axis-aligned bounding box with no allocation and inclusive intersection tests.
+///
+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;
+}
+
+///
+/// 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.
+///
+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;
+
+ /// Index of the lexicographic (Y, X) minimum vertex.
+ 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 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));
+ }
+
+ /// Regular 2^k-gon approximating a disk of the given radius (convex CCW).
+ 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;
+
+ ///
+ /// Containment with a 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// The vertical span [lo, hi] of the contour's cross-section at x, when x is
+ /// strictly inside its x-range (inset by ); false otherwise.
+ ///
+ 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;
+ }
+
+ /// The horizontal span at y, inset like .
+ 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);
+}
+
+///
+/// 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.
+///
+internal static class NfpGeometry
+{
+ ///
+ /// 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.
+ ///
+ public static ConvexContour Reflect(ConvexContour contour)
+ {
+ var n = contour.Count;
+ var points = new List(n);
+ for (var i = 0; i < n; i++)
+ points.Add(new Vector(-contour.X(i), -contour.Y(i)));
+ return ConvexContour.FromVertices(points);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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(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);
+ }
+}
diff --git a/OpenNest.Engine.Qwen38FlashNext/Engine/FastPoly.cs b/OpenNest.Engine.Qwen38FlashNext/Engine/FastPoly.cs
new file mode 100644
index 0000000..d04212f
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/Engine/FastPoly.cs
@@ -0,0 +1,461 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Qwen38FlashNext.Engine;
+
+using Math = System.Math;
+
+///
+/// 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. returns true only in that certified case and false
+/// whenever anything touches, so it can only ever skip the exact
+/// 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.
+///
+/// A holds the shared geometry;
+/// produces a placement in world coordinates in O(1) - translation leaves the grid and
+/// all cell indices unchanged, only the predicate coordinates shift.
+///
+///
+internal sealed class FastPoly
+{
+ /// Vertex-on-segment / collinearity tolerance for conservative touches.
+ private const double TouchEps = 1e-9;
+
+ private readonly FastPolyTemplate _template;
+
+ /// Translation applied to the shared template geometry.
+ 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;
+
+ ///
+ /// Builds from a closed (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.
+ ///
+ 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;
+
+ ///
+ /// True when this and are CERTIFIED clear: their
+ /// boundaries neither cross nor touch (within ) 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.
+ ///
+ public static bool Clears(FastPoly a, FastPoly b) => Relate(a, b) == FastRelation.Clear;
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// True when any vertex of lies strictly inside
+ /// , 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.
+ ///
+ 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;
+ }
+
+ /// Three-state outcome of .
+ public enum FastRelation
+ {
+ /// Shells certified disjoint: any materials inside them are clear.
+ Clear,
+
+ /// Shells share positive area (crossing or containment).
+ Overlap,
+
+ /// Boundary touch too close to classify: consult the exact gate.
+ Unknown,
+ }
+
+ ///
+ /// True when any edge of crosses or touches the boundary of
+ /// . 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+
+ /// Strict ray-cast containment (boundary touches are excluded upstream).
+ 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);
+}
+
+///
+/// Shared, immutable grid geometry for ; 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.
+///
+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;
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+}
diff --git a/OpenNest.Engine.Qwen38FlashNext/Engine/JobSolver.cs b/OpenNest.Engine.Qwen38FlashNext/Engine/JobSolver.cs
new file mode 100644
index 0000000..77fa5a2
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/Engine/JobSolver.cs
@@ -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);
+
+///
+/// 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 ; nothing here delegates to a
+/// built-in engine, nester, filler, or runner.
+///
+internal sealed class JobSolver
+{
+ private readonly NestJob _job;
+ private readonly PartPreparation _prep;
+ private readonly Dictionary _remaining;
+ private readonly Dictionary _placed;
+ private readonly Dictionary _used;
+ private readonly List _sheets = new();
+
+ private sealed record CommittedSheet(int StockIndex, List 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? 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 OutstandingDemands()
+ {
+ var demands = new List();
+ 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 _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 _maximumSpan = new(StringComparer.Ordinal);
+
+ ///
+ /// 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.
+ ///
+ private SheetAttempt? BestNextSheet(
+ List outstanding,
+ IProgress? 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private void FillSheet(SheetPacker packer, List outstanding, CancellationToken token)
+ {
+ var available = new Dictionary(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;
+ }
+ }
+ }
+ }
+
+ ///
+ /// 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).
+ ///
+ private static readonly int DemandOrderMode =
+ int.TryParse(Environment.GetEnvironmentVariable("QWEN_DEMAND_ORDER"), out var m)
+ ? m
+ : 1;
+
+ /// Wall-clock budget for one sheet's gap-fill pass.
+ private static readonly int GapFillMilliseconds =
+ int.TryParse(Environment.GetEnvironmentVariable("QWEN_GAPFILL_MS"), out var ms)
+ ? ms
+ : 120;
+
+ /// Trial-sheet metrics; costPerArea = plate area / part area placed.
+ private readonly record struct TrialScore(
+ int count,
+ int priorityHits,
+ double area,
+ double costPerArea
+ );
+
+ ///
+ /// 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.
+ ///
+ 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(StringComparer.Ordinal);
+ var plates = new List();
+ foreach (var sheet in _sheets)
+ {
+ var placements = new List(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
+ );
+ }
+}
diff --git a/OpenNest.Engine.Qwen38FlashNext/Engine/PartPreparation.cs b/OpenNest.Engine.Qwen38FlashNext/Engine/PartPreparation.cs
new file mode 100644
index 0000000..df544f0
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/Engine/PartPreparation.cs
@@ -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;
+
+///
+/// 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.
+///
+internal sealed class PartModel
+{
+ private PartModel(
+ string id,
+ int quantity,
+ int priority,
+ RotationPolicy rotation,
+ ShapeProfile profile,
+ Shape perimeterShape,
+ List 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; }
+
+ /// Closed contour topology (perimeter CCW, cutouts) used for region offsets.
+ public ShapeProfile Profile { get; }
+
+ /// Analytic closed perimeter (arcs preserved) for conservative flattening.
+ public Shape PerimeterShape { get; }
+
+ public List CutoutShapes { get; }
+
+ /// Material area (perimeter minus holes), from the analytic shapes.
+ public double Area { get; }
+
+ ///
+ /// 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.
+ ///
+ public const double CollisionTolerance = 0.0005;
+
+ ///
+ /// 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.
+ ///
+ public static PartModel? TryCreate(NestJobPart part)
+ {
+ try
+ {
+ var entities = new List();
+ 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(profile.Cutouts),
+ area
+ );
+ }
+ catch (Exception)
+ {
+ // Malformed snapshots are unplaceable, not fatal: report them unplaced so
+ // the rest of the job still nests.
+ return null;
+ }
+ }
+}
+
+///
+/// One part contour rotated about the snapshot origin - exactly the frame a
+/// produces (rotate, then translate by X/Y). Bounds,
+/// convex hull, and the spacing-inflated outline are computed once and reused.
+///
+internal sealed class OrientationModel
+{
+ internal OrientationModel(
+ double angle,
+ Polygon perimeter,
+ List holes,
+ Polygon? inflatedPerimeter,
+ List 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();
+ 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;
+ }
+
+ /// No cutouts and a convex perimeter: material equals hull.
+ public bool IsConvexSolid { get; }
+
+ public double Angle { get; }
+
+ /// Circumscribed flattened perimeter in the rotated frame (pre-translation).
+ public Polygon Perimeter { get; }
+
+ public List Holes { get; }
+
+ /// Material outline inflated by (null when spacing is zero).
+ public Polygon? InflatedPerimeter { get; }
+
+ /// Cutouts shrunk by ; holes that close up are dropped (treated solid).
+ public List 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;
+
+ /// Convex hull of the perimeter (open vertex list, at least 3 points).
+ public List Hull { get; }
+
+ ///
+ /// Fast clearance outline of the raw perimeter in this orientation's local frame
+ /// (lazily built; translated per anchor in O(1) via ).
+ ///
+ public FastPoly? PerimeterFast => _perimeterFast ??= FastPoly.From(Perimeter);
+
+ private FastPoly? _perimeterFast;
+
+ ///
+ /// Fast clearance outline of the gate material (spacing-inflated when positive) in
+ /// this orientation's local frame.
+ ///
+ public FastPoly? GateFast =>
+ _gateFast ??= FastPoly.From(InflatedPerimeter ?? Perimeter);
+
+ private FastPoly? _gateFast;
+
+ ///
+ /// Cached triangulation of the raw material (perimeter + holes) in this
+ /// orientation's local frame for the allocation-free exact gate.
+ ///
+ public TriSet? MaterialTris => _materialTris ??= TriSet.Build(Perimeter, Holes);
+
+ private TriSet? _materialTris;
+
+ ///
+ /// Cached triangulation of the gate material (spacing-inflated perimeter with
+ /// shrunk holes) in this orientation's local frame.
+ ///
+ public TriSet? GateTris =>
+ _gateTris ??= TriSet.Build(InflatedPerimeter ?? Perimeter, InflatedPerimeter != null ? InflatedHoles : Holes);
+
+ private TriSet? _gateTris;
+}
+
+/// Builds and caches per-(part, orientation, spacing) geometry for one engine run.
+internal sealed class PartPreparation
+{
+ private readonly List models = new();
+ private readonly Dictionary indexById = new(StringComparer.Ordinal);
+ private readonly Dictionary<(string, double, double), OrientationModel> orientations = new();
+
+ ///
+ /// 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.
+ ///
+ private readonly Dictionary overlaps = new();
+
+ internal sealed class OverlapKey : IEquatable
+ {
+ 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 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 Models => models;
+
+ public PartPreparation(IReadOnlyList 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);
+ }
+ }
+
+ /// Requirements whose snapshot geometry could not be interpreted at all.
+ public List 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(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();
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public static List CandidateAngles(PartModel model)
+ {
+ var angles = new List();
+ 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();
+ 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;
+ }
+}
diff --git a/OpenNest.Engine.Qwen38FlashNext/Engine/SheetPacker.cs b/OpenNest.Engine.Qwen38FlashNext/Engine/SheetPacker.cs
new file mode 100644
index 0000000..b334acc
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/Engine/SheetPacker.cs
@@ -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;
+
+/// A committed placement: model, orientation, and origin position on the sheet.
+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; }
+}
+
+///
+/// One sheet packed by this engine's own algorithm: bottom-left greedy insertion of
+/// convex NFP corner candidates.
+///
+/// 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.
+///
+///
+/// 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.
+///
+///
+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 _inflatedBounds = new();
+ private readonly List<(Polygon Perimeter, List Holes)> _placedGate = new();
+
+ ///
+ /// Fast-path outlines of the placed parts' gate material in world coordinates,
+ /// parallel to (O(1) translation of the shared per-
+ /// orientation template). A hit certifies the two
+ /// outer shells - hence both materials - are clear and skips the exact
+ /// gate, which at fine flattening triangulates thousands
+ /// of edges per call. Null when the outline has no usable ring.
+ ///
+ private readonly List _placedGateFast = new();
+
+ // NFP caches: (placedIndex, orientationId) -> forbidden-anchor contour.
+ private readonly Dictionary<(int, int), ConvexContour?> _nfpCache = new();
+ private readonly Dictionary _orientationIds = new();
+
+ ///
+ /// Per-orientation cache of NFP/NFP valley anchors (anchors touching two placed
+ /// parts at once). A committed part's NFP never changes and
+ /// 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).
+ ///
+ private sealed class ValleyCache
+ {
+ public int BuiltThrough;
+ public readonly List<(double X, double Y)> Valleys = new();
+ }
+
+ private readonly Dictionary _valleyCaches = new();
+ private readonly Dictionary _reflectedHulls = new();
+ private readonly Dictionary _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[] _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[_gridCols * _gridRows];
+ for (var i = 0; i < _grid.Length; i++)
+ _grid[i] = new List();
+ }
+
+ 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 Placed { get; } = new();
+
+ public bool IsFull => Placed.Count >= MaxPartsPerSheet;
+
+ ///
+ /// 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.
+ ///
+ public const int MaxPartsPerSheet = 500;
+
+ /// True when the part's bounds can never fit this sheet in any orientation.
+ 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}";
+
+ ///
+ /// Greedily insert an instance: best (bottom-left) legal corner over all candidate
+ /// orientations. Returns false (and changes nothing) when no legal position exists.
+ ///
+ 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;
+
+ ///
+ /// Corner candidates in deterministic ascending bottom-left order: anchor work-box
+ /// corners, NFP vertices, and NFP-edge/box-line crossings.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// Proper or endpoint intersection of two segments, if any.
+ 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 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(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 _nearScratch = new();
+
+ private HashSet 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;
+ }
+
+ ///
+ /// 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).
+ ///
+ 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 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+
+ ///
+ /// Exact clearance gate against one placed part: placed gate material (inflated by
+ /// spacing when positive) versus the candidate's raw material with holes subtracted.
+ ///
+ private bool MaterialOverlap(
+ (Polygon Perimeter, List 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) BuildCandidateGate(OrientationModel orientation, double x, double y)
+ {
+ var perimeter = (Polygon)orientation.Perimeter.Clone();
+ perimeter.Offset(x, y);
+ perimeter.UpdateBounds();
+ var holes = new List(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;
+ }
+
+ ///
+ /// Convex NFP of forbidden anchors: placedHull (+) disk(spacing) (+) reflect(candidateHull).
+ ///
+ 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;
+ }
+
+ ///
+ /// Bounded-size convex SUPERSET of (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.
+ ///
+ private static List CapHull(List hull, double dx, double dy)
+ {
+ var n = hull.Count;
+ var shifted = new List(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
+ {
+ 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();
+ 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);
+}
diff --git a/OpenNest.Engine.Qwen38FlashNext/OpenNest.Engine.Qwen38FlashNext.csproj b/OpenNest.Engine.Qwen38FlashNext/OpenNest.Engine.Qwen38FlashNext.csproj
new file mode 100644
index 0000000..c7edc44
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/OpenNest.Engine.Qwen38FlashNext.csproj
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/OpenNest.Engine.Qwen38FlashNext/Qwen38FlashNextNestingEngine.cs b/OpenNest.Engine.Qwen38FlashNext/Qwen38FlashNextNestingEngine.cs
new file mode 100644
index 0000000..e2a71d3
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/Qwen38FlashNextNestingEngine.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Threading;
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.Qwen38FlashNext.Engine;
+
+namespace OpenNest.Engine.Qwen38FlashNext;
+
+///
+/// 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.
+///
+/// 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.
+///
+///
+/// The engine is self-contained: it calls no built-in ,
+/// nester, filler, or runner, and is deterministic - identical input, identical layout.
+///
+///
+public sealed class Qwen38FlashNextNestingEngine : INestingEngine
+{
+ public NestJobResult Solve(
+ NestJob job,
+ IProgress? 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);
+ }
+}
diff --git a/OpenNest.Engine.Qwen38FlashNext/README.md b/OpenNest.Engine.Qwen38FlashNext/README.md
new file mode 100644
index 0000000..42842d8
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/README.md
@@ -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.Benchmark/OpenNest.Benchmark.csproj -c Release
+
+mkdir -p /OpenNest.Benchmark/bin/Release/net8.0/Engines
+cp OpenNest.Engine.Qwen38FlashNext/bin/Release/net8.0/OpenNest.Engine.Qwen38FlashNext.dll /OpenNest.Benchmark/bin/Release/net8.0/Engines/
+
+dotnet /OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll --parallel 1
+```
+
+`` 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`.
diff --git a/OpenNest.Engine.Qwen38FlashNext/tests/NfpGeometryTests.cs b/OpenNest.Engine.Qwen38FlashNext/tests/NfpGeometryTests.cs
new file mode 100644
index 0000000..b38dfd1
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/tests/NfpGeometryTests.cs
@@ -0,0 +1,151 @@
+using System;
+using Xunit;
+using OpenNest.Geometry;
+using OpenNest.Engine.Qwen38FlashNext.Engine;
+
+namespace OpenNest.Engine.Qwen38FlashNext.Tests;
+
+///
+/// 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).
+///
+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}");
+ }
+ }
+}
diff --git a/OpenNest.Engine.Qwen38FlashNext/tests/OpenNest.Engine.Qwen38FlashNext.Tests.csproj b/OpenNest.Engine.Qwen38FlashNext/tests/OpenNest.Engine.Qwen38FlashNext.Tests.csproj
new file mode 100644
index 0000000..59916ea
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/tests/OpenNest.Engine.Qwen38FlashNext.Tests.csproj
@@ -0,0 +1,17 @@
+
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OpenNest.Engine.Qwen38FlashNext/tests/Qwen38FlashNextNestingEngineTests.cs b/OpenNest.Engine.Qwen38FlashNext/tests/Qwen38FlashNextNestingEngineTests.cs
new file mode 100644
index 0000000..6d39936
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/tests/Qwen38FlashNextNestingEngineTests.cs
@@ -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;
+
+///
+/// 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.
+///
+public class Qwen38FlashNextNestingEngineTests
+{
+ [Fact]
+ public void HasPublicParameterlessConstructorForPluginDiscovery()
+ {
+ var engine = Activator.CreateInstance(typeof(Qwen38FlashNextNestingEngine));
+ Assert.IsAssignableFrom(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(
+ 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);
+
+ /// Y extent.
+ /// X extent.
+ 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;
+ }
+}
diff --git a/README.md b/README.md
index 0e0707d..234fc6b 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ the OpenNest app or `OpenNest.Benchmark` build output.
|--------|----------|
| [Gpt6Astra](OpenNest.Engine.Gpt6Astra/) | Contact-based placement |
| [Opus55](OpenNest.Engine.Opus55/) | Frontier-advance no-fit-polygon packing |
+| [Qwen38FlashNext](OpenNest.Engine.Qwen38FlashNext/) | Bottom-left greedy insertion over convex NFPs with an exact clearance gate |
## Building