Author SHA1 Message Date
ajandClaude Opus 5.5 aa0e6f967e feat(qwen38flashnext): snapshot first working engine as a baseline
Qwen3.8-Flash-Next reached a working engine and is now optimizing it.
Optimization passes can regress, so this preserves the first version
that passes all acceptance tests (13/13 against OpenNest master,
including the model's own rotated-spacing regression test) for
comparison and rollback. Snapshot taken 2026-09-24 12:00 from
hermes.lan:/home/aj/src/Qwen38FlashNext; the run is still in progress,
so this stays off master until it finishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:00:53 -04:00
10 changed files with 2376 additions and 0 deletions
@@ -0,0 +1,383 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
using Math = System.Math;
/// <summary>
/// Axis-aligned bounding box with no allocation and inclusive intersection tests.
/// </summary>
internal readonly struct Bounds
{
public Bounds(double minX, double minY, double maxX, double maxY)
{
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
}
public double MinX { get; }
public double MinY { get; }
public double MaxX { get; }
public double MaxY { get; }
public bool Intersects(in Bounds other, double margin = 0) =>
other.MinX <= MaxX + margin
&& MinX <= other.MaxX + margin
&& other.MinY <= MaxY + margin
&& MinY <= other.MaxY + margin;
}
/// <summary>
/// A convex contour as flat coordinate arrays (closed: last point != first), with
/// O(log n) strict-inside and exact vertical/horizontal span queries. This is the
/// engine's own working representation for No-Fit-Polygon geometry; nothing here is
/// shared with the built-in nesters.
/// </summary>
internal sealed class ConvexContour
{
// Numerical inset: points within this depth of the boundary count as outside, so a
// placement resting on the NFP (hull contact) is accepted.
public const double Surface = 1e-6;
private readonly double[] _x;
private readonly double[] _y;
private ConvexContour(double[] x, double[] y, Bounds bounds)
{
_x = x;
_y = y;
Bounds = bounds;
_ = FindStart();
}
public Bounds Bounds { get; }
public int Count => _x.Length;
/// <summary>Index of the lexicographic (Y, X) minimum vertex.</summary>
public int Start { get; private set; }
public double X(int i) => _x[i];
public double Y(int i) => _y[i];
public static ConvexContour FromVertices(IList<Vector> points)
{
var n = points.Count;
if (n > 1 && points[0].Equals(points[n - 1]))
n--;
if (n < 3)
throw new ArgumentException("Convex contour needs at least three vertices.");
var x = new double[n];
var y = new double[n];
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
for (var i = 0; i < n; i++)
{
x[i] = points[i].X;
y[i] = points[i].Y;
if (x[i] < minX)
minX = x[i];
if (x[i] > maxX)
maxX = x[i];
if (y[i] < minY)
minY = y[i];
if (y[i] > maxY)
maxY = y[i];
}
return new ConvexContour(x, y, new Bounds(minX, minY, maxX, maxY));
}
/// <summary>Regular 2^k-gon approximating a disk of the given radius (convex CCW).</summary>
public static ConvexContour Disk(double radius, int segments = 32)
{
var x = new double[segments];
var y = new double[segments];
for (var i = 0; i < segments; i++)
{
var angle = 2 * Math.PI * i / segments;
x[i] = radius * Math.Cos(angle);
y[i] = radius * Math.Sin(angle);
}
return new ConvexContour(x, y, new Bounds(-radius, -radius, radius, radius));
}
public ConvexContour Translated(double dx, double dy)
{
var n = _x.Length;
var x = new double[n];
var y = new double[n];
for (var i = 0; i < n; i++)
{
x[i] = _x[i] + dx;
y[i] = _y[i] + dy;
}
return new ConvexContour(x, y, new Bounds(Bounds.MinX + dx, Bounds.MinY + dy, Bounds.MaxX + dx, Bounds.MaxY + dy));
}
public double MinX => Bounds.MinX;
public double MinY => Bounds.MinY;
public double MaxX => Bounds.MaxX;
public double MaxY => Bounds.MaxY;
/// <summary>
/// Containment with a <see cref="Surface"/> band: points strictly outside return
/// false; points inside - OR within the band of an edge - return true, so anchors
/// resting on the NFP (the usual corner-candidate case) fall through to the exact
/// material gate instead of being certified by the fast path. The inset may never
/// exceed the circumscribed spacing disk's chord slack (Disk radius r/cos(pi/24)),
/// so a hull contact that still clears the true spacing passes the gate.
/// </summary>
public bool ContainsPoint(double px, double py)
{
var n = _x.Length;
var sx = _x[Start];
var sy = _y[Start];
// Polar-angle wedge from the start vertex (CCW order: first -> last).
var first = Mod(Start + 1, n);
var last = Mod(Start - 1, n);
var head = Cross(sx, sy, _x[first], _y[first], px, py);
if (head < -Surface)
return false;
var tail = Cross(sx, sy, _x[last], _y[last], px, py);
if (tail > Surface)
return false;
// Within the band of the two wedge rays: conservative inside.
if (head <= Surface || tail >= -Surface)
return true;
// Binary search for the fan triangle (start, vk, vk+1) bracketing the ray
// start->p; vk is CCW-ordered so polar angle rises monotonically first->last.
var lo = 0; // offset (from first) of the last vertex at-or-before p's angle
var hi = n - 2; // offset of last
while (hi - lo > 1)
{
var mid = (lo + hi) / 2;
var index = Mod(Start + 1 + mid, n);
if (Cross(sx, sy, _x[index], _y[index], px, py) >= -Surface)
lo = mid;
else
hi = mid;
}
var a = Mod(Start + 1 + lo, n);
var b = Mod(Start + 1 + lo + 1, n);
var edgeAB = Cross(_x[a], _y[a], _x[b], _y[b], px, py);
if (edgeAB < -Surface)
return false;
// Strictly inside the fan triangle, or inside the band of the far edge.
return edgeAB <= Surface
|| Cross(sx, sy, _x[a], _y[a], px, py) >= -Surface
&& Cross(_x[b], _y[b], sx, sy, px, py) >= -Surface;
}
/// <summary>
/// The vertical span [lo, hi] of the contour's cross-section at x, when x is
/// strictly inside its x-range (inset by <see cref="Surface"/>); false otherwise.
/// </summary>
public bool VerticalSpanAt(double x, out double lo, out double hi)
{
lo = 0;
hi = 0;
if (x < MinX + Surface || x > MaxX - Surface)
return false;
lo = double.MaxValue;
hi = double.MinValue;
var n = _x.Length;
var j = n - 1;
for (var i = 0; i < n; i++)
{
var x0 = _x[j];
var x1 = _x[i];
if ((x0 <= x && x1 >= x) || (x1 <= x && x0 >= x))
{
var y0 = _y[j];
var y1 = _y[i];
double y;
if (x1 == x0)
y = Math.Min(y0, y1);
else
y = y0 + (y1 - y0) * (x - x0) / (x1 - x0);
if (y < lo)
lo = y;
if (y > hi)
hi = y;
}
j = i;
}
return lo <= hi;
}
/// <summary>The horizontal span at y, inset like <see cref="VerticalSpanAt"/>.</summary>
public bool HorizontalSpanAt(double y, out double lo, out double hi)
{
lo = 0;
hi = 0;
if (y < MinY + Surface || y > MaxY - Surface)
return false;
lo = double.MaxValue;
hi = double.MinValue;
var n = _x.Length;
var j = n - 1;
for (var i = 0; i < n; i++)
{
var y0 = _y[j];
var y1 = _y[i];
if ((y0 <= y && y1 >= y) || (y1 <= y && y0 >= y))
{
var x0 = _x[j];
var x1 = _x[i];
double x;
if (y1 == y0)
x = Math.Min(x0, x1);
else
x = x0 + (x1 - x0) * (y - y0) / (y1 - y0);
if (x < lo)
lo = x;
if (x > hi)
hi = x;
}
j = i;
}
return lo <= hi;
}
private int Mod(int i, int n)
{
var m = i % n;
return m < 0 ? m + n : m;
}
private int FindStart()
{
var best = 0;
for (var i = 1; i < _y.Length; i++)
if (
_y[i] < _y[best] - 1e-12
|| (Math.Abs(_y[i] - _y[best]) <= 1e-12 && _x[i] < _x[best])
)
best = i;
Start = best;
return best;
}
private static double Cross(double ax, double ay, double bx, double by, double px, double py) =>
(bx - ax) * (py - ay) - (by - ay) * (px - ax);
}
/// <summary>
/// No-Fit-Polygon geometry for this engine: the Minkowski sum of two convex contours
/// (the classic linear edge-merge), used to build convex NFPs as
/// placedHull (+) disk(spacing) (+) reflect(candidateHull). The engine's placement
/// search consumes these contours directly; it never tessellates part material or
/// delegates to the built-in NFP machinery.
/// </summary>
internal static class NfpGeometry
{
/// <summary>
/// Point-symmetric reflection (rotation by 180 degrees). Negating every vertex
/// preserves CCW winding, so the vertex order must NOT be reversed - reversing it
/// would hand the edge-merge a CW contour and corrupt the NFP.
/// </summary>
public static ConvexContour Reflect(ConvexContour contour)
{
var n = contour.Count;
var points = new List<Vector>(n);
for (var i = 0; i < n; i++)
points.Add(new Vector(-contour.X(i), -contour.Y(i)));
return ConvexContour.FromVertices(points);
}
/// <summary>
/// Minkowski sum of two convex CCW contours via angular edge merge, starting from
/// the sum of each contour's lexicographic (Y, X) minimum vertex. Edges are chosen
/// by relative angle (cross product); the invariant that the two frontier edges are
/// always less than 180 degrees apart holds because both walks start at the lowest
/// vertex and each convex polygon turns by less than 180 degrees per vertex.
/// </summary>
public static ConvexContour Minkowski(ConvexContour a, ConvexContour b)
{
var na = a.Count;
var nb = b.Count;
var edges = new List<(double x, double y)>(na + nb);
// Edge vectors walking CCW from each start vertex.
var edgeA = new (double x, double y)[na];
for (var k = 0; k < na; k++)
{
var p = (a.Start + k) % na;
var q = (a.Start + k + 1) % na;
edgeA[k] = (a.X(q) - a.X(p), a.Y(q) - a.Y(p));
}
var edgeB = new (double x, double y)[nb];
for (var k = 0; k < nb; k++)
{
var p = (b.Start + k) % nb;
var q = (b.Start + k + 1) % nb;
edgeB[k] = (b.X(q) - b.X(p), b.Y(q) - b.Y(p));
}
var ka = 0;
var kb = 0;
while (ka < na || kb < nb)
{
if (ka >= na)
{
edges.Add(edgeB[kb++]);
continue;
}
if (kb >= nb)
{
edges.Add(edgeA[ka++]);
continue;
}
var ea = edgeA[ka];
var eb = edgeB[kb];
var cross = ea.x * eb.y - ea.y * eb.x;
var scale =
(ea.x * ea.x + ea.y * ea.y) * (eb.x * eb.x + eb.y * eb.y) + 1e-300;
if (Math.Abs(cross) <= 1e-9 * Math.Sqrt(scale))
{
// Same direction: emit the summed edge.
edges.Add((ea.x + eb.x, ea.y + eb.y));
ka++;
kb++;
}
else if (cross > 0)
{
// cross(ea, eb) > 0: eb is CCW-after ea, so ea is the more clockwise
// edge and must be emitted first to keep the merge in angular order.
edges.Add(ea);
ka++;
}
else
{
edges.Add(eb);
kb++;
}
}
var result = new List<Vector>(edges.Count + 1);
var px = a.X(a.Start) + b.X(b.Start);
var py = a.Y(a.Start) + b.Y(b.Start);
result.Add(new Vector(px, py));
foreach (var (ex, ey) in edges)
{
px += ex;
py += ey;
result.Add(new Vector(px, py));
}
if (result.Count > 1 && result[0].Equals(result[^1]))
result.RemoveAt(result.Count - 1);
return ConvexContour.FromVertices(result);
}
}
@@ -0,0 +1,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);
/// <summary>
/// Whole-job decision layer: which stock the next sheet uses, the order parts are
/// demanded in, when a sheet is finished, and when the job stops. Every placement
/// inside a sheet comes from <see cref="SheetPacker"/>; nothing here delegates to a
/// built-in engine, nester, filler, or runner.
/// </summary>
internal sealed class JobSolver
{
private readonly NestJob _job;
private readonly PartPreparation _prep;
private readonly Dictionary<string, int> _remaining;
private readonly Dictionary<string, int> _placed;
private readonly Dictionary<string, int> _used;
private readonly List<CommittedSheet> _sheets = new();
private sealed record CommittedSheet(int StockIndex, List<PlacedPart> Placements);
public JobSolver(NestJob job, PartPreparation prep)
{
_job = job;
_prep = prep;
_remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
_placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal);
_used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
}
private static readonly bool _diag =
Environment.GetEnvironmentVariable("QWEN_NEST_DIAG") == "1";
private void Diag(string message)
{
if (_diag)
Console.Error.WriteLine(
$"[qwen] sheets={_sheets.Count} placed={_placed.Values.Sum()} " +
$"mem={GC.GetTotalMemory(false) / 1048576}MB gc0={GC.CollectionCount(0)} " +
$"gc2={GC.CollectionCount(2)} {message}"
);
}
public NestJobResult Solve(IProgress<NestJobProgress>? progress, CancellationToken token)
{
var reason = NestJobStopReason.Completed;
while (true)
{
token.ThrowIfCancellationRequested();
var outstanding = OutstandingDemands();
if (outstanding.Count == 0)
break;
if (_job.Options.MaxPlates is int cap && _sheets.Count >= cap)
{
reason = NestJobStopReason.PlateLimitReached;
break;
}
var attempt = BestNextSheet(outstanding, progress, token);
Diag($"nextSheet -> {(attempt == null ? "none" : $"stock {_job.Plates[attempt.StockIndex].Id} placed {attempt.Packer.Placed.Count}")}");
if (attempt == null)
{
reason = AnyStockAvailable()
? NestJobStopReason.NoPlacementFound
: NestJobStopReason.StockExhausted;
break;
}
CommitSheet(attempt.Packer);
progress?.Report(
new NestJobProgress(
NestJobStage.PlateCommitted,
_job.Plates[attempt.StockIndex].Id,
_sheets.Count - 1,
_sheets.Count,
_placed.Values.Sum()
)
);
}
return BuildResult(reason);
}
private List<PartModel> OutstandingDemands()
{
var demands = new List<PartModel>();
foreach (var model in _prep.Models)
if (_remaining[model.Id] > 0)
demands.Add(model);
// This engine's own ordering: priority first, then the tallest-then-largest
// part first (a part's thinnest orientation extent), then id for determinism.
demands.Sort(
(a, b) =>
{
var byPriority = a.Priority.CompareTo(b.Priority);
if (byPriority != 0)
return byPriority;
var bySpan = MinimumMaxSpan(b).CompareTo(MinimumMaxSpan(a));
if (bySpan != 0)
return bySpan;
var byArea = b.Area.CompareTo(a.Area);
if (byArea != 0)
return byArea;
return string.CompareOrdinal(a.Id, b.Id);
}
);
return demands;
}
private double MinimumMaxSpan(PartModel model)
{
if (!_minimumSpan.TryGetValue(model.Id, out var span))
{
span = double.MaxValue;
foreach (var angle in PartPreparation.CandidateAngles(model))
{
var orientation = _prep.Oriented(model, angle, 0);
var worst = Math.Max(orientation.Width, orientation.Height);
if (worst < span)
span = worst;
}
_minimumSpan[model.Id] = span;
}
return span;
}
private readonly Dictionary<string, double> _minimumSpan = new(StringComparer.Ordinal);
/// <summary>
/// Packs every available stock size independently and commits the best trial:
/// most instances first, then highest priority coverage, then the smallest sheet
/// area (the cost function the benchmark scores), then input order.
/// </summary>
private SheetAttempt? BestNextSheet(
List<PartModel> outstanding,
IProgress<NestJobProgress>? progress,
CancellationToken token
)
{
SheetAttempt? best = null;
(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;
}
/// <summary>
/// This engine's fill policy for one sheet: walk the demand order and drain each
/// requirement greedily; a requirement that cannot place any more instances is
/// skipped (never aborts the sheet) and retried on the next sheet. Consumes a
/// local copy of demand - losing this trial must not change job state.
/// </summary>
private void FillSheet(SheetPacker packer, List<PartModel> outstanding, CancellationToken token)
{
var available = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var model in outstanding)
available[model.Id] = _remaining[model.Id];
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<string, int>(StringComparer.Ordinal);
var plates = new List<NestJobPlateResult>();
foreach (var sheet in _sheets)
{
var placements = new List<NestJobPlacement>(sheet.Placements.Count);
foreach (var placed in sheet.Placements)
{
instanceIndex.TryGetValue(placed.Model.Id, out var next);
instanceIndex[placed.Model.Id] = next + 1;
placements.Add(
new NestJobPlacement(
placed.Model.Id,
next,
placed.X,
placed.Y,
placed.Orientation.Angle
)
);
}
plates.Add(
new NestJobPlateResult(sheet.StockIndex, _job.Plates[sheet.StockIndex], placements)
);
}
var fulfillment = _job.Parts
.Select(part => new PartFulfillment(
part.Id,
part.Quantity,
_placed[part.Id],
_remaining[part.Id]
))
.ToList();
var stockUsage = _job.Plates
.Select(stock => new StockUsage(
stock.Id,
_used[stock.Id],
stock.Quantity is int quantity ? quantity - _used[stock.Id] : null
))
.ToList();
return new NestJobResult(
reason == NestJobStopReason.Completed
? NestJobStatus.Complete
: NestJobStatus.Incomplete,
reason,
plates,
fulfillment,
stockUsage
);
}
}
@@ -0,0 +1,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;
/// <summary>
/// A job requirement prepared once per solve: snapshot motions rebuilt into an owned
/// closed contour topology (perimeter + cutouts; rapid/layer-mark geometry dropped),
/// flattened collision polygons, and material area.
/// </summary>
internal sealed class PartModel
{
private PartModel(
string id,
int quantity,
int priority,
RotationPolicy rotation,
ShapeProfile profile,
Shape perimeterShape,
List<Shape> cutoutShapes,
double area
)
{
Id = id;
Quantity = quantity;
Priority = priority;
Rotation = rotation;
Profile = profile;
PerimeterShape = perimeterShape;
CutoutShapes = cutoutShapes;
Area = area;
}
public string Id { get; }
public int Quantity { get; }
public int Priority { get; }
public RotationPolicy Rotation { get; }
/// <summary>Closed contour topology (perimeter CCW, cutouts) used for region offsets.</summary>
public ShapeProfile Profile { get; }
/// <summary>Analytic closed perimeter (arcs preserved) for conservative flattening.</summary>
public Shape PerimeterShape { get; }
public List<Shape> CutoutShapes { get; }
/// <summary>Material area (perimeter minus holes), from the analytic shapes.</summary>
public double Area { get; }
/// <summary>
/// Chord tolerance for the engine's internal collision polygons. 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.
/// </summary>
public const double CollisionTolerance = 0.02;
/// <summary>
/// Returns null when the snapshot has no usable closed contour - such a part can
/// never be placed and is reported unplaced rather than failing the whole job.
/// </summary>
public static PartModel? TryCreate(NestJobPart part)
{
try
{
var entities = new List<Entity>();
foreach (
var entity in ConvertProgram.ToGeometry(
DrawingJobMapper.ToProgram(part.Geometry)
)
)
if (!ReferenceEquals(entity.Layer, SpecialLayers.Rapid))
entities.Add(entity);
if (entities.Count == 0)
return null;
var profile = new ShapeProfile(entities);
if (profile.Perimeter == null)
return null;
profile.NormalizeWinding();
var area = Math.Abs(profile.Perimeter.Area());
foreach (var cutout in profile.Cutouts)
area -= Math.Abs(cutout.Area());
if (!double.IsFinite(area) || area <= Tolerance.Epsilon)
return null;
return new PartModel(
part.Id,
part.Quantity,
part.Priority,
part.Rotation,
profile,
profile.Perimeter,
new List<Shape>(profile.Cutouts),
area
);
}
catch (Exception)
{
// Malformed snapshots are unplaceable, not fatal: report them unplaced so
// the rest of the job still nests.
return null;
}
}
}
/// <summary>
/// One part contour rotated about the snapshot origin - exactly the frame a
/// <see cref="NestJobPlacement"/> produces (rotate, then translate by X/Y). Bounds,
/// convex hull, and the spacing-inflated outline are computed once and reused.
/// </summary>
internal sealed class OrientationModel
{
internal OrientationModel(
double angle,
Polygon perimeter,
List<Polygon> holes,
Polygon? inflatedPerimeter,
List<Polygon> inflatedHoles,
double spacing
)
{
Angle = angle;
Perimeter = perimeter;
Holes = holes;
InflatedPerimeter = inflatedPerimeter;
InflatedHoles = inflatedHoles;
Spacing = spacing;
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var v in perimeter.Vertices)
{
if (v.X < minX)
minX = v.X;
if (v.X > maxX)
maxX = v.X;
if (v.Y < minY)
minY = v.Y;
if (v.Y > maxY)
maxY = v.Y;
}
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
var hullPoints = new List<Vector>();
try
{
var hull = ConvexHull.Compute(perimeter.Vertices);
foreach (var v in hull.Vertices)
{
if (hullPoints.Count > 0 && v.Equals(hullPoints[^1]))
continue;
hullPoints.Add(v);
}
if (hullPoints.Count > 1 && hullPoints[0].Equals(hullPoints[^1]))
hullPoints.RemoveAt(hullPoints.Count - 1);
}
catch (Exception)
{
hullPoints.Clear();
}
Hull = hullPoints.Count >= 3 ? hullPoints : perimeter.Vertices;
// True when the flattened perimeter is itself convex (no concavities) and the
// part has no cutouts: for two such parts the convex NFP is EXACT - material
// equals hull - so an anchor inside it is forbidden with no material test.
var convex = holes.Count == 0;
if (convex)
{
var verts = perimeter.Vertices;
var m = verts.Count;
if (m > 2 && verts[0].Equals(verts[m - 1]))
m--;
for (var i = 0; i < m && convex; i++)
{
var ax = verts[i].X;
var ay = verts[i].Y;
var bx = verts[(i + 1) % m].X;
var by = verts[(i + 1) % m].Y;
var cx = verts[(i + 2) % m].X;
var cy = verts[(i + 2) % m].Y;
if ((bx - ax) * (cy - by) - (by - ay) * (cx - bx) < -1e-9)
convex = false;
}
}
IsConvexSolid = convex;
}
/// <summary>No cutouts and a convex perimeter: material equals hull.</summary>
public bool IsConvexSolid { get; }
public double Angle { get; }
/// <summary>Circumscribed flattened perimeter in the rotated frame (pre-translation).</summary>
public Polygon Perimeter { get; }
public List<Polygon> Holes { get; }
/// <summary>Material outline inflated by <see cref="Spacing"/> (null when spacing is zero).</summary>
public Polygon? InflatedPerimeter { get; }
/// <summary>Cutouts shrunk by <see cref="Spacing"/>; holes that close up are dropped (treated solid).</summary>
public List<Polygon> InflatedHoles { get; }
public double Spacing { get; }
public double MinX { get; }
public double MinY { get; }
public double MaxX { get; }
public double MaxY { get; }
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
/// <summary>Convex hull of the perimeter (open vertex list, at least 3 points).</summary>
public List<Vector> Hull { get; }
}
/// <summary>Builds and caches per-(part, orientation, spacing) geometry for one engine run.</summary>
internal sealed class PartPreparation
{
private readonly List<PartModel> models = new();
private readonly Dictionary<string, int> indexById = new(StringComparer.Ordinal);
private readonly Dictionary<(string, double, double), OrientationModel> orientations = new();
/// <summary>
/// Cross-packer memo of exact material overlap: (placed orientation, placed anchor,
/// candidate orientation, candidate anchor) -> overlap. Sheet trials rebuild greedy
/// placement deterministically, so identical world poses recur across trials and
/// across sheets; the memo collapses the repeated polygon-clipping work. Bounded so
/// it can never grow unboundedly on pathological jobs.
/// </summary>
private readonly Dictionary<OverlapKey, bool> overlaps = new();
internal sealed class OverlapKey : IEquatable<OverlapKey>
{
private readonly int _placedHash;
private readonly long _px;
private readonly long _py;
private readonly int _candHash;
private readonly long _cx;
private readonly long _cy;
public OverlapKey(int placedHash, double px, double py, int candHash, double cx, double cy)
{
_placedHash = placedHash;
_px = (long)Math.Round(px * 1e6);
_py = (long)Math.Round(py * 1e6);
_candHash = candHash;
_cx = (long)Math.Round(cx * 1e6);
_cy = (long)Math.Round(cy * 1e6);
}
public bool Equals(OverlapKey? other) =>
other != null
&& _placedHash == other._placedHash
&& _px == other._px
&& _py == other._py
&& _candHash == other._candHash
&& _cx == other._cx
&& _cy == other._cy;
public override bool Equals(object? obj) => Equals(obj as OverlapKey);
public override int GetHashCode()
{
var hash = _placedHash;
hash = unchecked(hash * 397 + _px.GetHashCode());
hash = unchecked(hash * 397 + _py.GetHashCode());
hash = unchecked(hash * 397 + _candHash);
hash = unchecked(hash * 397 + _cx.GetHashCode());
hash = unchecked(hash * 397 + _cy.GetHashCode());
return hash;
}
}
private const int OverlapMemoCap = 500_000;
public bool MaterialOverlapMemo(
OrientationModel placed,
double placedX,
double placedY,
OrientationModel candidate,
double candidateX,
double candidateY,
Func<bool> compute
)
{
var key = new OverlapKey(
System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(placed),
placedX,
placedY,
System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(candidate),
candidateX,
candidateY
);
if (overlaps.TryGetValue(key, out var known))
return known;
if (overlaps.Count >= OverlapMemoCap)
overlaps.Clear();
var value = compute();
overlaps[key] = value;
return value;
}
public IReadOnlyList<PartModel> Models => models;
public PartPreparation(IReadOnlyList<NestJobPart> parts)
{
foreach (var part in parts)
{
var model = PartModel.TryCreate(part);
if (model == null)
{
InvalidIds.Add(part.Id);
continue;
}
indexById[model.Id] = models.Count;
models.Add(model);
}
}
/// <summary>Requirements whose snapshot geometry could not be interpreted at all.</summary>
public List<string> InvalidIds { get; } = new();
public bool TryGetModel(string partId, out PartModel model)
{
model = null!;
if (!indexById.TryGetValue(partId, out var index))
return false;
model = models[index];
return true;
}
public OrientationModel Oriented(PartModel model, double angle, double spacing)
{
// Round keys so policy-equivalent angles (0 vs 2pi) share one cached orientation.
var key = (model.Id, Math.Round(angle, 9), Math.Round(spacing, 9));
if (orientations.TryGetValue(key, out var cached))
return cached;
var perimeterShape = (Shape)model.PerimeterShape.Clone();
perimeterShape.Rotate(angle);
var perimeter = perimeterShape.ToPolygonWithTolerance(
PartModel.CollisionTolerance,
circumscribe: true
);
var holes = new List<Polygon>(model.CutoutShapes.Count);
foreach (var cutout in model.CutoutShapes)
{
var shape = (Shape)cutout.Clone();
shape.Rotate(angle);
holes.Add(
shape.ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true)
);
}
Polygon? inflated = null;
var inflatedHoles = new List<Polygon>();
if (spacing > Tolerance.Epsilon)
{
// Conservative (circumscribed, padded) region offset: a superset of the
// validator's inflation, so accepted clearances never fall short. The
// offset commutes with rotation, so inflate the unrotated profile once and
// rotate the result into this orientation's frame - an unrotated inflation
// would test the candidate against the material of a different angle.
var region = ClipperBridge.Offset(model.Profile, spacing, 0.02, circumscribe: true);
var outer = region.LargestOuter();
if (outer != null)
{
outer.Rotate(angle);
outer.UpdateBounds();
inflated = outer;
}
foreach (var hole in region.Holes)
if (hole != null)
{
hole.Rotate(angle);
hole.UpdateBounds();
inflatedHoles.Add(hole);
}
}
var result = new OrientationModel(angle, perimeter, holes, inflated, inflatedHoles, spacing);
orientations[key] = result;
return result;
}
/// <summary>
/// Legal orientations for a requirement: exactly the policy angles when the policy
/// enumerates them, otherwise 0/90/180/270 degrees plus the minimum-area bounding
/// rectangle angle (rotating-calipers), with 180-degree equivalents included.
/// </summary>
public static List<double> CandidateAngles(PartModel model)
{
var angles = new List<double>();
var policy = model.Rotation;
if (policy.Kind == RotationPolicyKind.Automatic)
{
angles.Add(0);
angles.Add(Math.PI / 2);
angles.Add(Math.PI);
angles.Add(3 * Math.PI / 2);
try
{
var hull = ConvexHull.Compute(
model
.PerimeterShape
.ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true)
.Vertices
);
var obb = RotatingCalipers.MinimumBoundingRectangle(hull);
var normalized = OpenNest.Math.Angle.NormalizeRad(obb.Angle);
if (normalized > 0.001 && normalized < Math.PI - 0.001)
{
angles.Add(normalized);
angles.Add(OpenNest.Math.Angle.NormalizeRad(normalized + Math.PI));
}
}
catch (Exception)
{
// A calipers failure only costs candidate angles, never correctness.
}
}
else if (policy.Kind == RotationPolicyKind.Fixed)
{
angles.Add(policy.Start);
if (policy.Allow180Equivalent)
angles.Add(policy.Start + Math.PI);
}
else
{
// BoundedSweep: enumerate the exact step grid the policy allows.
var count = (int)Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9);
if (count < 0)
count = 0;
if (count > 4000)
count = 4000;
for (var i = 0; i <= count; i++)
{
angles.Add(policy.Start + i * policy.Step);
if (policy.Allow180Equivalent)
angles.Add(policy.Start + i * policy.Step + Math.PI);
}
}
// Normalize to [0, 2pi), deduplicate, preserve first-seen order (deterministic).
var unique = new List<double>();
foreach (var angle in angles)
{
var normalized = OpenNest.Math.Angle.NormalizeRad(angle);
if (normalized < 0)
normalized += 2 * Math.PI;
var duplicate = false;
foreach (var existing in unique)
if (Math.Abs(SignedDelta(existing, normalized)) < 1e-9)
{
duplicate = true;
break;
}
if (!duplicate)
unique.Add(normalized);
}
return unique;
}
private static double SignedDelta(double a, double b)
{
var delta = (a - b) % (2 * Math.PI);
if (delta > Math.PI)
delta -= 2 * Math.PI;
if (delta < -Math.PI)
delta += 2 * Math.PI;
return delta;
}
}
@@ -0,0 +1,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;
/// <summary>A committed placement: model, orientation, and origin position on the sheet.</summary>
internal readonly struct PlacedPart
{
public PlacedPart(PartModel model, OrientationModel orientation, double x, double y)
{
Model = model;
Orientation = orientation;
X = x;
Y = y;
}
public PartModel Model { get; }
public OrientationModel Orientation { get; }
public double X { get; }
public double Y { get; }
}
internal readonly struct PlacementResult
{
public PlacementResult(PlacedPart part)
{
Part = part;
}
public PlacedPart Part { get; }
}
/// <summary>
/// One sheet packed by this engine's own algorithm: bottom-left greedy insertion of
/// convex NFP corner candidates.
/// <para>
/// For each candidate orientation the packer builds a convex No-Fit-Polygon per
/// already-placed part as placedHull (+) disk(spacing) (+) reflect(candidateHull) -
/// a superset of the true NFP because hulls ignore concavities and cutouts, so an
/// anchor outside every NFP plus inside the anchor work-box is always legal ("strict"
/// certification). An anchor inside an NFP is still accepted when the exact material
/// gate says the parts clear: that gate inflates the placed part's material by the
/// spacing (holes shrunk, closed holes treated solid) and tests it against the
/// candidate's raw material with holes subtracted - the same inflation rule the
/// benchmark validator uses, so interlocking concave parts are recovered without ever
/// accepting an overlap or a spacing violation.
/// </para>
/// <para>
/// Candidate anchors are the corner points of the feasible region: the four anchor
/// work-box corners, every NFP vertex, and every NFP-edge/box-line crossing (slides).
/// Candidates are tried in ascending bottom-left order and the first legal one wins.
/// </para>
/// </summary>
internal sealed class SheetPacker
{
private const int MaxHullVertices = 40;
private readonly double _workLeft;
private readonly double _workBottom;
private readonly double _workRight;
private readonly double _workTop;
// Per-placed-part caches (indexed by placement order).
private readonly List<Bounds> _inflatedBounds = new();
private readonly List<(Polygon Perimeter, List<Polygon> Holes)> _placedGate = new();
// NFP caches: (placedIndex, orientationId) -> forbidden-anchor contour.
private readonly Dictionary<(int, int), ConvexContour?> _nfpCache = new();
private readonly Dictionary<OrientationModel, int> _orientationIds = new();
private readonly Dictionary<OrientationModel, ConvexContour> _reflectedHulls = new();
private readonly Dictionary<int, ConvexContour> _placedHullDisk = new();
private ConvexContour? _disk;
// Uniform spatial grid over placed parts' inflated bounds: IsLegal only tests the
// parts whose cells touch the candidate's cells, so legality stays near-constant
// as a sheet fills instead of scanning every placed part.
private readonly double _cellSize;
private readonly int _gridCols;
private readonly int _gridRows;
private readonly List<int>[] _grid;
private SheetPacker(NestPlateStock stock, PartPreparation prep, int stockIndex)
{
Stock = stock;
StockIndex = stockIndex;
Preparation = prep;
Spacing = stock.PartSpacing;
var left = stock.Quadrant is 1 or 4 ? 0.0 : -stock.Size.Length;
var bottom = stock.Quadrant is 1 or 2 ? 0.0 : -stock.Size.Width;
_workLeft = left + stock.EdgeSpacing.Left;
_workBottom = bottom + stock.EdgeSpacing.Bottom;
_workRight = left + stock.Size.Length - stock.EdgeSpacing.Right;
_workTop = bottom + stock.Size.Width - stock.EdgeSpacing.Top;
WorkWidth = _workRight - _workLeft;
WorkHeight = _workTop - _workBottom;
// Cells roughly the size of a mid-range part: a candidate usually touches 2-6.
_cellSize = System.Math.Max(1.0, System.Math.Min(WorkWidth, WorkHeight) / 12.0);
_gridCols = System.Math.Max(1, (int)System.Math.Ceiling(WorkWidth / _cellSize));
_gridRows = System.Math.Max(1, (int)System.Math.Ceiling(WorkHeight / _cellSize));
_grid = new List<int>[_gridCols * _gridRows];
for (var i = 0; i < _grid.Length; i++)
_grid[i] = new List<int>();
}
public static SheetPacker Create(NestPlateStock stock, PartPreparation prep, int stockIndex) =>
new(stock, prep, stockIndex);
public NestPlateStock Stock { get; }
public int StockIndex { get; }
public PartPreparation Preparation { get; }
public double Spacing { get; }
public double WorkWidth { get; }
public double WorkHeight { get; }
public List<PlacedPart> Placed { get; } = new();
public bool IsFull => Placed.Count >= MaxPartsPerSheet;
/// <summary>
/// Safety cap on parts per sheet: real sheets never exceed this, and it bounds
/// per-insert NFP work and the validator's area budget on pathological jobs.
/// </summary>
public const int MaxPartsPerSheet = 500;
/// <summary>True when the part's bounds can never fit this sheet in any orientation.</summary>
public bool CanEverFit(PartModel model)
{
foreach (var angle in PartPreparation.CandidateAngles(model))
{
var orientation = Preparation.Oriented(model, angle, 0);
if (orientation.Width <= WorkWidth + 1e-9 && orientation.Height <= WorkHeight + 1e-9)
return true;
}
return false;
}
internal long DiagInsertAttempts;
internal long DiagCandidateChecks;
internal long DiagGateCalls;
internal long DiagConvexRejections;
public string DiagStats() =>
$"inserts={DiagInsertAttempts} checks={DiagCandidateChecks} gates={DiagGateCalls} " +
$"convexRej={DiagConvexRejections}";
/// <summary>
/// Greedily insert an instance: best (bottom-left) legal corner over all candidate
/// orientations. Returns false (and changes nothing) when no legal position exists.
/// </summary>
public bool TryInsert(PartModel model, out PlacementResult result)
{
result = default;
var bestScore = double.MaxValue;
PlacedPart? best = null;
DiagInsertAttempts++;
foreach (var angle in PartPreparation.CandidateAngles(model))
{
var orientation = Preparation.Oriented(model, angle, Spacing);
if (orientation.Width > WorkWidth + 1e-9 || orientation.Height > WorkHeight + 1e-9)
continue;
foreach (var (x, y) in OrderedCandidates(orientation))
{
var score = Score(orientation, x, y);
if (score >= bestScore)
continue; // no later candidate (same sort) can beat it
if (!IsLegal(orientation, x, y))
continue;
bestScore = score;
best = new PlacedPart(model, orientation, x, y);
break; // first legal in ascending-score order is this orientation's best
}
}
if (best == null)
return false;
Commit(best.Value);
result = new PlacementResult(best.Value);
return true;
}
private double Score(OrientationModel orientation, double x, double y) =>
x + orientation.MinX + (y + orientation.MinY) * 1.0001;
/// <summary>
/// Corner candidates in deterministic ascending bottom-left order: anchor work-box
/// corners, NFP vertices, and NFP-edge/box-line crossings.
/// </summary>
private List<(double x, double y)> OrderedCandidates(OrientationModel orientation)
{
var boxLeft = _workLeft - orientation.MinX;
var boxRight = _workRight - orientation.MaxX;
var boxBottom = _workBottom - orientation.MinY;
var boxTop = _workTop - orientation.MaxY;
var seen = new HashSet<(long, long)>();
var candidates = new List<(double, double)>(128);
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<double, double> add
)
{
var (ua, ub) = vertical ? (ax, bx) : (ay, by);
if (ua == ub)
return;
var t = (at - ua) / (ub - ua);
if (t < 0 || t > 1)
return;
var along = vertical ? ay + (by - ay) * t : ax + (bx - ax) * t;
if (vertical)
add(at, along);
else
add(along, at);
}
private void Commit(PlacedPart part)
{
Placed.Add(part);
var pad = Spacing;
_inflatedBounds.Add(
new Bounds(
part.X + part.Orientation.MinX - pad,
part.Y + part.Orientation.MinY - pad,
part.X + part.Orientation.MaxX + pad,
part.Y + part.Orientation.MaxY + pad
)
);
// Gate geometry: material inflated by spacing (holes shrunk) when positive,
// raw material at zero spacing; already in world coordinates.
var gatePerimeter = part.Orientation.InflatedPerimeter ?? part.Orientation.Perimeter;
var gateHoles = part.Orientation.InflatedPerimeter != null
? part.Orientation.InflatedHoles
: part.Orientation.Holes;
var worldPerimeter = (Polygon)gatePerimeter.Clone();
worldPerimeter.Offset(part.X, part.Y);
worldPerimeter.UpdateBounds();
var worldHoles = new List<Polygon>(gateHoles.Count);
foreach (var hole in gateHoles)
{
var h = (Polygon)hole.Clone();
h.Offset(part.X, part.Y);
h.UpdateBounds();
worldHoles.Add(h);
}
_placedGate.Add((worldPerimeter, worldHoles));
GridAdd(Placed.Count - 1, _inflatedBounds[^1]);
}
// ---- uniform spatial grid (cell -> placed indices) --------------------------
private void GridAdd(int placedIndex, in Bounds bounds)
{
var c0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var c1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var r0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
var r1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
_grid[r * _gridCols + c].Add(placedIndex);
}
private readonly HashSet<int> _nearScratch = new();
private HashSet<int> Near(in Bounds bounds)
{
_nearScratch.Clear();
var c0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var c1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxX - _workLeft) / _cellSize),
0,
_gridCols - 1
);
var r0 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MinY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
var r1 = System.Math.Clamp(
(int)System.Math.Floor((bounds.MaxY - _workBottom) / _cellSize),
0,
_gridRows - 1
);
for (var r = r0; r <= r1; r++)
for (var c = c0; c <= c1; c++)
foreach (var index in _grid[r * _gridCols + c])
_nearScratch.Add(index);
return _nearScratch;
}
/// <summary>
/// Legality of one anchor. Outside every overlapping NFP is strict certification;
/// inside one still passes when the exact material gate clears (interlocking
/// concaves and cutouts that the convex NFP cannot represent).
/// </summary>
private bool IsLegal(OrientationModel orientation, double x, double y)
{
if (
x + orientation.MinX < _workLeft - 1e-9
|| x + orientation.MaxX > _workRight + 1e-9
|| y + orientation.MinY < _workBottom - 1e-9
|| y + orientation.MaxY > _workTop + 1e-9
)
return false;
var pad = Spacing;
var candidate = new Bounds(
x + orientation.MinX - pad,
y + orientation.MinY - pad,
x + orientation.MaxX + pad,
y + orientation.MaxY + pad
);
// World-space candidate material, built at most once per anchor and only when
// a convex-NFP hit actually needs the exact gate; freed with the anchor.
(Polygon Perimeter, List<Polygon> Holes)? gate = null;
foreach (var i in Near(candidate))
{
var bounds = _inflatedBounds[i];
if (
candidate.MinX >= bounds.MaxX
|| candidate.MaxX <= bounds.MinX
|| candidate.MinY >= bounds.MaxY
|| candidate.MaxY <= bounds.MinY
)
continue;
// 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;
}
/// <summary>
/// Exact clearance gate against one placed part: placed gate material (inflated by
/// spacing when positive) versus the candidate's raw material with holes subtracted.
/// </summary>
private bool MaterialOverlap(
(Polygon Perimeter, List<Polygon> Holes) gate,
OrientationModel orientation,
double x,
double y,
int placedIndex
)
{
var placed = _placedGate[placedIndex];
if (!placed.Perimeter.BoundingBox.Intersects(orientation.Perimeter.BoundingBox.Translate(x, y)))
return false;
if (!gate.Perimeter.BoundingBox.Intersects(placed.Perimeter.BoundingBox))
return false;
var placedPart = Placed[placedIndex];
return Preparation.MaterialOverlapMemo(
placedPart.Orientation,
placedPart.X,
placedPart.Y,
orientation,
x,
y,
() => Collision.HasOverlap(
gate.Perimeter,
placed.Perimeter,
gate.Holes,
placed.Holes
)
);
}
private (Polygon, List<Polygon>) BuildCandidateGate(OrientationModel orientation, double x, double y)
{
var perimeter = (Polygon)orientation.Perimeter.Clone();
perimeter.Offset(x, y);
perimeter.UpdateBounds();
var holes = new List<Polygon>(orientation.Holes.Count);
foreach (var hole in orientation.Holes)
{
var h = (Polygon)hole.Clone();
h.Offset(x, y);
h.UpdateBounds();
holes.Add(h);
}
return (perimeter, holes);
}
private int OrientationId(OrientationModel orientation)
{
if (!_orientationIds.TryGetValue(orientation, out var id))
{
id = _orientationIds.Count;
_orientationIds[orientation] = id;
}
return id;
}
/// <summary>
/// Convex NFP of forbidden anchors: placedHull (+) disk(spacing) (+) reflect(candidateHull).
/// </summary>
private ConvexContour? NfpFor(int placedIndex, OrientationModel orientation)
{
var key = (placedIndex, OrientationId(orientation));
if (_nfpCache.TryGetValue(key, out var cached))
return cached;
ConvexContour? result;
try
{
if (!_placedHullDisk.TryGetValue(placedIndex, out var placedDisk))
{
var placed = Placed[placedIndex];
var hull = placed.Orientation.Hull;
var capped = CapHull(hull, placed.X, placed.Y);
var placedHull = ConvexContour.FromVertices(capped);
placedDisk = Spacing > Tolerance.Epsilon
? NfpGeometry.Minkowski(placedHull, Disk())
: placedHull;
_placedHullDisk[placedIndex] = placedDisk;
}
if (!_reflectedHulls.TryGetValue(orientation, out var reflected))
{
var capped = CapHull(orientation.Hull, 0, 0);
reflected = NfpGeometry.Reflect(ConvexContour.FromVertices(capped));
_reflectedHulls[orientation] = reflected;
}
result = NfpGeometry.Minkowski(placedDisk, reflected);
}
catch (Exception)
{
// A degenerate Minkowski sum removes the fast rejection for this pair;
// the material gate still enforces correctness.
result = null;
}
_nfpCache[key] = result;
return result;
}
/// <summary>
/// Bounded-size convex SUPERSET of <paramref name="hull"/> (translated by dx/dy).
/// When the hull is dense, keep every k-th vertex, then shift each chord's
/// supporting line outward by the chord's maximum sagitta (the largest distance of
/// any dropped vertex to its chord). Every dropped vertex lies within the sagitta
/// of its chord, so the offset half-plane intersection contains the original hull
/// and the NFP built from it stays a conservative superset of the forbidden anchors.
/// </summary>
private static List<Vector> CapHull(List<Vector> hull, double dx, double dy)
{
var n = hull.Count;
var shifted = new List<Vector>(n);
for (var i = 0; i < n; i++)
shifted.Add(new Vector(hull[i].X + dx, hull[i].Y + dy));
if (n <= MaxHullVertices)
return shifted;
// Chord (v_i, v_{i+k}) for i in steps of k, with each chord's outward shift:
// the max perpendicular distance from any vertex it spans to the chord line.
var k = (int)Math.Ceiling(n / (double)MaxHullVertices);
var lines = new List<(double ax, double ay, double bx, double by, double shift)>();
for (var i = 0; i < n; i += k)
{
var a = shifted[i];
var b = shifted[(i + k) % n];
var span = Math.Min(k, n - i);
var sagitta = 0.0;
var length = Math.Sqrt((b.X - a.X) * (b.X - a.X) + (b.Y - a.Y) * (b.Y - a.Y));
if (length > 1e-12)
for (var j = 1; j < span; j++)
{
var p = shifted[i + j];
var distance = Math.Abs(Cross(a.X, a.Y, b.X, b.Y, p)) / length;
if (distance > sagitta)
sagitta = distance;
}
lines.Add((a.X, a.Y, b.X, b.Y, sagitta));
}
// Sutherland-Hodgman from a generous bounding box; the interior of each chord
// is the CCW left side, shifted outward (left) by the sagitta.
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var v in shifted)
{
if (v.X < minX)
minX = v.X;
if (v.X > maxX)
maxX = v.X;
if (v.Y < minY)
minY = v.Y;
if (v.Y > maxY)
maxY = v.Y;
}
var margin = Math.Max(1.0, Math.Max(maxX - minX, maxY - minY));
var polygon = new List<Vector>
{
new(minX - margin, minY - margin),
new(maxX + margin, minY - margin),
new(maxX + margin, maxY + margin),
new(minX - margin, maxY + margin),
};
foreach (var (ax, ay, bx, by, shift) in lines)
{
if (polygon.Count == 0)
return shifted; // degenerate; fall back to full hull
// Shift the line perpendicular away from the interior (CCW: interior is left).
var edgeX = bx - ax;
var edgeY = by - ay;
var length = Math.Sqrt(edgeX * edgeX + edgeY * edgeY);
if (length <= 1e-12)
continue;
var nx = edgeY / length;
var ny = -edgeX / length;
var ox = ax + nx * shift;
var oy = ay + ny * shift;
var input = polygon;
polygon = new List<Vector>();
for (var i = 0; i < input.Count; i++)
{
var current = input[i];
var next = input[(i + 1) % input.Count];
var currentInside = Cross(ox, oy, ox + edgeX, oy + edgeY, current) >= 0;
var nextInside = Cross(ox, oy, ox + edgeX, oy + edgeY, next) >= 0;
if (currentInside)
{
polygon.Add(current);
if (!nextInside)
polygon.Add(Intersect(ox, oy, ox + edgeX, oy + edgeY, current, next));
}
else if (nextInside)
{
polygon.Add(Intersect(ox, oy, ox + edgeX, oy + edgeY, current, next));
}
}
}
return polygon.Count >= 3 ? polygon : shifted;
}
private static double Cross(double ax, double ay, double bx, double by, Vector p) =>
(bx - ax) * (p.Y - ay) - (by - ay) * (p.X - ax);
private static Vector Intersect(
double ax,
double ay,
double bx,
double by,
Vector p,
Vector q
)
{
var dx1 = bx - ax;
var dy1 = by - ay;
var dx2 = q.X - p.X;
var dy2 = q.Y - p.Y;
var cross = dx1 * dy2 - dy1 * dx2;
if (Math.Abs(cross) < 1e-300)
return p;
var t = ((p.X - ax) * dy2 - (p.Y - ay) * dx2) / cross;
return new Vector(ax + t * dx1, ay + t * dy1);
}
private ConvexContour Disk() =>
// Circumscribed so the polygon contains the true spacing disk: the NFP stays a
// conservative superset of the forbidden-anchor region.
_disk ??= ConvexContour.Disk(Spacing / Math.Cos(Math.PI / 24), 24);
}
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Shared settings and the OpenNest.Engine reference come from Directory.Build.props. -->
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Engine.Qwen38FlashNext.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
using System;
using System.Threading;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Qwen38FlashNext.Engine;
namespace OpenNest.Engine.Qwen38FlashNext;
/// <summary>
/// Independent whole-job nesting engine: bottom-left-first placement over convex
/// No-Fit-Polygons with an exact material-clearance gate, driven sheet by sheet by a
/// greedy demand scheduler.
/// <para>
/// Per sheet, parts are demanded in the engine's own order (priority, then the 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.
/// </para>
/// <para>
/// The engine is self-contained: it calls no built-in <see cref="INestingEngine"/>,
/// nester, filler, or runner, and is deterministic - identical input, identical layout.
/// </para>
/// </summary>
public sealed class Qwen38FlashNextNestingEngine : INestingEngine
{
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
var preparation = new PartPreparation(job.Parts);
var solver = new JobSolver(job, preparation);
return solver.Solve(progress, token);
}
}
+112
View File
@@ -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>/OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
mkdir -p <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/Engines
cp OpenNest.Engine.Qwen38FlashNext/bin/Release/net8.0/OpenNest.Engine.Qwen38FlashNext.dll <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/Engines/
dotnet <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <path-to-.nest-or-folder> --parallel 1
```
`<OpenNest>` is the OpenNest checkout root. Your engine shows up in the report under its
CLR type name (`Qwen38FlashNextNestingEngine`), competing on equal footing against the built-in
engines.
@@ -0,0 +1,151 @@
using System;
using Xunit;
using OpenNest.Geometry;
using OpenNest.Engine.Qwen38FlashNext.Engine;
namespace OpenNest.Engine.Qwen38FlashNext.Tests;
/// <summary>
/// These tests target the engine's internal NFP math through its public surface
/// (SheetPacker via reflection is overkill; ConvexContour/NfpGeometry are internal,
/// so InternalsVisibleTo is required).
/// </summary>
public class NfpGeometryTests
{
private static ConvexContour Square(double x0, double y0, double x1, double y1) =>
ConvexContour.FromVertices(
new[]
{
new Vector(x0, y0),
new Vector(x1, y0),
new Vector(x1, y1),
new Vector(x0, y1),
}
);
[Fact]
public void MinkowskiOfTwoSquaresIsTheExpectedRectangle()
{
var a = Square(0, 0, 10, 10);
var b = Square(-5, -5, 5, 5); // centered square, side 10
var sum = NfpGeometry.Minkowski(a, b);
// [0,10]^2 + [-5,5]^2 = [-5,15]^2
Assert.Equal(-5, sum.MinX, 6);
Assert.Equal(-5, sum.MinY, 6);
Assert.Equal(15, sum.MaxX, 6);
Assert.Equal(15, sum.MaxY, 6);
// Strict containment sanity: center inside, far corner outside.
Assert.True(sum.ContainsPoint(0, 0));
Assert.True(sum.ContainsPoint(14.9, 14.9));
Assert.False(sum.ContainsPoint(20, 20));
var n = sum.Count;
for (var i = 0; i < n; i++)
{
var ax = sum.X(i);
var ay = sum.Y(i);
var bx = sum.X((i + 1) % n);
var by = sum.Y((i + 1) % n);
var cx = sum.X((i + 2) % n);
var cy = sum.Y((i + 2) % n);
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
Assert.True(cross >= -1e-9, $"non-convex (clockwise) turn at vertex {i} of Minkowski result");
}
}
[Fact]
public void MinkowskiOfTrianglesIsConvexAndContainsTheSums()
{
var a = ConvexContour.FromVertices(
new[] { new Vector(0, 0), new Vector(10, 0), new Vector(0, 10) }
);
var b = ConvexContour.FromVertices(
new[] { new Vector(0, 0), new Vector(4, 0), new Vector(0, 4) }
);
var sum = NfpGeometry.Minkowski(a, b);
// Vertex sums must lie on the boundary of the true Minkowski sum.
Assert.True(sum.ContainsPoint(1, 1));
Assert.True(sum.ContainsPoint(9, 1));
Assert.True(sum.ContainsPoint(1, 12));
var n = sum.Count;
for (var i = 0; i < n; i++)
{
var ax = sum.X(i);
var ay = sum.Y(i);
var bx = sum.X((i + 1) % n);
var by = sum.Y((i + 1) % n);
var cx = sum.X((i + 2) % n);
var cy = sum.Y((i + 2) % n);
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
Assert.True(cross >= -1e-9, $"non-convex turn at vertex {i}");
}
}
[Fact]
public void ReflectPreservesCcwWinding()
{
var a = Square(0, 0, 10, 10);
var r = NfpGeometry.Reflect(a);
Assert.Equal(-10, r.MinX, 6);
Assert.Equal(-10, r.MinY, 6);
Assert.Equal(0, r.MaxX, 6);
Assert.Equal(0, r.MaxY, 6);
var n = r.Count;
for (var i = 0; i < n; i++)
{
var ax = r.X(i);
var ay = r.Y(i);
var bx = r.X((i + 1) % n);
var by = r.Y((i + 1) % n);
var cx = r.X((i + 2) % n);
var cy = r.Y((i + 2) % n);
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
Assert.True(cross >= -1e-9, $"Reflect produced a non-CCW contour at vertex {i}");
}
}
[Fact]
public void NfpOfTwoSquaresIsTheForbiddenAnchorSquare()
{
// Placed [0,10]^2, candidate [0,10]^2, zero spacing: NFP of forbidden
// anchors = placed (+) reflect(candidate) = (-10,10)^2. Anchors strictly
// inside it overlap; anchors outside it clear.
var placed = Square(0, 0, 10, 10);
var candidate = Square(0, 0, 10, 10);
var nfp = NfpGeometry.Minkowski(placed, NfpGeometry.Reflect(candidate));
Assert.Equal(-10, nfp.MinX, 6);
Assert.Equal(-10, nfp.MinY, 6);
Assert.Equal(10, nfp.MaxX, 6);
Assert.Equal(10, nfp.MaxY, 6);
Assert.True(nfp.ContainsPoint(5, 5)); // overlap
Assert.True(nfp.ContainsPoint(-5, -5)); // overlap
// Boundary contact counts as forbidden (conservative): the fast-path
// certification only accepts anchors CLEAR of the NFP; contact defers to
// the exact material gate.
Assert.True(nfp.ContainsPoint(10, 0));
Assert.False(nfp.ContainsPoint(0, 10.001)); // beyond top, legal
var n = nfp.Count;
for (var i = 0; i < n; i++)
{
var ax = nfp.X(i);
var ay = nfp.Y(i);
var bx = nfp.X((i + 1) % n);
var by = nfp.Y((i + 1) % n);
var cx = nfp.X((i + 2) % n);
var cy = nfp.Y((i + 2) % n);
var cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx);
Assert.True(cross >= -1e-9, $"non-convex turn at vertex {i}");
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Engine.Qwen38FlashNext.csproj" />
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
<ProjectReference Include="$(OpenNestRoot)OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Benchmark;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Qwen38FlashNext.Tests;
/// <summary>
/// Starter acceptance tests. Every layout is checked by the same NestValidator the benchmark
/// scores with, so a passing test means the benchmark will accept the layout. They fail until
/// Solve() is implemented; add engine-specific tests alongside them.
/// </summary>
public class Qwen38FlashNextNestingEngineTests
{
[Fact]
public void HasPublicParameterlessConstructorForPluginDiscovery()
{
var engine = Activator.CreateInstance(typeof(Qwen38FlashNextNestingEngine));
Assert.IsAssignableFrom<INestingEngine>(engine);
}
[Fact]
public void RectanglesFitOnOneSheetWithSpacing()
{
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Assert.Equal(12, result.Plates[0].Placements.Count);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
{
var job = Job(
new[]
{
Part("disc", Disc(3), 10),
Part("ell", LShape(12, 8, 4), 10),
Part("tri", Triangle(9, 6), 10),
},
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
);
var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void RotatedConcavePartsKeepSpacingAtFixedAngles()
{
// Regression: the per-orientation spacing inflation must live in the rotated
// frame. L-shapes pinned to 90/270 degrees exercise exactly the orientations
// where an unrotated inflation misrepresents the material and lets parts
// rest closer than the spacing.
var l = Part(
"l90",
LShape(12, 8, 4),
8,
RotationPolicy.Fixed(System.Math.PI / 2, allow180Equivalent: true)
);
var job = Job(new[] { l }, new[] { Stock("sheet", 40, 60, spacing: 0.5) });
var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
}
[Fact]
public void OverflowSpillsOntoAdditionalSheets()
{
var job = Job(new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) });
var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Count > 1);
}
[Fact]
public void PartTooBigForAnySheetIsReportedUnplaced()
{
var job = Job(
new[] { Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(5, 5), 4) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) }
);
var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result);
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
Assert.Equal(1, huge.Unplaced);
}
// ---- helpers -------------------------------------------------------------------------
private static void AssertValid(NestJob job, NestJobResult result)
{
var materialized = NestResultMaterializer.Materialize(job, result);
var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList();
var requirements = job.Parts.ToDictionary<NestJobPart, Drawing, (string Name, int Quantity)>(
p => materialized.DrawingsByPartId[p.Id],
p => (p.Id, p.Quantity),
ReferenceEqualityComparer.Instance
);
var validation = NestValidator.Validate(runs, requirements);
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations));
foreach (var f in result.Fulfillment)
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
}
private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
new(parts, stock, options);
private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) =>
new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation);
/// <param name="width">Y extent.</param>
/// <param name="length">X extent.</param>
private static NestPlateStock Stock(
string id,
double width,
double length,
double spacing = 0,
Spacing edge = default,
int quadrant = 1,
int? quantity = null
) => new(id, new Size(width, length), quantity, spacing, edge, quadrant);
private static Program Polyline(params (double X, double Y)[] points)
{
var program = new Program();
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
foreach (var (x, y) in points.Skip(1))
program.Codes.Add(new LinearMove(x, y));
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
return program;
}
private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
private static Program Disc(double r)
{
var program = new Program();
program.Codes.Add(new RapidMove(r, 0));
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
return program;
}
}