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