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