From 13478f51023787197b2c03633bd5acc1c93ec5b7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 29 Sep 2026 20:49:00 -0400 Subject: [PATCH] feat(rectangles): add rectangle-lane maximal-rectangles engine Nests every part as the axis-aligned box of its material at its minimum-area rotation and packs boxes with a maximal-rectangles free list (6 placement rules x global/ordered pick). Sheet choice uses the salvage-credited look-ahead cost. Curved extremes read the way the layout check sees them (circumscribed arcs on the Clipper grid), so discs, rings and obrounds stay valid at box contact; an 80-job sweep failed 16-50 jobs before that rule. Local 2026 production lanes (--min-salvage-dimension 12, --parallel 3): - 91 all-rectangular jobs: 91/91 valid, cost 333081 vs best general 332153 (Gpt6Astra, 89/91 valid), 5.9 s vs 13.7 s (Opus55) and 169.5 s (Gpt6Astra) - 77 box-filling (>= 90%) jobs: 77/77 valid, lowest total cost 641637 --- OpenNest.Engine.Rectangles/BoxCatalog.cs | 136 +++++++++++++++ OpenNest.Engine.Rectangles/MaxRectsSheet.cs | 165 ++++++++++++++++++ .../OpenNest.Engine.Rectangles.csproj | 6 + OpenNest.Engine.Rectangles/README.md | 74 ++++++++ .../RectanglesNestingEngine.cs | 149 ++++++++++++++++ OpenNest.Engine.Rectangles/SheetPacker.cs | 165 ++++++++++++++++++ .../tests/CurvedExtremeSweepTests.cs | 31 ++++ .../OpenNest.Engine.Rectangles.Tests.csproj | 16 ++ .../tests/RectanglesNestingEngineTests.cs | 157 +++++++++++++++++ README.md | 1 + 10 files changed, 900 insertions(+) create mode 100644 OpenNest.Engine.Rectangles/BoxCatalog.cs create mode 100644 OpenNest.Engine.Rectangles/MaxRectsSheet.cs create mode 100644 OpenNest.Engine.Rectangles/OpenNest.Engine.Rectangles.csproj create mode 100644 OpenNest.Engine.Rectangles/README.md create mode 100644 OpenNest.Engine.Rectangles/RectanglesNestingEngine.cs create mode 100644 OpenNest.Engine.Rectangles/SheetPacker.cs create mode 100644 OpenNest.Engine.Rectangles/tests/CurvedExtremeSweepTests.cs create mode 100644 OpenNest.Engine.Rectangles/tests/OpenNest.Engine.Rectangles.Tests.csproj create mode 100644 OpenNest.Engine.Rectangles/tests/RectanglesNestingEngineTests.cs diff --git a/OpenNest.Engine.Rectangles/BoxCatalog.cs b/OpenNest.Engine.Rectangles/BoxCatalog.cs new file mode 100644 index 0000000..1d7b9de --- /dev/null +++ b/OpenNest.Engine.Rectangles/BoxCatalog.cs @@ -0,0 +1,136 @@ +using OpenNest.Converters; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Rectangles; + +/// One allowed rotation of a part, reduced to its analytic material bounding box. +/// Rotation in radians about the snapshot origin. +/// Material X extent after rotation. +/// Material Y extent after rotation. +/// Rotated material bounds' left edge relative to the snapshot origin. +/// Rotated material bounds' bottom edge relative to the snapshot origin. +internal sealed record BoxOrientation(double Angle, double Width, double Height, double OffsetX, double OffsetY); + +/// A requested part type: every instance shares the same orientations. +internal sealed record BoxType( + int Index, + NestJobPart Part, + IReadOnlyList Orientations, + double MaterialArea) +{ + public string Id => Part.Id; + public int Priority => Part.Priority; + + /// Smallest bounding-box area over the allowed orientations. + public double BoxArea => Orientations.Count == 0 ? 0 : Orientations.Min(o => o.Width * o.Height); + + /// Shortest side over all orientations; free space narrower than this is useless. + public double MinSide => Orientations.Count == 0 ? double.MaxValue + : Orientations.Min(o => System.Math.Min(o.Width, o.Height)); +} + +/// +/// Reduces every requested part to the axis-aligned boxes of its useful rotations. Only material +/// contours count (rapids and scribe/etch marks are excluded), exactly as the layout check's +/// bounds test does. Orientations are the host rotation candidates whose box area is within a +/// hair of the minimum (the minimum-area bounding rectangle plus its right-angle turn), with +/// duplicate box shapes removed. Unreadable geometry yields a type with no orientations. +/// +internal static class BoxCatalog +{ + private const double AreaTieRelative = 1e-6; + private const double DimensionTie = 1e-7; + + public static IReadOnlyList Build(NestJob job) + { + var types = new List(job.Parts.Count); + for (var i = 0; i < job.Parts.Count; i++) + types.Add(Read(i, job.Parts[i])); + return types; + } + + private static BoxType Read(int index, NestJobPart part) + { + var geometry = JobPartGeometry.TryRead(part.Geometry); + if (geometry == null) + return new BoxType(index, part, Array.Empty(), 0); + + var candidates = new List(); + foreach (var angle in RotationCandidates.ForShape(part.Rotation, geometry.Perimeter)) + { + var bounds = RotatedMaterialBounds(part.Geometry, angle); + if (bounds is not { } b || !(b.Width > 0) || !(b.Height > 0)) + continue; + candidates.Add(new BoxOrientation(angle, b.Width, b.Height, b.Left, b.Bottom)); + } + + if (candidates.Count == 0) + return new BoxType(index, part, Array.Empty(), geometry.MaterialArea); + + var minArea = candidates.Min(c => c.Width * c.Height); + var kept = new List(); + foreach (var c in candidates) + { + if (c.Width * c.Height > minArea * (1 + AreaTieRelative)) + continue; + if (kept.Any(k => System.Math.Abs(k.Width - c.Width) <= DimensionTie + && System.Math.Abs(k.Height - c.Height) <= DimensionTie)) + continue; + kept.Add(c); + } + return new BoxType(index, part, kept, geometry.MaterialArea); + } + + /// + /// Material bounds after rotation, as the layout check will see them. The check flattens + /// perimeter arcs circumscribed and snaps to a 1e-4 Clipper grid, so a curved extreme reads + /// slightly outside the true arc. Each side takes the larger of the analytic bound and the + /// check's own outline (ClipperBridge.OffsetForValidation at zero inflation, flattened in the + /// same local frame), and any side where the outline sticks out gets one more grid unit. + /// Straight edges are unchanged, so rectangles still pack at exactly the part spacing. + /// + private static (double Left, double Bottom, double Width, double Height)? RotatedMaterialBounds( + PartGeometrySnapshot snapshot, double angle) + { + var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(snapshot)) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + if (entities.Count == 0) + return null; + foreach (var entity in entities) + entity.Rotate(angle); + var left = entities.Min(e => e.Left); + var bottom = entities.Min(e => e.Bottom); + var right = entities.Max(e => e.Right); + var top = entities.Max(e => e.Top); + if (!double.IsFinite(left) || !double.IsFinite(bottom) || !double.IsFinite(right) || !double.IsFinite(top)) + return null; + + var profile = new ShapeProfile(entities); + var outline = profile.Perimeter == null ? null + : ClipperBridge.OffsetForValidation(profile, 0, NestTolerances.ValidationOutline).LargestOuter(); + if (outline != null && outline.Vertices.Count >= 3) + { + left = Widen(left, outline.Vertices.Min(v => v.X), -1); + bottom = Widen(bottom, outline.Vertices.Min(v => v.Y), -1); + right = Widen(right, outline.Vertices.Max(v => v.X), +1); + top = Widen(top, outline.Vertices.Max(v => v.Y), +1); + } + return (left, bottom, right - left, top - bottom); + } + + /// + /// Pushes a side out to the check's outline plus one grid unit when the outline sticks out by + /// more than a quarter of the spacing slack. Smaller differences are grid rounding (at most half + /// a unit per vertex) or negligible bulge: two facing sides then lose under 0.00035 in total, + /// inside NestTolerances.SpacingSlack, and ignoring them keeps rotated rectangles exact. + /// + private static double Widen(double analytic, double outline, int direction) + { + var grid = System.Math.Pow(10, -NestTolerances.ClipperPrecision); + var beyond = (outline - analytic) * direction; + return beyond > NestTolerances.SpacingSlack / 4 ? outline + direction * grid : analytic; + } +} diff --git a/OpenNest.Engine.Rectangles/MaxRectsSheet.cs b/OpenNest.Engine.Rectangles/MaxRectsSheet.cs new file mode 100644 index 0000000..9da3187 --- /dev/null +++ b/OpenNest.Engine.Rectangles/MaxRectsSheet.cs @@ -0,0 +1,165 @@ +namespace OpenNest.Engine.Rectangles; + +/// Axis-aligned rectangle in sheet-local packing coordinates. +internal readonly record struct Rect(double X, double Y, double W, double H) +{ + public double Right => X + W; + public double Top => Y + H; + + public bool Contains(Rect other) => + other.X >= X - MaxRectsSheet.Eps && other.Y >= Y - MaxRectsSheet.Eps + && other.Right <= Right + MaxRectsSheet.Eps && other.Top <= Top + MaxRectsSheet.Eps; + + public bool Overlaps(Rect other) => + other.X < Right - MaxRectsSheet.Eps && other.Right > X + MaxRectsSheet.Eps + && other.Y < Top - MaxRectsSheet.Eps && other.Top > Y + MaxRectsSheet.Eps; +} + +/// How a free position is scored; lower (Primary, Secondary) wins. +internal enum FitRule +{ + /// Smallest leftover on the tighter side of the free rectangle. + BestShortSide, + /// Smallest leftover on the looser side of the free rectangle. + BestLongSide, + /// Smallest free rectangle that holds the item. + BestArea, + /// Lowest top edge, then leftmost: packs rows upward and keeps a clean top offcut. + BottomLeft, + /// Leftmost right edge, then lowest: packs columns rightward and keeps a clean right offcut. + LeftBottom, + /// Most perimeter touching the sheet edge or already placed items. + ContactPoint, +} + +/// +/// Maximal-rectangles free-space tracker for one sheet (Jylänki, "A Thousand Ways to Pack the +/// Bin", 2010). Keeps every maximal empty rectangle, so any position a box can legally occupy +/// is the bottom-left corner of some free rectangle. Items and the bin are inflated by the part +/// spacing on their right/top sides by the caller, so touching inflated boxes are exactly one +/// spacing apart and the last box may touch the sheet's work-area edge. +/// +internal sealed class MaxRectsSheet +{ + public const double Eps = 1e-9; + + private readonly List free = new(); + private readonly List used = new(); + + public MaxRectsSheet(double width, double height) + { + Width = width; + Height = height; + free.Add(new Rect(0, 0, width, height)); + } + + public double Width { get; } + public double Height { get; } + public IReadOnlyList Used => used; + + /// Best position for a w-by-h item under the rule, or null when nothing holds it. + public (Rect Place, double Primary, double Secondary)? FindBest(double w, double h, FitRule rule) + { + (Rect Place, double Primary, double Secondary)? best = null; + foreach (var f in free) + { + if (w > f.W + Eps || h > f.H + Eps) + continue; + var place = new Rect(f.X, f.Y, w, h); + var (p, s) = Score(f, place, rule); + if (best is not { } b || p < b.Primary - Eps + || (p <= b.Primary + Eps && s < b.Secondary - Eps)) + best = (place, p, s); + } + return best; + } + + /// Commits an item and splits every free rectangle it intersects. + public void Place(Rect item) + { + var next = new List(free.Count + 8); + foreach (var f in free) + { + if (!f.Overlaps(item)) + { + next.Add(f); + continue; + } + if (item.X > f.X + Eps) + next.Add(new Rect(f.X, f.Y, item.X - f.X, f.H)); + if (item.Right < f.Right - Eps) + next.Add(new Rect(item.Right, f.Y, f.Right - item.Right, f.H)); + if (item.Y > f.Y + Eps) + next.Add(new Rect(f.X, f.Y, f.W, item.Y - f.Y)); + if (item.Top < f.Top - Eps) + next.Add(new Rect(f.X, item.Top, f.W, f.Top - item.Top)); + } + free.Clear(); + free.AddRange(Prune(next)); + used.Add(item); + } + + private static List Prune(List rects) + { + // Drop rectangles contained in another; of two equal ones keep the first (deterministic). + var keep = new bool[rects.Count]; + for (var i = 0; i < rects.Count; i++) + keep[i] = rects[i].W > Eps && rects[i].H > Eps; + for (var i = 0; i < rects.Count; i++) + { + if (!keep[i]) + continue; + for (var j = 0; j < rects.Count; j++) + { + if (i == j || !keep[j]) + continue; + if (rects[j].Contains(rects[i]) && (!rects[i].Contains(rects[j]) || j < i)) + { + keep[i] = false; + break; + } + } + } + var result = new List(rects.Count); + for (var i = 0; i < rects.Count; i++) + if (keep[i]) + result.Add(rects[i]); + return result; + } + + private (double Primary, double Secondary) Score(Rect f, Rect place, FitRule rule) + { + var dx = f.W - place.W; + var dy = f.H - place.H; + return rule switch + { + FitRule.BestShortSide => (System.Math.Min(dx, dy), System.Math.Max(dx, dy)), + FitRule.BestLongSide => (System.Math.Max(dx, dy), System.Math.Min(dx, dy)), + FitRule.BestArea => (f.W * f.H - place.W * place.H, System.Math.Min(dx, dy)), + FitRule.BottomLeft => (place.Top, place.X), + FitRule.LeftBottom => (place.Right, place.Y), + FitRule.ContactPoint => (-Contact(place), place.Top + place.Right), + _ => throw new ArgumentOutOfRangeException(nameof(rule)), + }; + } + + private double Contact(Rect r) + { + var total = 0.0; + if (r.X <= Eps) total += r.H; + if (r.Right >= Width - Eps) total += r.H; + if (r.Y <= Eps) total += r.W; + if (r.Top >= Height - Eps) total += r.W; + foreach (var u in used) + { + if (System.Math.Abs(u.X - r.Right) <= Eps || System.Math.Abs(u.Right - r.X) <= Eps) + total += Overlap(u.Y, u.Top, r.Y, r.Top); + if (System.Math.Abs(u.Y - r.Top) <= Eps || System.Math.Abs(u.Top - r.Y) <= Eps) + total += Overlap(u.X, u.Right, r.X, r.Right); + } + return total; + } + + private static double Overlap(double a0, double a1, double b0, double b1) => + System.Math.Max(0, System.Math.Min(a1, b1) - System.Math.Max(a0, b0)); +} diff --git a/OpenNest.Engine.Rectangles/OpenNest.Engine.Rectangles.csproj b/OpenNest.Engine.Rectangles/OpenNest.Engine.Rectangles.csproj new file mode 100644 index 0000000..24b0a43 --- /dev/null +++ b/OpenNest.Engine.Rectangles/OpenNest.Engine.Rectangles.csproj @@ -0,0 +1,6 @@ + + + + + + diff --git a/OpenNest.Engine.Rectangles/README.md b/OpenNest.Engine.Rectangles/README.md new file mode 100644 index 0000000..8e52768 --- /dev/null +++ b/OpenNest.Engine.Rectangles/README.md @@ -0,0 +1,74 @@ +# OpenNest.Engine.Rectangles + +A whole-job `INestingEngine` for the **rectangle lane**: jobs whose parts are plain or +near-rectangular plates. Every part is nested as the axis-aligned box of its material at its +minimum-area rotation(s), packed with a **maximal-rectangles** free list +(Jylänki, *A Thousand Ways to Pack the Bin*, 2010). + +For these parts the box wastes almost nothing, and exact box packing is both denser and far +faster than contour-sliding engines. Irregular parts are still placed validly, but only as their +bounding boxes: nothing is nested into another part's notch or hole. Use a general engine +(Opus55, Gpt6Astra) for irregular work. + +## Algorithm + +**1. Boxes (`BoxCatalog`)** +- Rotations come from the host's `RotationCandidates.ForShape` (the policy angles plus, for + `Automatic`, the minimum-bounding-rectangle alignment). Only the rotations whose box area is + within 1e-6 of the minimum are kept, with duplicate box shapes removed. A 10 x 4 plate drawn at + 30° is squared up; a fixed-rotation part keeps its one angle. +- Bounds use material contours only (rapids and scribe/etch excluded), like the layout check. +- **Curved extremes:** the layout check circumscribes arcs and snaps to a 1e-4 grid, so a disc + or obround reads slightly larger than its true arc. Each box side takes the check's own + outline (`ClipperBridge.OffsetForValidation`) plus one grid unit wherever that outline sticks + out by more than a quarter of `NestTolerances.SpacingSlack`. Straight-edged parts are unchanged + and still pack at exactly the part spacing. + +**2. One sheet (`MaxRectsSheet`, `SheetPacker`)** +- Boxes and the work area are grown by the part spacing on their right/top sides, so touching + grown boxes are exactly one spacing apart and the last box can touch the work-area edge. +- The free list keeps every maximal empty rectangle; placing a box splits each free rectangle it + hits and prunes the contained ones. +- Six placement rules: best short side, best long side, best area, bottom-left, left-bottom and + contact point. Two pick modes: *global* (each step places whichever remaining box scores best + anywhere) and *ordered* (priority, then largest box first, each type filled until it stops + fitting). Lower priority numbers are always served first. + +**3. Whole job (`RectanglesNestingEngine`)** +- Sheet by sheet, every available stock is packed under all 12 rule/mode pairs. The candidate with + the lowest estimated whole-job cost wins: its salvage-credited `NestJobCost.NetSheetArea`, plus + the remaining demand priced at the best net-area-per-part-area ratio any candidate reached. A + candidate that leaves more of the most urgent priority tier unplaced never wins. +- Deterministic: no clocks or randomness; the only stop besides completion is the host token. + +## Results + +`OpenNest.Benchmark`, `--min-salvage-dimension 12`, each file's own sheet sizes, `--parallel 3` +(times are under that load). Synthetic results only here; the real-part corpus stays local. + +Local corpus of converted 2026 production nests (file names and parts stay out of this repo): + +| Lane | Jobs | Engine | Valid | Total cost | Time | +|---|---|---|---|---|---| +| Every part plain or near-rectangular | 91 | **Rectangles** | 91 | 333,081 | 5.9 s | +| | | Gpt6Astra | 89 | 332,153 | 169.5 s | +| | | Opus55 | 91 | 332,925 | 13.7 s | +| | | StockLadder | 89 | 337,641 | 36.0 s | +| Every part fills ≥ 90% of its box | 77 | **Rectangles** | 77 | 641,637 | 19.1 s | +| | | Gpt6Astra | 76 | 651,514 | 195.6 s | +| | | StockLadder | 76 | 671,590 | 93.4 s | +| | | Opus55 | 76 | 693,972 | 31.8 s | + +Rectangles never leaves a part unplaced that another engine placed. Its losses are a handful of +multi-sheet jobs where the sheet-by-sheet greedy choice commits to a worse sheet mix (see Next). + +`tests/` (27 tests): contract, starter and engine tests, including an 80-job sweep of discs, +rings, obrounds and triangles at four spacings that failed 16–50 jobs before the curved-extreme +rule. + +## Next + +- Multi-sheet jobs: the sheet-by-sheet greedy choice loses to whole-plan search on a few jobs. + Re-plan the last sheets and try whole-job variants, as Opus55 does. +- Guillotine-only mode for shears and for cut-off-friendly layouts. +- Exhaustive search for small jobs (one or two part types). diff --git a/OpenNest.Engine.Rectangles/RectanglesNestingEngine.cs b/OpenNest.Engine.Rectangles/RectanglesNestingEngine.cs new file mode 100644 index 0000000..8d0bdd0 --- /dev/null +++ b/OpenNest.Engine.Rectangles/RectanglesNestingEngine.cs @@ -0,0 +1,149 @@ +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.Rectangles; + +/// +/// Rectangle-lane nesting engine: every part is nested as the axis-aligned box of its material at +/// its minimum-area rotations, packed with a maximal-rectangles free list (Jylänki 2010). +/// +/// Built for jobs of plain and near-rectangular parts, where a part's box wastes almost nothing +/// and exact box packing beats contour-sliding engines on both speed and density. Irregular parts +/// are still placed validly, only as their bounding boxes; they are not nested into each other. +/// +/// Sheet by sheet, each available stock is packed under several free-space scoring rules and +/// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with +/// the lowest estimated whole-job cost wins: its salvage-credited net area (NestJobCost) plus the +/// remaining demand priced at the best net-area-per-part-area ratio seen among the candidates. +/// Deterministic: no clocks or randomness; the only stop besides completion is the host token. +/// +public sealed class RectanglesNestingEngine : INestingEngine +{ + private static readonly FitRule[] Rules = + { + FitRule.BestShortSide, FitRule.BestLongSide, FitRule.BestArea, + FitRule.BottomLeft, FitRule.LeftBottom, FitRule.ContactPoint, + }; + + private static readonly PickMode[] Modes = { PickMode.Global, PickMode.Ordered }; + + public NestJobResult Solve( + NestJob job, + IProgress? progress = null, + CancellationToken token = default + ) + { + ArgumentNullException.ThrowIfNull(job); + token.ThrowIfCancellationRequested(); + + var types = BoxCatalog.Build(job); + var remaining = types.Select(t => t.Part.Quantity).ToArray(); + // Parts with unreadable geometry or no box that fits any offered sheet can never be placed. + foreach (var t in types) + if (t.Orientations.Count == 0 || !job.Plates.Any(stock => t.Orientations.Any(o => FitsStock(stock, o)))) + remaining[t.Index] = 0; + + var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal); + var result = new NestJobResultBuilder(job, progress); + NestJobStopReason reason; + + while (true) + { + if (remaining.All(r => r == 0)) + { + reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met. + break; + } + if (job.Options.MaxPlates is int cap && result.SheetsUsed(job) >= cap) + { + reason = NestJobStopReason.PlateLimitReached; + break; + } + + var trials = new List<(SheetPlan Plan, double Net)>(); + foreach (var stock in job.Plates) + { + token.ThrowIfCancellationRequested(); + if (stock.Quantity is int available && used[stock.Id] >= available) + continue; + progress?.Report(new NestJobProgress( + NestJobStage.EvaluatingCandidate, stock.Id, result.SheetsUsed(job), result.SheetsUsed(job), 0)); + foreach (var mode in Modes) + foreach (var rule in Rules) + { + var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token); + if (plan.Parts.Count > 0) + trials.Add((plan, NetArea(job, plan))); + } + } + + 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; + } + + var chosen = Choose(types, remaining, trials); + result.AddSheet(chosen.Stock, chosen.Poses()); + used[chosen.Stock.Id]++; + foreach (var p in chosen.Parts) + remaining[p.Type.Index]--; + } + + return result.Build(reason); + } + + /// + /// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the + /// best net-area-per-material ratio any candidate achieved, so a sheet that finishes the job + /// competes fairly with a denser partial one. Ties: more material placed, then enumeration order. + /// + private static SheetPlan Choose( + IReadOnlyList types, int[] remaining, List<(SheetPlan Plan, double Net)> trials) + { + var demandArea = types.Sum(t => remaining[t.Index] * t.MaterialArea); + var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12)); + return trials + .Select((t, order) => (t.Plan, order, + Estimate: t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio)) + .OrderBy(t => PriorityDebt(types, remaining, t.Plan)) + .ThenBy(t => t.Estimate) + .ThenByDescending(t => t.Plan.MaterialArea) + .ThenBy(t => t.order) + .First() + .Plan; + } + + /// + /// Priority guard: how many instances of the most urgent (lowest-number) tier with remaining + /// demand this plan leaves unplaced. Plans are ranked on this before cost, so a cheaper sheet + /// can never win by serving a later tier at the expense of an earlier one. + /// + private static int PriorityDebt(IReadOnlyList types, int[] remaining, SheetPlan plan) + { + var active = types.Where(t => remaining[t.Index] > 0).ToList(); + if (active.Count == 0) + return 0; + var top = active.Min(t => t.Priority); + var placed = plan.Parts.Count(p => p.Type.Priority == top); + return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed; + } + + private static double NetArea(NestJob job, SheetPlan plan) => + plan.Envelope is { } envelope + ? NestJobCost.NetSheetArea(job.Options, plan.Stock, envelope) + : plan.Stock.Area; + + private static bool FitsStock(NestPlateStock stock, BoxOrientation o) + { + var work = stock.WorkArea; + return o.Width <= work.Right - work.Left + MaxRectsSheet.Eps + && o.Height <= work.Top - work.Bottom + MaxRectsSheet.Eps; + } +} + +internal static class ResultBuilderExtensions +{ + public static int SheetsUsed(this NestJobResultBuilder builder, NestJob job) => + job.Plates.Sum(builder.SheetsUsed); +} diff --git a/OpenNest.Engine.Rectangles/SheetPacker.cs b/OpenNest.Engine.Rectangles/SheetPacker.cs new file mode 100644 index 0000000..7edd7ac --- /dev/null +++ b/OpenNest.Engine.Rectangles/SheetPacker.cs @@ -0,0 +1,165 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Rectangles; + +/// How the next box is chosen on a sheet. +internal enum PickMode +{ + /// Every step places whichever remaining type/orientation scores best anywhere. + Global, + /// Types in (priority, largest box first) order; each fills until it no longer fits. + Ordered, +} + +/// One placed box: which part type, which orientation, and its material bounds' corner. +internal readonly record struct Placed(BoxType Type, BoxOrientation Orientation, double Left, double Bottom); + +/// A proposed single-sheet layout. +internal sealed record SheetPlan( + NestPlateStock Stock, + IReadOnlyList Parts, + double MaterialArea, + Box? Envelope, + FitRule Rule, + PickMode Mode) +{ + /// Converts box corners into job poses (rotate about the snapshot origin, then translate). + public IEnumerable<(string PartId, double X, double Y, double Rotation)> Poses() => + Parts.Select(p => (p.Type.Id, p.Left - p.Orientation.OffsetX, p.Bottom - p.Orientation.OffsetY, + p.Orientation.Angle)); +} + +/// +/// Packs the remaining demand onto one sheet of the given stock with a maximal-rectangles free +/// list. Lower priority numbers are always served first: a higher-number type is only placed +/// when no lower-number type still fits anywhere. +/// +internal static class SheetPacker +{ + public static SheetPlan Pack( + IReadOnlyList types, IReadOnlyList remaining, NestPlateStock stock, + FitRule rule, PickMode mode, CancellationToken token) + { + var work = stock.WorkArea; + var s = stock.PartSpacing; + var sheet = new MaxRectsSheet(work.Right - work.Left + s, work.Top - work.Bottom + s); + var left = remaining.ToArray(); + var placed = new List(); + + if (mode == PickMode.Global) + PackGlobal(types, left, sheet, s, rule, placed, token); + else + PackOrdered(types, left, sheet, s, rule, placed, token); + + var area = 0.0; + Box? envelope = null; + foreach (var p in placed) + { + area += p.Type.MaterialArea; + var box = new Box(work.Left + p.Left, work.Bottom + p.Bottom, p.Orientation.Width, p.Orientation.Height); + envelope = envelope == null ? box : Union(envelope, box); + } + + var world = placed + .Select(p => p with { Left = work.Left + p.Left, Bottom = work.Bottom + p.Bottom }) + .ToList(); + return new SheetPlan(stock, world, area, envelope, rule, mode); + } + + private static void PackGlobal( + IReadOnlyList types, int[] left, MaxRectsSheet sheet, double s, FitRule rule, + List placed, CancellationToken token) + { + // Free space only shrinks, so an orientation that fails once never fits again on this sheet. + var dead = types.Select(t => new bool[t.Orientations.Count]).ToArray(); + var tiers = types.Select(t => t.Priority).Distinct().Order().ToArray(); + + while (true) + { + token.ThrowIfCancellationRequested(); + (BoxType Type, int Orientation, Rect Place, double P, double S)? best = null; + foreach (var tier in tiers) + { + foreach (var type in types) + { + if (type.Priority != tier || left[type.Index] == 0) + continue; + for (var o = 0; o < type.Orientations.Count; o++) + { + if (dead[type.Index][o]) + continue; + var orientation = type.Orientations[o]; + var fit = sheet.FindBest(orientation.Width + s, orientation.Height + s, rule); + if (fit is not { } f) + { + dead[type.Index][o] = true; + continue; + } + if (best is not { } b || Better(f.Primary, f.Secondary, type.BoxArea, b.P, b.S, b.Type.BoxArea)) + best = (type, o, f.Place, f.Primary, f.Secondary); + } + } + if (best != null) + break; + } + + if (best is not { } chosen) + return; + sheet.Place(chosen.Place); + left[chosen.Type.Index]--; + placed.Add(new Placed(chosen.Type, chosen.Type.Orientations[chosen.Orientation], chosen.Place.X, chosen.Place.Y)); + } + } + + private static void PackOrdered( + IReadOnlyList types, int[] left, MaxRectsSheet sheet, double s, FitRule rule, + List placed, CancellationToken token) + { + var order = types + .Where(t => t.Orientations.Count > 0) + .OrderBy(t => t.Priority) + .ThenByDescending(t => t.BoxArea) + .ThenBy(t => t.Index); + + foreach (var type in order) + { + while (left[type.Index] > 0) + { + token.ThrowIfCancellationRequested(); + (int Orientation, Rect Place, double P, double S)? best = null; + for (var o = 0; o < type.Orientations.Count; o++) + { + var orientation = type.Orientations[o]; + var fit = sheet.FindBest(orientation.Width + s, orientation.Height + s, rule); + if (fit is { } f && (best is not { } b || Better(f.Primary, f.Secondary, 0, b.P, b.S, 0))) + best = (o, f.Place, f.Primary, f.Secondary); + } + if (best is not { } chosen) + break; + sheet.Place(chosen.Place); + left[type.Index]--; + placed.Add(new Placed(type, type.Orientations[chosen.Orientation], chosen.Place.X, chosen.Place.Y)); + } + } + } + + /// Lower score wins; on a tie the larger box goes first (strict, so input order breaks full ties). + private static bool Better(double p, double s, double area, double bp, double bs, double barea) + { + if (p < bp - MaxRectsSheet.Eps) return true; + if (p > bp + MaxRectsSheet.Eps) return false; + if (s < bs - MaxRectsSheet.Eps) return true; + if (s > bs + MaxRectsSheet.Eps) return false; + return area > barea + MaxRectsSheet.Eps; + } + + private static Box Union(Box a, Box b) + { + var l = System.Math.Min(a.Left, b.Left); + var bo = System.Math.Min(a.Bottom, b.Bottom); + var r = System.Math.Max(a.Right, b.Right); + var t = System.Math.Max(a.Top, b.Top); + return new Box(l, bo, r - l, t - bo); + } +} diff --git a/OpenNest.Engine.Rectangles/tests/CurvedExtremeSweepTests.cs b/OpenNest.Engine.Rectangles/tests/CurvedExtremeSweepTests.cs new file mode 100644 index 0000000..c8609e9 --- /dev/null +++ b/OpenNest.Engine.Rectangles/tests/CurvedExtremeSweepTests.cs @@ -0,0 +1,31 @@ +using OpenNest.Engine.Testing; +using static OpenNest.Engine.Testing.JobBuilder; +using static OpenNest.Engine.Testing.Shapes; +using OpenNest.Engine.Jobs; +namespace OpenNest.Engine.Rectangles.Tests; +/// +/// Curved extremes (discs, rings, obrounds) and sloped ones (triangles) must clear the layout check +/// at every spacing: the check circumscribes arcs, so box-touching copies are only valid when the +/// catalog reads their boxes the way the check does. Failed at 16-50 of these 80 jobs before that. +/// +public class CurvedExtremeSweepTests +{ + [Fact] + public void CurvedAndSlopedPartsPassTheLayoutCheckAtEverySpacing() + { + var bad = new List(); var n = 0; + foreach (var r in new[] { 0.37, 0.5, 0.731, 1.0, 1.23, 2.0, 3.3, 5.0, 7.77, 12.0 }) + foreach (var sp in new[] { 0.0, 0.1, 0.25, 0.3125 }) + foreach (var rot in new[] { false, true }) + foreach (var ring in new[] { false, true }) + { + var pol = rot ? RotationPolicy.Automatic : RotationPolicy.Fixed(0); + var job = Job(new[] { Part("d", ring ? Ring(2 * r, r) : Disc(r), 12, pol), Part("o", Obround(4 * r, 1.3 * r), 6, pol), Part("t", Triangle(3 * r, 2 * r), 4, pol) }, + new[] { Stock("s", 12 * r + 5, 14 * r + 5, spacing: sp) }); + var res = new RectanglesNestingEngine().Solve(job); n++; + var v = NestLayoutCheck.Violations(job, res); + if (v.Count > 0) bad.Add($"r={r} sp={sp} rot={rot} ring={ring}: {v[0]}"); + } + Assert.True(bad.Count == 0, $"{bad.Count}/{n}\n" + string.Join("\n", bad)); + } +} diff --git a/OpenNest.Engine.Rectangles/tests/OpenNest.Engine.Rectangles.Tests.csproj b/OpenNest.Engine.Rectangles/tests/OpenNest.Engine.Rectangles.Tests.csproj new file mode 100644 index 0000000..6fe9df8 --- /dev/null +++ b/OpenNest.Engine.Rectangles/tests/OpenNest.Engine.Rectangles.Tests.csproj @@ -0,0 +1,16 @@ + + + false + true + + + + + + + + + + + + diff --git a/OpenNest.Engine.Rectangles/tests/RectanglesNestingEngineTests.cs b/OpenNest.Engine.Rectangles/tests/RectanglesNestingEngineTests.cs new file mode 100644 index 0000000..f9ed1d2 --- /dev/null +++ b/OpenNest.Engine.Rectangles/tests/RectanglesNestingEngineTests.cs @@ -0,0 +1,157 @@ +using OpenNest.Engine.Testing; +using static OpenNest.Engine.Testing.JobBuilder; +using static OpenNest.Engine.Testing.Shapes; +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Rectangles.Tests; + +/// +/// Starter acceptance tests. Every layout is checked by the shared NestLayoutCheck 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 RectanglesNestingEngineTests +{ + [Fact] + public void HasPublicParameterlessConstructorForPluginDiscovery() + { + var engine = Activator.CreateInstance(typeof(RectanglesNestingEngine)); + 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 RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(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 RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(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 RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(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 RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge"); + Assert.Equal(1, huge.Unplaced); + } + + [Fact] + public void ExactFitGridPacksAtExactlyThePartSpacing() + { + // 4 x 3 boxes of 10 x 5 at 0.5 spacing need exactly 41.5 x 16. + var job = Job(new[] { Part("r", Rectangle(10, 5), 12) }, + new[] { Stock("s", 16, 41.5, spacing: 0.5, quantity: 1) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(12, Assert.Single(result.Plates).Placements.Count); + } + + [Fact] + public void ArcExtremePartsStayClearAtTheSpacing() + { + // Discs and obrounds have arcs, not vertices, at their box edges: the validator's + // circumscribed flattening would read box-touching copies as closer than the spacing. + var job = Job(new[] { Part("disc", Disc(2), 20), Part("ob", Obround(8, 3), 12) }, + new[] { Stock("s", 30, 40, spacing: 0.25) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void MixedSizesFillOneSheetThatShelfPackingWouldSplit() + { + // Area check: 2*(24x20) + 4*(12x10) + 8*(6x5) = 960 + 480 + 240 = 1680 of 48 x 40 = 1920. + // A maximal-rectangles packing fits all of it on one sheet with zero spacing. + var job = Job(new[] + { + Rectangle("big", 24, 20, 2, RotationPolicy.Automatic), + Rectangle("mid", 12, 10, 4, RotationPolicy.Automatic), + Rectangle("small", 6, 5, 8, RotationPolicy.Automatic), + }, + new[] { Stock("s", 40, 48) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Single(result.Plates); + } + + [Fact] + public void RotatedInputIsNestedAtItsMinimumBoundingRectangle() + { + // A 10 x 4 rectangle drawn at 30 degrees: only its squared-up box fits 4 per 20.5 x 8.5 sheet. + var c = System.Math.Cos(System.Math.PI / 6); + var s = System.Math.Sin(System.Math.PI / 6); + (double, double) R(double x, double y) => (x * c - y * s + 5, x * s + y * c + 5); + var tilted = Polyline(R(0, 0), R(10, 0), R(10, 4), R(0, 4)); + var job = Job(new[] { Part("tilted", tilted, 4, RotationPolicy.Automatic) }, + new[] { Stock("s", 8.5, 20.5, spacing: 0.5, quantity: 1) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } +} + +public sealed class RectanglesContractTests : EngineContractTests { } diff --git a/README.md b/README.md index 71c4419..7305dc6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ the OpenNest app or `OpenNest.Benchmark` build output. | [Gpt6Astra](OpenNest.Engine.Gpt6Astra/) | Contact-based placement | | [Opus55](OpenNest.Engine.Opus55/) | Frontier-advance no-fit-polygon packing | | [Qwen38FlashNext](OpenNest.Engine.Qwen38FlashNext/) | Bottom-left greedy insertion over convex NFPs with an exact clearance gate | +| [Rectangles](OpenNest.Engine.Rectangles/) | Rectangle lane: maximal-rectangles box packing for plain and near-rectangular parts | ## Building