feat(qwen38flashnext): add the finished engine

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

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

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

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