Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa0e6f967e |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user