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/JobSolver.cs b/OpenNest.Engine.Qwen38FlashNext/Engine/JobSolver.cs
new file mode 100644
index 0000000..3f88e52
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/Engine/JobSolver.cs
@@ -0,0 +1,314 @@
+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;
+ 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);
+
+ ///
+ /// 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;
+ (int count, int priorityHits, double area) 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);
+ if (
+ best == null
+ || score.count > bestScore.count
+ || (score.count == bestScore.count && score.priorityHits > bestScore.priorityHits)
+ || (
+ score.count == bestScore.count
+ && score.priorityHits == bestScore.priorityHits
+ && score.area < bestScore.area
+ )
+ )
+ {
+ 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];
+
+ 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");
+ }
+ }
+
+ private (int count, int priorityHits, double area) 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);
+ return (count, priorityHits, packer.Stock.Size.Width * packer.Stock.Size.Length);
+ }
+
+ 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..c8e4713
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/Engine/PartPreparation.cs
@@ -0,0 +1,487 @@
+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. Arcs are flattened
+ /// circumscribed, so the polygon always contains the true material and every
+ /// clearance the engine accepts is at least as strict as the validator requires.
+ ///
+ public const double CollisionTolerance = 0.02;
+
+ ///
+ /// 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; }
+}
+
+/// 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..6631290
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/Engine/SheetPacker.cs
@@ -0,0 +1,687 @@
+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();
+
+ // NFP caches: (placedIndex, orientationId) -> forbidden-anchor contour.
+ private readonly Dictionary<(int, int), ConvexContour?> _nfpCache = new();
+ private readonly Dictionary _orientationIds = 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;
+
+ public string DiagStats() =>
+ $"inserts={DiagInsertAttempts} checks={DiagCandidateChecks} gates={DiagGateCalls} " +
+ $"convexRej={DiagConvexRejections}";
+
+ ///
+ /// 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);
+
+ void Add(double x, double y)
+ {
+ if (x < boxLeft - 1e-9 || x > boxRight + 1e-9 || y < boxBottom - 1e-9 || y > boxTop + 1e-9)
+ return;
+ x = Math.Clamp(x, boxLeft, boxRight);
+ y = Math.Clamp(y, boxBottom, boxTop);
+ 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);
+ }
+ }
+
+ candidates.Sort(
+ (p, q) =>
+ {
+ var byY = p.Item2.CompareTo(q.Item2);
+ return byY != 0 ? byY : p.Item1.CompareTo(q.Item1);
+ }
+ );
+ return candidates;
+ }
+
+ 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));
+
+ 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;
+
+ // The convex NFP is hull-based: it over-approximates the forbidden region
+ // for concave or holed parts, so neither outside nor inside it can decide
+ // anything on its own. It only short-circuits the pair test when both
+ // materials are convex solids with uncapped hulls, where the NFP is exact
+ // (modulo the circumscribed disk's chord error, which only ever rejects a
+ // hair too much). Everything else pays the exact material gate.
+ DiagCandidateChecks++;
+ if (
+ orientation.IsConvexSolid
+ && Placed[i].Orientation.IsConvexSolid
+ && orientation.Hull.Count <= MaxHullVertices
+ && Placed[i].Orientation.Hull.Count <= MaxHullVertices
+ )
+ {
+ var nfp = NfpFor(i, orientation);
+ if (nfp != null)
+ {
+ if (!nfp.ContainsPoint(x, y))
+ continue; // strict certification: materials are the hulls
+ DiagConvexRejections++;
+ return false; // convex vs convex inside the exact NFP: overlap
+ }
+ // Degenerate NFP: fall through to the material gate.
+ }
+
+ // 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;
+
+ DiagGateCalls++;
+ gate ??= BuildCandidateGate(orientation, x, y);
+ if (MaterialOverlap(gate.Value, orientation, x, y, i))
+ return false;
+ }
+ return true;
+ }
+
+ ///
+ /// 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];
+ 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..6c7f14a
--- /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 part
+/// with the thinnest worst-case orientation extent, then 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. Which stock the next sheet uses is chosen by re-packing each available size
+/// and committing the trial that places the most instances on the smallest sheet; 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..ccbe3f7
--- /dev/null
+++ b/OpenNest.Engine.Qwen38FlashNext/README.md
@@ -0,0 +1,112 @@
+# OpenNest.Engine.Qwen38FlashNext
+
+An independent `INestingEngine` implementation. It must not be a wrapper, ensemble, or
+selector over OpenNest's built-in engines. `Solve()` must not call, instantiate, or
+delegate to any existing `INestingEngine` (`StockLadderNestingEngine`,
+`FixedStrategyNestingEngine`), `NestingEngineRegistry`, `NestJobRunner`, or the whole-plate
+nesters/fillers behind `PlateNesterFactory` (`DefaultPlateNester`, `StripPlateNester`,
+`RemnantPlateNester`, `PlateFillService`, `DefaultPlateFiller`, ...). It must also never run
+several of them and keep the best result.
+
+The decisions that make it an engine must be yours: which sheet(s) to use, which parts go
+where and in what order, which pattern/strategy to apply to which region, and when to stop.
+
+## Allowed building blocks
+
+Reuse is encouraged. These are tools you drive, composed by your own decision logic:
+
+- `OpenNest.Core` geometry: `Polygon`, `Shape`, `BoundingBox`, `Vector`, `Box`, `ConvexHull`,
+ `ConvexDecomposition`, `RotatingCalipers`, `Collision`, `NoFitPolygon`, `ShapeProfile`,
+ `SpatialQuery`.
+- Fill and pattern components in `OpenNest.Engine.Fill`: `FillLinear`, `FillExtents`,
+ `PairFiller`, `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor`, `FillScore`,
+ `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`,
+ `BestCombination`.
+- `OpenNest.Engine.BestFit` (`BestFitFinder`, `PairEvaluator`, ...), `RectanglePacking`,
+ `CirclePacking`.
+
+If you find a faster or better way to do something a shared component already does (for
+example linear patterning), implement it inside this engine's own project and leave the
+shared code untouched. Do not edit `OpenNest.Core`, `OpenNest.Engine`, or
+`OpenNest.Benchmark`. Call it out in your report (what it replaces, why it is better,
+measured numbers) so it can be generalized and upstreamed for every engine later.
+
+## 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.
+- **`JobSolver`** walks demands in its own order (priority, then smallest worst-case
+ orientation extent, then area, then id) and drains each greedily. For the next sheet it
+ trials *every* available stock size independently and commits the trial placing the most
+ instances, breaking ties by priority coverage then sheet 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 real 69-drawing/219-part PT75 job below that costs
+~125 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
+
+`P260805-10-PT75-corrected.nest` (69 drawings, 219 parts, sizes 60x120/72x120/60x96/48x144,
+spacing 0.3, `--parallel 1`): **valid, 219/219 placed, 31 plates, 80.2% utilization,
+cost 214848**, ~125 s. The same run's Baseline layout scores INVALID (over-quantity and a
+spacing violation in the source file), and StockLadder crashes on a drawing whose geometry
+has no usable closed edges - the engine's per-part try/catch reports such parts unplaced
+instead of failing the job.
+
+## Tests
+
+`tests/` holds starter acceptance tests. Every layout is checked by the benchmark's own
+`NestValidator` (bounds, spacing, quantities, stock, rotation), so a passing test means the
+benchmark will accept the layout. They fail until `Solve()` is implemented. Keep them and
+add engine-specific tests next to them.
+
+```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. Your engine shows up in the report under its
+CLR type name (`Qwen38FlashNextNestingEngine`), competing on equal footing against the built-in
+engines.
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;
+ }
+}