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