From cbef2f5e4187b55a12114bb98e7081005d738ec4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 05:10:50 -0400 Subject: [PATCH] feat(engine): Opus55 frontier-advance NFP nesting engine Rename the OpenNest.Engine.Sonnet5 scaffold to OpenNest.Engine.Opus55 and implement an independent whole-job INestingEngine (no built-in engine, registry, or best-fit internals are called or copied). - PartCatalog: snapshot perimeter -> polygon per allowed orientation, with adaptive chord tolerance and MBR-aligned rotations for Automatic parts. - NoFitCache: spacing footprints and cached Clipper2 Minkowski NFPs (convex fast path; concave sweep plus both containment terms). - FrontierPacker: per-(type, orientation) free regions (inner-fit rectangle minus NFPs), updated incrementally; gap-fill-largest, else least front advance per area^beta. - Engine: look-ahead stock choice by estimated whole-job net area, six deterministic variants, tail re-plan of the last 1-3 sheets. - Tests judged by OpenNest.Benchmark's NestValidator, including an NFP containment regression guard. P260805-10.nest (219 parts), all 9 stock sizes: 219/219 valid, 27 sheets, 91.7% utilization, ~7 s. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Engine.Opus55/FrontierPacker.cs | 272 ++++++++++++++++ OpenNest.Engine.Opus55/NoFitCache.cs | 171 ++++++++++ .../OpenNest.Engine.Opus55.csproj | 5 +- OpenNest.Engine.Opus55/Opus55NestingEngine.cs | 294 ++++++++++++++++++ OpenNest.Engine.Opus55/PartCatalog.cs | 274 ++++++++++++++++ OpenNest.Engine.Opus55/README.md | 95 ++++++ OpenNest.Engine.Opus55/SheetEconomics.cs | 41 +++ .../tests/NoFitCacheTests.cs | 68 ++++ .../tests/OpenNest.Engine.Opus55.Tests.csproj | 4 +- .../tests/Opus55NestingEngineTests.cs | 283 +++++++++++++++++ OpenNest.Engine.Sonnet5/README.md | 57 ---- .../Sonnet5NestingEngine.cs | 41 --- .../tests/Sonnet5NestingEngineTests.cs | 20 -- 13 files changed, 1504 insertions(+), 121 deletions(-) create mode 100644 OpenNest.Engine.Opus55/FrontierPacker.cs create mode 100644 OpenNest.Engine.Opus55/NoFitCache.cs rename OpenNest.Engine.Sonnet5/OpenNest.Engine.Sonnet5.csproj => OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj (65%) create mode 100644 OpenNest.Engine.Opus55/Opus55NestingEngine.cs create mode 100644 OpenNest.Engine.Opus55/PartCatalog.cs create mode 100644 OpenNest.Engine.Opus55/README.md create mode 100644 OpenNest.Engine.Opus55/SheetEconomics.cs create mode 100644 OpenNest.Engine.Opus55/tests/NoFitCacheTests.cs rename OpenNest.Engine.Sonnet5/tests/OpenNest.Engine.Sonnet5.Tests.csproj => OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj (73%) create mode 100644 OpenNest.Engine.Opus55/tests/Opus55NestingEngineTests.cs delete mode 100644 OpenNest.Engine.Sonnet5/README.md delete mode 100644 OpenNest.Engine.Sonnet5/Sonnet5NestingEngine.cs delete mode 100644 OpenNest.Engine.Sonnet5/tests/Sonnet5NestingEngineTests.cs diff --git a/OpenNest.Engine.Opus55/FrontierPacker.cs b/OpenNest.Engine.Opus55/FrontierPacker.cs new file mode 100644 index 0000000..63c8f61 --- /dev/null +++ b/OpenNest.Engine.Opus55/FrontierPacker.cs @@ -0,0 +1,272 @@ +using Clipper2Lib; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Opus55; + +/// Direction the packing front sweeps across the sheet (the free strip is left behind it). +internal enum PackAxis +{ + /// Front moves in +X; parts settle toward low X, then low Y. + X, + + /// Front moves in +Y; parts settle toward low Y, then low X. + Y, +} + +internal sealed record Placed(Orientation Orientation, double X, double Y) +{ + public double Left => X + Orientation.MinX; + public double Right => X + Orientation.MaxX; + public double Bottom => Y + Orientation.MinY; + public double Top => Y + Orientation.MaxY; +} + +internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList Parts, double PartArea); + +/// +/// Fills one sheet with a frontier-advance rule over incrementally maintained free regions. +/// +/// For every (part type, orientation) still in play the packer keeps the exact set of legal +/// reference points: the inner-fit rectangle of the work area minus the no-fit polygons of +/// everything already placed. Each placement subtracts one translated NFP from each region, +/// so regions only shrink, and a region that empties is retired for the rest of the sheet. +/// +/// Choice rule, applied over all types and orientations at once (not in a fixed order): +/// 1. Gap fill - if any part fits without pushing the packing front forward, place the +/// largest such part at its lowest such point. +/// 2. Otherwise advance - place the part whose front advance per unit area^beta is smallest, +/// i.e. the one that buys the most material coverage for the sheet length it consumes. +/// Parts are never placed in a sequence given up front; the sheet state decides what comes next. +/// +internal sealed class FrontierPacker +{ + /// Slack added around the inner-fit rectangle so zero-width fits survive Clipper; + /// chosen points are clamped back, which moves them far less than the clearance margin. + private const double FitSlack = 2e-4; + + private const double Tie = 1e-6; + + private readonly IReadOnlyList types; + private readonly NoFitCache nfps; + private readonly NestPlateStock stock; + private readonly PackAxis axis; + private readonly double beta; + private readonly Box work; + private readonly WorkCounter counter; + + public FrontierPacker(IReadOnlyList types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter) + { + this.counter = counter; + this.types = types; + this.nfps = nfps; + this.stock = stock; + this.axis = axis; + this.beta = beta; + work = WorkArea(stock); + } + + public static Box WorkArea(NestPlateStock stock) + { + var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length; + var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width; + return new Box( + left + stock.EdgeSpacing.Left, + bottom + stock.EdgeSpacing.Bottom, + stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right, + stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top + ); + } + + /// True when the orientation's bounds fit the work area at all (Box.Length is the X extent). + public static bool Fits(Orientation o, Box work) => + o.Width <= work.Length + 1e-9 && o.Height <= work.Width + 1e-9; + + public SheetFill Fill(IReadOnlyList remaining, CancellationToken token) + { + var left = remaining.ToArray(); + var states = new List(); + foreach (var type in types) + { + if (left[type.Index] <= 0) + continue; + foreach (var o in type.Orientations) + if (Fits(o, work)) + states.Add(new Region(o, work)); + } + + var placed = new List(); + var partArea = 0.0; + var front = axis == PackAxis.X ? work.Left : work.Bottom; + + while (states.Count > 0) + { + token.ThrowIfCancellationRequested(); + var choice = Choose(states, front); + if (choice == null) + break; + + var (region, point) = choice.Value; + var part = new Placed(region.Orientation, point.x, point.y); + placed.Add(part); + var typeIndex = region.Orientation.TypeIndex; + partArea += types[typeIndex].Area; + front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top); + + if (--left[typeIndex] == 0) + states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex); + + // Each surviving region loses the positions the new part now blocks. Regions are + // independent, so they update in parallel without affecting determinism. + var snapshot = states.ToArray(); + counter.Add(snapshot.Length); + Parallel.For( + 0, + snapshot.Length, + new ParallelOptions { CancellationToken = token }, + i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y) + ); + states.RemoveAll(s => s.IsEmpty); + } + + return new SheetFill(stock, placed, partArea); + } + + private (Region, PointD)? Choose(List states, double front) + { + Region? bestRegion = null; + var bestPoint = default(PointD); + var bestFills = false; + var bestValue = double.PositiveInfinity; + var bestSide = double.PositiveInfinity; + var bestLead = double.PositiveInfinity; + + foreach (var region in states) + { + if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead)) + continue; + var area = types[region.Orientation.TypeIndex].Area; + var fills = advance <= Tie; + // Gap fill prefers bigger parts (negated area); advance prefers least advance per area. + var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta); + + var better = bestRegion == null + || (fills && !bestFills) + || ( + fills == bestFills + && ( + value < bestValue - Tie * System.Math.Max(1, System.Math.Abs(bestValue)) + || ( + value <= bestValue + Tie * System.Math.Max(1, System.Math.Abs(bestValue)) + && (side < bestSide - Tie || (side <= bestSide + Tie && lead < bestLead - Tie)) + ) + ) + ); + if (!better) + continue; + bestRegion = region; + bestPoint = point; + bestFills = fills; + bestValue = value; + bestSide = side; + bestLead = lead; + } + + return bestRegion == null ? null : (bestRegion, bestPoint); + } + + /// Legal reference points for one orientation on this sheet. + private sealed class Region + { + private readonly double minX, minY, maxX, maxY; + private PathsD free; + private RectD bounds; + + public Region(Orientation orientation, Box work) + { + Orientation = orientation; + minX = work.Left - orientation.MinX; + maxX = work.Right - orientation.MaxX; + minY = work.Bottom - orientation.MinY; + maxY = work.Top - orientation.MaxY; + // Guard against fits that are infeasible by less than the bounds tolerance. + if (maxX < minX) + maxX = minX; + if (maxY < minY) + maxY = minY; + free = new PathsD + { + new PathD + { + new(minX - FitSlack, minY - FitSlack), + new(maxX + FitSlack, minY - FitSlack), + new(maxX + FitSlack, maxY + FitSlack), + new(minX - FitSlack, maxY + FitSlack), + }, + }; + bounds = Clipper.GetBounds(free); + } + + public Orientation Orientation { get; } + public bool IsEmpty => free.Count == 0; + + public void Subtract(Nfp nfp, double dx, double dy) + { + if ( + nfp.Bounds.right + dx < bounds.left + || nfp.Bounds.left + dx > bounds.right + || nfp.Bounds.bottom + dy < bounds.top + || nfp.Bounds.top + dy > bounds.bottom + ) + return; + var clip = Clipper.TranslatePaths(nfp.Region, dx, dy); + free = Clipper.Difference(free, clip, FillRule.NonZero, NoFitCache.Precision); + // Drop numerical dust; a sliver thinner than the precision grid is no real room. + free.RemoveAll(p => p.Count < 3); + bounds = free.Count == 0 ? default : Clipper.GetBounds(free); + } + + /// + /// Best vertex of the free region: least front advance, then lowest cross-axis position, + /// then lowest leading edge. Vertices suffice because every score is linear in position. + /// + public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead) + { + point = default; + advance = side = lead = double.PositiveInfinity; + var found = false; + var o = Orientation; + foreach (var path in free) + foreach (var raw in path) + { + var x = System.Math.Clamp(raw.x, minX, maxX); + var y = System.Math.Clamp(raw.y, minY, maxY); + double reach, across, start; + if (axis == PackAxis.X) + { + reach = x + o.MaxX; + across = y + o.MinY; + start = x + o.MinX; + } + else + { + reach = y + o.MaxY; + across = x + o.MinX; + start = y + o.MinY; + } + var adv = System.Math.Max(0, reach - front); + var better = !found + || adv < advance - Tie + || (adv <= advance + Tie && (across < side - Tie || (across <= side + Tie && start < lead - Tie))); + if (!better) + continue; + found = true; + point = new PointD(x, y); + advance = adv; + side = across; + lead = start; + } + return found; + } + } +} diff --git a/OpenNest.Engine.Opus55/NoFitCache.cs b/OpenNest.Engine.Opus55/NoFitCache.cs new file mode 100644 index 0000000..09fa25a --- /dev/null +++ b/OpenNest.Engine.Opus55/NoFitCache.cs @@ -0,0 +1,171 @@ +using System.Collections.Concurrent; +using Clipper2Lib; + +namespace OpenNest.Engine.Opus55; + +/// +/// Spacing-inflated footprints and the no-fit polygons between them, for one clearance value. +/// +/// Every placed part owns a footprint: its outline grown by half the required clearance +/// (plus its own chord tolerance). Two parts respect the clearance exactly when their +/// footprints do not overlap, so the whole spacing rule reduces to NFP containment. +/// NFPs are translation-invariant, so each (orientation, orientation) pair is computed once +/// per job and reused by every sheet, stock trial and strategy variant. +/// +internal sealed class NoFitCache +{ + /// Clipper decimal precision; 1e-4 job units is far below any margin we keep. + public const int Precision = 4; + + private readonly double halfClearance; + private readonly ConcurrentDictionary<(int, int), PathD> footprints = new(); + private readonly ConcurrentDictionary<(int, int, int, int), Lazy> nfps = new(); + + public NoFitCache(double clearance) + { + halfClearance = clearance / 2; + } + + public PathD Footprint(Orientation o) => + footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o)); + + /// NFP of around placed at the origin. + public Nfp Get(Orientation fixedPart, Orientation moving) => + nfps.GetOrAdd( + (fixedPart.TypeIndex, fixedPart.Index, moving.TypeIndex, moving.Index), + _ => new Lazy(() => Build(fixedPart, moving), LazyThreadSafetyMode.ExecutionAndPublication) + ) + .Value; + + private PathD BuildFootprint(Orientation o) + { + // Miter joins (squared past the limit) always contain the exact round offset, so the + // footprint is a superset of "every point within the clearance of the outline". + var inflated = Clipper.InflatePaths( + new PathsD { o.Outline }, + halfClearance + o.Tolerance, + JoinType.Miter, + EndType.Polygon, + 2.0, + Precision, + 0.0 + ); + var best = inflated.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).First(); + if (!Clipper.IsPositive(best)) + best.Reverse(); + return best; + } + + private Nfp Build(Orientation fixedPart, Orientation moving) + { + var a = Footprint(fixedPart); + var b = Footprint(moving); + var negB = new PathD(b.Count); + foreach (var p in b) + negB.Add(new PointD(-p.x, -p.y)); + + PathsD region; + if (IsConvex(a) && IsConvex(b)) + { + region = new PathsD { ConvexSum(a, negB) }; + } + else + { + // A (+) P, with P = -B: a reference point the boundary sweep misses puts the moving + // copy of B clear of A's boundary, so that copy is inside A, contains A, or misses it. + // (A + p0) covers "B inside A" and (P + a0) covers "B swallows A"; both are needed. + var sweep = Minkowski.Sum(negB, a, true, Precision); + sweep.Add(Clipper.TranslatePath(a, negB[0].x, negB[0].y)); + sweep.Add(Clipper.TranslatePath(negB, a[0].x, a[0].y)); + region = Clipper.Union(sweep, new PathsD(), FillRule.NonZero, Precision); + } + return new Nfp(region, Clipper.GetBounds(region)); + } + + /// Minkowski sum of two convex CCW polygons by merging edges in angle order. + private static PathD ConvexSum(PathD a, PathD b) + { + var ia = LowestIndex(a); + var ib = LowestIndex(b); + var result = new PathD(a.Count + b.Count); + var current = new PointD(a[ia].x + b[ib].x, a[ia].y + b[ib].y); + int i = 0, j = 0; + while (i < a.Count || j < b.Count) + { + result.Add(current); + var ea = i < a.Count ? Edge(a, ia + i) : default; + var eb = j < b.Count ? Edge(b, ib + j) : default; + // Both edge sequences start at the lowest vertex, so their angles rise through [0, 2pi). + double order; + if (i >= a.Count) + order = -1; + else if (j >= b.Count) + order = 1; + else + { + var difference = EdgeAngle(eb) - EdgeAngle(ea); + order = System.Math.Abs(difference) < 1e-12 ? 0 : difference; + } + if (order > 0) + { + current = new PointD(current.x + ea.x, current.y + ea.y); + i++; + } + else if (order < 0) + { + current = new PointD(current.x + eb.x, current.y + eb.y); + j++; + } + else + { + current = new PointD(current.x + ea.x + eb.x, current.y + ea.y + eb.y); + i++; + j++; + } + } + return result; + } + + private static double EdgeAngle(PointD edge) + { + var angle = System.Math.Atan2(edge.y, edge.x); + return angle < 0 ? angle + System.Math.PI * 2 : angle; + } + + private static PointD Edge(PathD path, int index) + { + var from = path[index % path.Count]; + var to = path[(index + 1) % path.Count]; + return new PointD(to.x - from.x, to.y - from.y); + } + + /// Lowest (then leftmost) vertex: the start of a CCW edge sequence sorted by angle. + private static int LowestIndex(PathD path) + { + var best = 0; + for (var i = 1; i < path.Count; i++) + if (path[i].y < path[best].y || (path[i].y == path[best].y && path[i].x < path[best].x)) + best = i; + return best; + } + + private static bool IsConvex(PathD path) + { + var n = path.Count; + if (n < 3) + return false; + for (var i = 0; i < n; i++) + { + var a = path[i]; + var b = path[(i + 1) % n]; + var c = path[(i + 2) % n]; + var cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x); + if (cross < -1e-12) + return false; + } + return true; + } +} + +/// Forbidden reference-point region (interior = overlap, boundary = touching) and its bounds. +internal sealed record Nfp(PathsD Region, RectD Bounds); diff --git a/OpenNest.Engine.Sonnet5/OpenNest.Engine.Sonnet5.csproj b/OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj similarity index 65% rename from OpenNest.Engine.Sonnet5/OpenNest.Engine.Sonnet5.csproj rename to OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj index 0ffbe7b..b8cab81 100644 --- a/OpenNest.Engine.Sonnet5/OpenNest.Engine.Sonnet5.csproj +++ b/OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj @@ -1,13 +1,14 @@ net8.0 - OpenNest.Engine.Sonnet5 - OpenNest.Engine.Sonnet5 + OpenNest.Engine.Opus55 + OpenNest.Engine.Opus55 enable enable + diff --git a/OpenNest.Engine.Opus55/Opus55NestingEngine.cs b/OpenNest.Engine.Opus55/Opus55NestingEngine.cs new file mode 100644 index 0000000..e0cfd4e --- /dev/null +++ b/OpenNest.Engine.Opus55/Opus55NestingEngine.cs @@ -0,0 +1,294 @@ +using System; +using System.Threading; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.Opus55; + +/// +/// Frontier-advance NFP packer with look-ahead stock selection. +/// +/// Per sheet, keeps the exact free region of every +/// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places +/// either the largest part that fills a gap behind the packing front, or the part that advances +/// the front least per unit of area covered. Across sheets, every available stock size is +/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the +/// remaining demand at the best efficiency seen) is committed. A handful of deterministic +/// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins. +/// +/// Fully deterministic: no clocks or randomness influence any decision. +/// +public sealed class Opus55NestingEngine : INestingEngine +{ + /// + /// Extra clearance beyond the stock's part spacing, in job units. Validators polygonize arcs + /// circumscribed at 0.01 per side, so two tangent true arcs can read as up to 0.02 closer + /// than they are; the rest absorbs Clipper's 1e-4 grid and inner-fit clamping. + /// + internal const double ClearanceMargin = 0.022; + + /// Strategy variants, tried in order: (front direction, area exponent beta). + private static readonly (PackAxis Axis, double Beta)[] Variants = + { + (PackAxis.X, 1.0), + (PackAxis.Y, 1.0), + (PackAxis.X, 0.5), + (PackAxis.Y, 0.5), + (PackAxis.X, 1.5), + (PackAxis.Y, 1.5), + }; + + /// + /// Deterministic work budget, in free-region subtractions, after which no further variant + /// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock. + /// + internal long WorkBudget { get; init; } = 1_500_000; + + public NestJobResult Solve( + NestJob job, + IProgress? progress = null, + CancellationToken token = default + ) + { + ArgumentNullException.ThrowIfNull(job); + var types = PartCatalog.Build(job); + var solver = new Solver(job, types, progress, token); + + // Demand that no offered stock can hold in any allowed orientation is reported unplaced. + var demand = new int[types.Count]; + foreach (var type in types) + { + var placeable = job.Plates.Any(stock => + stock.Quantity != 0 + && type.Orientations.Any(o => FrontierPacker.Fits(o, FrontierPacker.WorkArea(stock))) + ); + demand[type.Index] = placeable ? type.Part.Quantity : 0; + } + + Plan? best = null; + foreach (var (axis, beta) in Variants) + { + token.ThrowIfCancellationRequested(); + if (best != null && solver.Work.Value >= WorkBudget) + break; + var plan = solver.Plan(demand, axis, beta); + if (best == null || plan.IsBetterThan(best)) + best = plan; + if (best.Unplaced == 0 && best.Sheets.Count == 0) + break; + } + + // The last sheets hold the leftovers, which is where waste concentrates; re-plan them. + best = solver.ImproveTail(best!, WorkBudget * 2); + return BuildResult(job, types, best, progress); + } + + /// Shared state for one solve: job, catalog, NFP caches, effort meter. + private sealed class Solver( + NestJob job, + IReadOnlyList types, + IProgress? progress, + CancellationToken token + ) + { + private const int MaxTail = 3; + private readonly Dictionary caches = new(); + + public WorkCounter Work { get; } = new(); + + private double Penalty => job.Plates.Count == 0 ? 0 : job.Plates.Max(SheetEconomics.SheetArea); + + public Plan Plan(int[] demand, PackAxis axis, double beta) + { + var run = Decode(demand, axis, beta, new Dictionary(StringComparer.Ordinal), job.Options.MaxPlates, null); + var unplaced = types.Sum(t => t.Part.Quantity) - run.Sheets.Sum(s => s.Parts.Count); + var reason = run.Reason; + if (unplaced > 0 && reason == NestJobStopReason.Completed) + reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold. + return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason); + } + + /// + /// Takes the parts off the last k sheets (k = 1..3) and re-plans just that demand with + /// every stock forced as the first sheet, under every variant; the cheapest complete + /// re-plan that beats the current tail replaces it. Tails are small and effort is metered. + /// + public Plan ImproveTail(Plan plan, long budget) + { + var sheets = plan.Sheets.ToList(); + for (var k = 1; k <= System.Math.Min(MaxTail, sheets.Count); k++) + { + if (Work.Value >= budget) + break; + var prefix = sheets.Take(sheets.Count - k).ToList(); + var tail = sheets.Skip(sheets.Count - k).ToList(); + var tailParts = tail.Sum(s => s.Parts.Count); + var tailNet = tail.Sum(s => SheetEconomics.NetArea(job.Options, s)); + var tailDemand = new int[types.Count]; + foreach (var part in tail.SelectMany(s => s.Parts)) + tailDemand[part.Orientation.TypeIndex]++; + var used = prefix + .GroupBy(s => s.Stock.Id) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal); + int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null; + + Run? bestRun = null; + var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet); + foreach (var (axis, beta) in Variants) + foreach (var first in job.Plates) + { + token.ThrowIfCancellationRequested(); + var run = Decode(tailDemand, axis, beta, used, cap, first); + if (run.Sheets.Sum(s => s.Parts.Count) != tailParts || run.Net >= bestNet) + continue; + bestRun = run; + bestNet = run.Net; + } + + if (bestRun == null) + continue; + sheets = prefix.Concat(bestRun.Sheets).ToList(); + plan = plan with { Sheets = sheets.ToList(), Cost = plan.Cost - (tailNet - bestRun.Net) }; + } + return plan; + } + + private NoFitCache CacheFor(NestPlateStock stock) + { + var clearance = System.Math.Max(0, stock.PartSpacing) + ClearanceMargin; + if (!caches.TryGetValue(clearance, out var cache)) + caches[clearance] = cache = new NoFitCache(clearance); + return cache; + } + + /// + /// Greedy sheet-by-sheet decode. seeds finite-stock + /// accounting, bounds the sheets this run may add, and + /// , when set, forces the stock of the first sheet. + /// + private Run Decode( + int[] demand, + PackAxis axis, + double beta, + IReadOnlyDictionary usedBefore, + int? sheetCap, + NestPlateStock? first + ) + { + var remaining = (int[])demand.Clone(); + var used = job.Plates.ToDictionary(s => s.Id, s => usedBefore.GetValueOrDefault(s.Id), StringComparer.Ordinal); + var sheets = new List(); + var net = 0.0; + NestJobStopReason reason; + + while (true) + { + if (remaining.All(r => r == 0)) + { + reason = NestJobStopReason.Completed; + break; + } + if (sheetCap is int cap && sheets.Count >= cap) + { + reason = NestJobStopReason.PlateLimitReached; + break; + } + + var trials = new List<(SheetFill Fill, double Net)>(); + foreach (var stock in job.Plates) + { + token.ThrowIfCancellationRequested(); + if (sheets.Count == 0 && first != null && !ReferenceEquals(stock, first)) + continue; + if (stock.Quantity is int available && used[stock.Id] >= available) + continue; + progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0)); + var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work); + var fill = packer.Fill(remaining, token); + if (fill.Parts.Count > 0) + trials.Add((fill, SheetEconomics.NetArea(job.Options, fill))); + } + + if (trials.Count == 0) + { + var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q); + reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound; + break; + } + + // Look-ahead: charge whatever a trial leaves behind at the best efficiency any trial + // achieved, so a sheet that finishes the job competes fairly with a denser partial one. + var remainingArea = types.Sum(t => remaining[t.Index] * t.Area); + var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12)); + var chosen = trials + .Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio)) + .OrderBy(t => t.Estimate) + .ThenByDescending(t => t.Fill.Parts.Count) + .ThenBy(t => t.order) + .First(); + + sheets.Add(chosen.Fill); + net += chosen.Net; + used[chosen.Fill.Stock.Id]++; + foreach (var part in chosen.Fill.Parts) + remaining[part.Orientation.TypeIndex]--; + } + + return new Run(sheets, net, reason); + } + } + + private sealed record Run(IReadOnlyList Sheets, double Net, NestJobStopReason Reason); + + private static NestJobResult BuildResult( + NestJob job, + IReadOnlyList types, + Plan plan, + IProgress? progress + ) + { + var placed = new int[types.Count]; + var plates = new List(plan.Sheets.Count); + var committedParts = 0; + foreach (var sheet in plan.Sheets) + { + var placements = sheet.Parts.Select(p => + { + var type = types[p.Orientation.TypeIndex]; + return new NestJobPlacement(type.Part.Id, placed[type.Index]++, p.X, p.Y, p.Orientation.Rotation); + }); + plates.Add(new NestJobPlateResult(plates.Count, sheet.Stock, placements.ToList())); + committedParts += sheet.Parts.Count; + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, sheet.Stock.Id, plates.Count - 1, plates.Count, committedParts)); + } + + var fulfillment = types.Select(t => new PartFulfillment(t.Part.Id, t.Part.Quantity, placed[t.Index], t.Part.Quantity - placed[t.Index])); + var usage = job.Plates.Select(stock => + { + var count = plan.Sheets.Count(s => ReferenceEquals(s.Stock, stock)); + return new StockUsage(stock.Id, count, stock.Quantity - count); + }); + var status = plan.Unplaced == 0 ? NestJobStatus.Complete : NestJobStatus.Incomplete; + return new NestJobResult(status, plan.Reason, plates, fulfillment.ToList(), usage.ToList()); + } + + private sealed record Plan(IReadOnlyList Sheets, double Cost, int Unplaced, NestJobStopReason Reason) + { + public bool IsBetterThan(Plan other) + { + if (Unplaced != other.Unplaced) + return Unplaced < other.Unplaced; + var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost)); + if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale) + return Cost < other.Cost; + return Sheets.Count < other.Sheets.Count; + } + } +} + +/// Deterministic effort meter shared by all packers in one solve. +internal sealed class WorkCounter +{ + private long value; + public long Value => Interlocked.Read(ref value); + public void Add(long amount) => Interlocked.Add(ref value, amount); +} diff --git a/OpenNest.Engine.Opus55/PartCatalog.cs b/OpenNest.Engine.Opus55/PartCatalog.cs new file mode 100644 index 0000000..6402a17 --- /dev/null +++ b/OpenNest.Engine.Opus55/PartCatalog.cs @@ -0,0 +1,274 @@ +using Clipper2Lib; +using OpenNest.Converters; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Opus55; + +/// +/// One allowed pose of a part type: its rotation, its polygonized outline at that rotation +/// (reference point = snapshot origin), and the outline's conservative bounds. +/// +internal sealed class Orientation +{ + public required int TypeIndex { get; init; } + public required int Index { get; init; } + public required double Rotation { get; init; } + + /// CCW outline whose every point lies within of the true perimeter. + public required PathD Outline { get; init; } + + /// Chord deviation used for arcs; footprints are grown by it to stay conservative. + public required double Tolerance { get; init; } + + /// Outline bounds grown by the tolerance, so they contain the true perimeter. + public required double MinX { get; init; } + public required double MinY { get; init; } + public required double MaxX { get; init; } + public required double MaxY { get; init; } + + public double Width => MaxX - MinX; + public double Height => MaxY - MinY; +} + +internal sealed class PartType +{ + public required int Index { get; init; } + public required NestJobPart Part { get; init; } + public required double Area { get; init; } + public required IReadOnlyList Orientations { get; init; } +} + +/// +/// Converts job snapshots into the polygon world the packer works in. Parts whose geometry +/// cannot be read are kept with no orientations, so they surface as unplaced instead of +/// failing the whole job. +/// +internal static class PartCatalog +{ + /// Finest chord deviation of the working outline from true arcs, in job units. + public const double ChordTolerance = 0.002; + + /// Outline vertex count above which arcs are polygonized more coarsely (NFP cost is ~n*m). + private const int TargetVertices = 64; + + /// Hard cap on distinct orientations evaluated per part type. + private const int MaxOrientations = 8; + + private const double TwoPi = System.Math.PI * 2; + + public static IReadOnlyList Build(NestJob job) + { + // Fewer orientations per type for jobs with many distinct parts; every (type, rotation) + // pair costs a feasible-region update per placement. + var perType = System.Math.Clamp(48 / System.Math.Max(1, job.Parts.Count), 2, MaxOrientations); + var types = new List(job.Parts.Count); + for (var index = 0; index < job.Parts.Count; index++) + { + var part = job.Parts[index]; + Shape? perimeter; + try + { + perimeter = ReadPerimeter(part.Geometry); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException) + { + perimeter = null; + } + + if (perimeter == null) + { + types.Add(new PartType { Index = index, Part = part, Area = 0, Orientations = [] }); + continue; + } + + var angles = CandidateAngles(part.Rotation, perimeter, perType); + var tolerance = ChooseTolerance(perimeter); + var orientations = new List(); + var signatures = new List(); + foreach (var angle in angles) + { + var outline = Polygonize(perimeter, angle, tolerance); + if (outline.Count < 3) + continue; + // Point-symmetric parts (rectangles, discs...) look identical at several angles; + // evaluating duplicates only costs time. + var signature = Signature(outline); + if (signatures.Contains(signature)) + continue; + signatures.Add(signature); + orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance)); + } + + var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline)); + types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations }); + } + return types; + } + + private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) + { + var entities = ConvertProgram + .ToGeometry(DrawingJobMapper.ToProgram(geometry)) + .Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid)) + .ToList(); + if (entities.Count == 0) + return null; + var profile = new ShapeProfile(entities); + return profile.Perimeter is { } perimeter && perimeter.Area() > 1e-9 ? perimeter : null; + } + + /// + /// Coarsens arc polygonization (up to 0.1% of the part size) until the outline is small + /// enough for cheap Minkowski sums. Lines are always exact, so only arc-heavy parts pay. + /// + private static double ChooseTolerance(Shape perimeter) + { + var box = perimeter.BoundingBox; + var cap = System.Math.Max(ChordTolerance, 0.001 * System.Math.Max(box.Width, box.Length)); + var tolerance = ChordTolerance; + while (tolerance * 2 <= cap && perimeter.ToPolygonWithTolerance(tolerance).Vertices.Count > TargetVertices) + tolerance *= 2; + return tolerance; + } + + private static PathD Polygonize(Shape perimeter, double angle, double tolerance) + { + var shape = (Shape)perimeter.Clone(); + if (angle != 0) + shape.Rotate(angle); + var polygon = shape.ToPolygonWithTolerance(tolerance); + var path = new PathD(polygon.Vertices.Count); + foreach (var v in polygon.Vertices) + { + if (path.Count > 0 && System.Math.Abs(path[^1].x - v.X) < 1e-9 && System.Math.Abs(path[^1].y - v.Y) < 1e-9) + continue; + path.Add(new PointD(v.X, v.Y)); + } + if (path.Count > 1 && System.Math.Abs(path[0].x - path[^1].x) < 1e-9 && System.Math.Abs(path[0].y - path[^1].y) < 1e-9) + path.RemoveAt(path.Count - 1); + if (!Clipper.IsPositive(path)) + path.Reverse(); + return path; + } + + private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance) + { + var bounds = Clipper.GetBounds(outline); + return new Orientation + { + TypeIndex = typeIndex, + Index = index, + Rotation = angle, + Outline = outline, + Tolerance = tolerance, + MinX = bounds.left - tolerance, + MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y. + MaxX = bounds.right + tolerance, + MaxY = bounds.bottom + tolerance, + }; + } + + private static string Signature(PathD outline) + { + var bounds = Clipper.GetBounds(outline); + var points = outline + .Select(p => (System.Math.Round(p.x - bounds.left, 5), System.Math.Round(p.y - bounds.top, 5))) + .OrderBy(p => p.Item1) + .ThenBy(p => p.Item2) + .Select(p => $"{p.Item1:R},{p.Item2:R}"); + return string.Join(";", points); + } + + /// + /// Rotations to try, all satisfying the part's policy. Automatic parts get the four + /// right angles plus the two orientations that align their minimum-area bounding + /// rectangle with the sheet axes. + /// + internal static List CandidateAngles(RotationPolicy policy, Shape perimeter, int limit) + { + var raw = new List(); + switch (policy.Kind) + { + case RotationPolicyKind.Fixed: + raw.Add(policy.Start); + if (policy.Allow180Equivalent) + raw.Add(policy.Start + System.Math.PI); + break; + + case RotationPolicyKind.BoundedSweep: + { + var steps = (int)System.Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9); + var samples = System.Math.Min(steps + 1, policy.Allow180Equivalent ? System.Math.Max(1, limit / 2) : limit); + for (var i = 0; i < samples; i++) + { + var k = samples == 1 ? 0 : (int)System.Math.Round(i * (double)steps / (samples - 1)); + raw.Add(policy.Start + k * policy.Step); + if (policy.Allow180Equivalent) + raw.Add(policy.Start + k * policy.Step + System.Math.PI); + } + break; + } + + default: + { + var rightAngles = new[] { 0, System.Math.PI / 2, System.Math.PI, System.Math.PI * 1.5 }; + var aligned = AlignedAngle(perimeter); + raw.Add(0); + raw.Add(System.Math.PI / 2); + if (aligned is double a) + { + raw.Add(Normalize(a)); + raw.Add(Normalize(a + System.Math.PI / 2)); + } + raw.Add(System.Math.PI); + raw.Add(System.Math.PI * 1.5); + if (aligned is double b) + { + raw.Add(Normalize(b + System.Math.PI)); + raw.Add(Normalize(b + System.Math.PI * 1.5)); + } + break; + } + } + + var result = new List(); + foreach (var angle in raw) + { + if (!policy.Allows(angle)) + continue; + if (result.Any(existing => SameTurn(existing, angle))) + continue; + result.Add(angle); + if (result.Count >= limit) + break; + } + return result; + } + + private static double? AlignedAngle(Shape perimeter) + { + var polygon = perimeter.ToPolygonWithTolerance(ChordTolerance * 5); + if (polygon.Vertices.Count < 3) + return null; + var mbr = RotatingCalipers.MinimumBoundingRectangle(polygon.Vertices); + var angle = Normalize(-mbr.Angle) % (System.Math.PI / 2); + // Already axis-aligned (within ~0.05°): the right angles cover it. + if (angle < 1e-3 || System.Math.PI / 2 - angle < 1e-3) + return null; + return angle; + } + + private static double Normalize(double angle) + { + var value = angle % TwoPi; + return value < 0 ? value + TwoPi : value; + } + + private static bool SameTurn(double a, double b) + { + var delta = System.Math.Abs(Normalize(a - b)); + return delta < 1e-9 || TwoPi - delta < 1e-9; + } +} diff --git a/OpenNest.Engine.Opus55/README.md b/OpenNest.Engine.Opus55/README.md new file mode 100644 index 0000000..d648b37 --- /dev/null +++ b/OpenNest.Engine.Opus55/README.md @@ -0,0 +1,95 @@ +# OpenNest.Engine.Opus55 + +An independent whole-job `INestingEngine`: **frontier-advance NFP packing with look-ahead +stock selection**. It does not call, wrap, or select over any built-in engine +(`StockLadderNestingEngine`, `FixedStrategyNestingEngine` strategies, `PlateNesterFactory`, +`NestingEngineRegistry`), nor the removed `OpenNest.Engine/Nfp` bottom-left-fill/annealing code. +Every placement decision (which part, which rotation, where, on which sheet) comes from the logic below. + +## Algorithm + +**1. Geometry (`PartCatalog`, `NoFitCache`)** +- Each part's outer perimeter is polygonized with a known chord tolerance (0.002 by default, + coarsened for arc-heavy parts until the outline is ≤ ~64 vertices, capped at 0.1% of part size). +- Candidate rotations come from the part's `RotationPolicy`: for `Automatic`, the four right + angles plus the two orientations that axis-align the minimum-area bounding rectangle + (`RotatingCalipers`); for sweeps, up to 8 evenly spaced legal steps. Point-symmetric duplicates are dropped. +- Each orientation gets a **footprint**: outline inflated (miter joins, so it contains the exact + round offset) by `(spacing + 0.022) / 2 + chordTolerance`. Two parts respect the spacing + when their footprints don't overlap. The 0.022 covers validators that polygonize arcs + circumscribed at 0.01 per side, plus Clipper's 1e-4 grid. +- **No-fit polygons** between footprints come from Clipper2 Minkowski sums: an O(n+m) + edge merge for convex pairs, and for concave pairs the boundary sweep ∪ (A + p₀) ∪ (−B + a₀). + The last two terms cover "B inside A" and "B swallows A". NFPs are cached per orientation pair. + +**2. Sheet filling (`FrontierPacker`)** +- For every (part type, orientation) still in play, the packer keeps the exact **free region** of + legal reference points: the inner-fit rectangle minus the NFPs of everything placed. Each + placement subtracts one translated NFP from each region (in parallel, which stays deterministic). + Regions only shrink, and an empty region is retired for the rest of the sheet. +- At every step all remaining types × orientations compete (there is no fixed placement sequence): + 1. **Gap fill:** if any part fits without pushing the packing front forward, place the + *largest* such part at its lowest point. + 2. **Advance:** otherwise place the part with the least front advance per `area^β`, i.e. the + most material coverage for the sheet length it consumes. +- The front sweeps along X or Y, which leaves one full-width offcut strip for salvage credit. + +**3. Whole job (`Opus55NestingEngine`, `SheetEconomics`)** +- Sheet by sheet, every available stock size is trial-filled. The trial with the lowest + *estimated whole-job cost* (its net area, plus the remaining demand priced at the best + efficiency any trial achieved) is committed. This lets a sheet that finishes the job beat a + denser partial one. +- Net area = sheet area − `SalvageRate` × the largest qualifying full-width/full-length edge + offcut. This is the objective the benchmark scores. +- Six strategy variants (front axis X/Y × β ∈ {1, 0.5, 1.5}) each run whole-job, and the cheapest + plan wins (fewest unplaced, then cost, then sheets). A **tail re-plan** then re-decodes the + parts on the last 1–3 sheets with each stock forced first, and keeps any strictly cheaper result. +- **Deterministic:** no clock or randomness affects decisions. Effort is capped by a + count-based work budget (free-region subtractions), not wall time. + +## Layout + +| File | Role | +|---|---| +| `Opus55NestingEngine.cs` | `Solve()`: demand filtering, variants, stock look-ahead, tail re-plan, result assembly | +| `FrontierPacker.cs` | One-sheet fill: free regions and the gap-fill/advance choice rule | +| `NoFitCache.cs` | Spacing footprints and cached NFPs (Clipper2 Minkowski) | +| `PartCatalog.cs` | Snapshot → perimeter polygon per allowed orientation | +| `SheetEconomics.cs` | Net-area objective with salvage credit | +| `tests/` | xUnit suite. Layouts are judged by `OpenNest.Benchmark.NestValidator` | + +## Build / test + +```bash +dotnet build OpenNest.Engine.Opus55/OpenNest.Engine.Opus55.csproj -c Release +dotnet test OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj +``` + +This project is intentionally **outside** `OpenNest.sln`, the same pattern as the +`OpenNest.Engine.Aurora` plugin. It's discovered at runtime as a plugin. + +## Benchmark + +```bash +dotnet build OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release +mkdir -p OpenNest.Benchmark/bin/Release/net8.0/Engines +cp OpenNest.Engine.Opus55/bin/Release/net8.0/OpenNest.Engine.Opus55.dll OpenNest.Benchmark/bin/Release/net8.0/Engines/ +dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll +``` + +The engine reports as `Opus55NestingEngine`. + +## Known limitations + +- **No part-in-part:** holes are treated as solid, so small parts never nest inside cutouts. +- **Clearance padding:** gaps are ~0.022 (plus up to the chord tolerance) wider than the + required spacing, to stay valid under circumscribed-polygon validators. That's negligible in mm + and about 0.02" in inches. The constants are absolute and assume job units near inch/mm scale. +- **Rotation coverage:** `Automatic` parts try at most 8 orientations (fewer when a job has many + distinct parts: `48 / partCount`, minimum 2). Free-angle rotations aren't explored beyond the MBR alignment. +- **Greedy core:** there is no order/permutation search. The variants and tail re-plan are the only + search, and density on small mixed jobs trails what an interlocking-pair filler can reach. +- **`NestJobPart.Priority` is ignored**, and progress reports only `EvaluatingCandidate` + per trial and `PlateCommitted` at the end, with no finer-grained progress. +- Parts whose geometry has no readable closed perimeter, or that fit no offered stock at any + allowed rotation, are reported unplaced (`NoPlacementFound`) instead of failing the job. diff --git a/OpenNest.Engine.Opus55/SheetEconomics.cs b/OpenNest.Engine.Opus55/SheetEconomics.cs new file mode 100644 index 0000000..4ef3af9 --- /dev/null +++ b/OpenNest.Engine.Opus55/SheetEconomics.cs @@ -0,0 +1,41 @@ +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.Opus55; + +/// +/// The objective the engine optimizes: sheet area consumed, less the salvage credit for the +/// single largest full-width or full-length edge offcut the job's options allow. Packing toward +/// one edge (see ) is what makes that offcut large. +/// +internal static class SheetEconomics +{ + public static double SheetArea(NestPlateStock stock) => stock.Size.Width * stock.Size.Length; + + public static double NetArea(NestJobOptions options, SheetFill fill) + { + var area = SheetArea(fill.Stock); + var minimum = options.MinimumSalvageDimension; + if (options.SalvageRate <= 0 || minimum <= 0 || fill.Parts.Count == 0) + return area; + + var work = FrontierPacker.WorkArea(fill.Stock); + var gap = fill.Stock.PartSpacing; + var left = fill.Parts.Min(p => p.Left); + var right = fill.Parts.Max(p => p.Right); + var bottom = fill.Parts.Min(p => p.Bottom); + var top = fill.Parts.Max(p => p.Top); + var offcuts = new[] + { + // Box.Length is the X extent, Box.Width the Y extent. + (work.Length, bottom - work.Bottom - gap), + (work.Length, work.Top - top - gap), + (left - work.Left - gap, work.Width), + (work.Right - right - gap, work.Width), + }; + var salvage = 0.0; + foreach (var (a, b) in offcuts) + if (a >= minimum && b >= minimum) + salvage = System.Math.Max(salvage, a * b); + return area - options.SalvageRate * salvage; + } +} diff --git a/OpenNest.Engine.Opus55/tests/NoFitCacheTests.cs b/OpenNest.Engine.Opus55/tests/NoFitCacheTests.cs new file mode 100644 index 0000000..5f1c093 --- /dev/null +++ b/OpenNest.Engine.Opus55/tests/NoFitCacheTests.cs @@ -0,0 +1,68 @@ +using System.Linq; +using Clipper2Lib; +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Opus55.Tests; + +public class NoFitCacheTests +{ + [Theory] + [InlineData(0.0, 0.0)] // B's corner at A's corner: B covers A completely. + [InlineData(-5.0, -5.0)] // A deep inside B. + [InlineData(2.0, 0.5)] // Partial overlap. + public void ForbidsEveryOverlappingOffsetIncludingContainment(double dx, double dy) + { + var (small, big) = Orientations(); + var nfp = new NoFitCache(0.1).Get(small, big); + + Assert.True(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be forbidden"); + } + + [Theory] + [InlineData(4.0, 0.0)] // Beside A, clear by more than the clearance. + [InlineData(0.0, -21.0)] // Below A. + [InlineData(-21.0, 0.0)] // Left of A. + public void AllowsClearOffsets(double dx, double dy) + { + var (small, big) = Orientations(); + var nfp = new NoFitCache(0.1).Get(small, big); + + Assert.False(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be free"); + } + + /// A = 3x3 L (concave), B = 20x20 square; both at rotation 0 with origin at the lower-left. + private static (Orientation Small, Orientation Big) Orientations() + { + var job = new NestJob( + new[] + { + new NestJobPart("small", Snapshot((0, 0), (3, 0), (3, 1), (1, 1), (1, 3), (0, 3)), 1, 0, RotationPolicy.Fixed(0)), + new NestJobPart("big", Snapshot((0, 0), (20, 0), (20, 20), (0, 20)), 1, 0, RotationPolicy.Fixed(0)), + }, + new[] { new NestPlateStock("s", new Size(100, 100)) } + ); + var types = PartCatalog.Build(job); + return (types[0].Orientations.Single(), types[1].Orientations.Single()); + } + + private static bool Forbidden(Nfp nfp, PointD point) + { + var winding = 0; + foreach (var path in nfp.Region) + if (Clipper.PointInPolygon(point, path) == PointInPolygonResult.IsInside) + winding += Clipper.IsPositive(path) ? 1 : -1; + return winding != 0; + } + + private static PartGeometrySnapshot Snapshot(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 PartGeometrySnapshot.FromProgram(program); + } +} diff --git a/OpenNest.Engine.Sonnet5/tests/OpenNest.Engine.Sonnet5.Tests.csproj b/OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj similarity index 73% rename from OpenNest.Engine.Sonnet5/tests/OpenNest.Engine.Sonnet5.Tests.csproj rename to OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj index ee0d1ae..c89db0e 100644 --- a/OpenNest.Engine.Sonnet5/tests/OpenNest.Engine.Sonnet5.Tests.csproj +++ b/OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj @@ -13,7 +13,9 @@ - + + + diff --git a/OpenNest.Engine.Opus55/tests/Opus55NestingEngineTests.cs b/OpenNest.Engine.Opus55/tests/Opus55NestingEngineTests.cs new file mode 100644 index 0000000..4beb32b --- /dev/null +++ b/OpenNest.Engine.Opus55/tests/Opus55NestingEngineTests.cs @@ -0,0 +1,283 @@ +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.Opus55.Tests; + +public class Opus55NestingEngineTests +{ + [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 Opus55NestingEngine().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), + Part("slot", Obround(10, 3), 6), + }, + new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) } + ); + + var result = new Opus55NestingEngine().Solve(job); + + AssertValid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void ZeroSpacingStillKeepsPartsApartForValidation() + { + var job = Job(new[] { Part("disc", Disc(2), 30), Part("rect", Rectangle(7, 3), 20) }, new[] { Stock("sheet", 30, 40) }); + + var result = new Opus55NestingEngine().Solve(job); + + AssertValid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void LargeAndSmallConcavePartsShareASheet() + { + // End-to-end companion to NoFitCacheTests' containment cases (the precise regression guard). + var job = Job( + new[] { Part("small", LShape(3, 3, 1), 6), Part("big", Rectangle(20, 20), 2) }, + new[] { Stock("sheet", 25, 45, spacing: 0.25) } + ); + + var result = new Opus55NestingEngine().Solve(job); + + AssertValid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void PicksTheCheaperSheetWhenItHoldsEverything() + { + var job = Job( + new[] { Part("square", Rectangle(10, 10), 4) }, + new[] { Stock("big", 60, 120, spacing: 0.25), Stock("small", 25, 25, spacing: 0.25) } + ); + + var result = new Opus55NestingEngine().Solve(job); + + AssertValid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal("small", Assert.Single(result.Plates).StockId); + } + + [Fact] + public void SpillsOntoAdditionalSheets() + { + var job = Job(new[] { Part("rect", Rectangle(20, 10), 25) }, new[] { Stock("sheet", 30, 50, spacing: 0.5) }); + + var result = new Opus55NestingEngine().Solve(job); + + AssertValid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.True(result.Plates.Count > 1); + Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count)); + var indices = result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex).OrderBy(i => i); + Assert.Equal(Enumerable.Range(0, 25), indices); + } + + [Fact] + public void RespectsFixedAndBoundedRotationPolicies() + { + var fixedPolicy = RotationPolicy.Fixed(0); + var sweep = RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4); + var job = Job( + new[] + { + Part("fixed", LShape(10, 6, 3), 8, fixedPolicy), + Part("swept", Triangle(8, 5), 8, sweep), + }, + new[] { Stock("sheet", 40, 60, spacing: 0.25) } + ); + + var result = new Opus55NestingEngine().Solve(job); + + AssertValid(job, result); + foreach (var placement in result.Plates.SelectMany(p => p.Placements)) + { + var policy = placement.PartId == "fixed" ? fixedPolicy : sweep; + Assert.True(policy.Allows(placement.Rotation), $"{placement.PartId} at {placement.Rotation}"); + } + } + + [Fact] + public void OversizedPartIsReportedUnplacedWithoutBlockingOthers() + { + var job = Job( + new[] { Part("huge", Rectangle(100, 100), 1), Part("small", Rectangle(5, 5), 3) }, + new[] { Stock("sheet", 20, 20) } + ); + + var result = new Opus55NestingEngine().Solve(job); + + AssertValid(job, result); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason); + Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced); + Assert.Equal(3, result.Fulfillment.Single(f => f.PartId == "small").Placed); + } + + [Fact] + public void StopsWhenFiniteStockRunsOut() + { + var job = Job(new[] { Part("rect", Rectangle(9, 9), 20) }, new[] { Stock("sheet", 20, 20, quantity: 2) }); + + var result = new Opus55NestingEngine().Solve(job); + + AssertValid(job, result); + Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason); + Assert.Equal(2, result.Plates.Count); + var usage = Assert.Single(result.StockUsage); + Assert.Equal(2, usage.Used); + Assert.Equal(0, usage.Remaining); + } + + [Fact] + public void HonorsMaxPlates() + { + var job = Job( + new[] { Part("rect", Rectangle(9, 9), 20) }, + new[] { Stock("sheet", 20, 20) }, + new NestJobOptions(maxPlates: 1) + ); + + var result = new Opus55NestingEngine().Solve(job); + + AssertValid(job, result); + Assert.Single(result.Plates); + Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason); + } + + [Fact] + public void IsDeterministic() + { + NestJob Build() => + Job( + new[] { Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12) }, + new[] { Stock("a", 30, 45, spacing: 0.3), Stock("b", 40, 40, spacing: 0.3) } + ); + + var first = new Opus55NestingEngine().Solve(Build()); + var second = new Opus55NestingEngine().Solve(Build()); + + Assert.Equal(Describe(first), Describe(second)); + } + + [Fact] + public void HasPublicParameterlessConstructorForPluginDiscovery() + { + var engine = Activator.CreateInstance(typeof(Opus55NestingEngine)); + Assert.IsAssignableFrom(engine); + } + + // ---- helpers ------------------------------------------------------------------------- + + private static string Describe(NestJobResult result) => + string.Join( + "|", + result.Plates.Select(p => + p.StockId + ":" + string.Join(",", p.Placements.Select(x => $"{x.PartId}#{x.InstanceIndex}@{x.X:R},{x.Y:R},{x.Rotation:R}")) + ) + ); + + 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; + } + + /// Stadium: two semicircular ends joined by straight sides, offset from the origin. + private static Program Obround(double length, double width) + { + var r = width / 2; + var program = new Program(); + program.Codes.Add(new RapidMove(1 + r, 1)); + program.Codes.Add(new LinearMove(1 + length - r, 1)); + program.Codes.Add(new ArcMove(1 + length - r, 1 + width, 1 + length - r, 1 + r, RotationType.CCW)); + program.Codes.Add(new LinearMove(1 + r, 1 + width)); + program.Codes.Add(new ArcMove(1 + r, 1, 1 + r, 1 + r, RotationType.CCW)); + return program; + } +} diff --git a/OpenNest.Engine.Sonnet5/README.md b/OpenNest.Engine.Sonnet5/README.md deleted file mode 100644 index ba2ffbb..0000000 --- a/OpenNest.Engine.Sonnet5/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# OpenNest.Engine.Sonnet5 - -An independent `INestingEngine` implementation — **not** a wrapper, ensemble, or -selector over OpenNest's built-in engines (`StockLadderNestingEngine`, -`FixedStrategyNestingEngine` "Default"/"Strip"/"Vertical Remnant"/"Horizontal Remnant`, -or anything reachable through `PlateNesterFactory`/`NestingEngineRegistry`). -`Solve()` must never call, instantiate, or otherwise delegate a placement decision -to one of those. - -## Allowed building blocks - -Low-level geometry/data-structure primitives are fair game — they are not nesting -strategies: - -- `OpenNest.Core` geometry: `Polygon`, `Shape`, `BoundingBox`, `Vector`, `Box`, - `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, `Collision` (overlap/spacing - checks), `NoFitPolygon`, `ShapeProfile`, `SpatialQuery`. -- `OpenNest.Engine` support types if useful: `PartBoundary`, `RotationAnalysis`, - `AngleCandidateBuilder` — the *decision logic* using them must be your own (don't just - call `BestFitFinder`/`PairEvaluator`/`RotationSlideStrategy`, which are the existing - best-fit engine's internals). - -## What to fill in - -`Sonnet5NestingEngine.cs` — implement `Solve()`. Pick and document an actual -placement strategy (NFP-based sliding placement, skyline/shelf packer, -simulated-annealing/genetic layout search, guillotine-cut packer, -physics/gravity-settling, etc). It's fine to be simpler or worse than the built-in -engines to start; it must not be the same algorithm re-derived through indirection. - -## Build - -```bash -dotnet build OpenNest.Engine.Sonnet5/OpenNest.Engine.Sonnet5.csproj -``` - -This project is intentionally **outside** `OpenNest.sln` (same pattern as the -`OpenNest.Engine.Aurora` plugin) — it's discovered at runtime as a plugin, not built -as part of the main solution. - -## Try it out with the benchmark - -`OpenNest.Benchmark` auto-loads plugin engines from an `Engines/` folder next to its -own build output: - -```bash -dotnet build OpenNest.Engine.Sonnet5/OpenNest.Engine.Sonnet5.csproj -c Release -dotnet build OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release - -mkdir -p OpenNest.Benchmark/bin/Release/net8.0/Engines -cp OpenNest.Engine.Sonnet5/bin/Release/net8.0/OpenNest.Engine.Sonnet5.dll OpenNest.Benchmark/bin/Release/net8.0/Engines/ - -dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll -``` - -Your engine will show up in the report under its CLR type name (`Sonnet5NestingEngine`), -competing on equal footing against the built-in engines. diff --git a/OpenNest.Engine.Sonnet5/Sonnet5NestingEngine.cs b/OpenNest.Engine.Sonnet5/Sonnet5NestingEngine.cs deleted file mode 100644 index c0958be..0000000 --- a/OpenNest.Engine.Sonnet5/Sonnet5NestingEngine.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Threading; -using OpenNest.Engine.Jobs; - -namespace OpenNest.Engine.Sonnet5; - -/// -/// TODO: name and describe the actual placement strategy here (e.g. "skyline packer with -/// greedy shelf assignment", "NFP-based sliding placement with simulated-annealing order -/// search", etc). This must be an independently designed algorithm — see README.md. -/// -public sealed class Sonnet5NestingEngine : INestingEngine -{ - public NestJobResult Solve( - NestJob job, - IProgress? progress = null, - CancellationToken token = default - ) - { - ArgumentNullException.ThrowIfNull(job); - - // TODO: implement independent placement logic here. - // - // Do NOT call NestingEngineRegistry.Create(...), PlateNesterFactory, or any - // FixedStrategyNestingEngine / StockLadderNestingEngine instance from inside this - // method. Decide placements yourself using OpenNest.Core / OpenNest.Geometry - // primitives (Polygon, NoFitPolygon, Collision, ConvexHull, RotatingCalipers, etc). - // - // job.Parts -> requested parts (PartGeometrySnapshot geometry, quantity, priority, rotation policy) - // job.Plates -> candidate stock sheets (size, spacing, quadrant, quantity) - // job.Options -> job-wide options - // - // Return a NestJobResult built from NestJobPlateResult (one per used sheet, holding - // ordered NestJobPlacement values), PartFulfillment (requested vs placed per part id), - // and StockUsage (sheets used per stock id). - - throw new NotImplementedException( - "Sonnet5 nesting engine placement logic not yet implemented." - ); - } -} diff --git a/OpenNest.Engine.Sonnet5/tests/Sonnet5NestingEngineTests.cs b/OpenNest.Engine.Sonnet5/tests/Sonnet5NestingEngineTests.cs deleted file mode 100644 index 158af80..0000000 --- a/OpenNest.Engine.Sonnet5/tests/Sonnet5NestingEngineTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Generic; -using OpenNest.Engine.Jobs; -using OpenNest.Geometry; - -namespace OpenNest.Engine.Sonnet5.Tests; - -public class Sonnet5NestingEngineTests -{ - [Fact] - public void SolveReturnsAResultForASingleSimplePart() - { - // TODO: replace with a real fixture once Solve() is implemented — this only - // proves the plumbing (project reference, constructor, interface) is wired up. - var engine = new Sonnet5NestingEngine(); - - Assert.NotNull(engine); - Assert.IsAssignableFrom(engine); - } -}