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
This commit is contained in:
aj
2026-09-29 20:49:00 -04:00
parent 33cc2ee810
commit 13478f5102
10 changed files with 900 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Rectangles;
/// <summary>One allowed rotation of a part, reduced to its analytic material bounding box.</summary>
/// <param name="Angle">Rotation in radians about the snapshot origin.</param>
/// <param name="Width">Material X extent after rotation.</param>
/// <param name="Height">Material Y extent after rotation.</param>
/// <param name="OffsetX">Rotated material bounds' left edge relative to the snapshot origin.</param>
/// <param name="OffsetY">Rotated material bounds' bottom edge relative to the snapshot origin.</param>
internal sealed record BoxOrientation(double Angle, double Width, double Height, double OffsetX, double OffsetY);
/// <summary>A requested part type: every instance shares the same orientations.</summary>
internal sealed record BoxType(
int Index,
NestJobPart Part,
IReadOnlyList<BoxOrientation> Orientations,
double MaterialArea)
{
public string Id => Part.Id;
public int Priority => Part.Priority;
/// <summary>Smallest bounding-box area over the allowed orientations.</summary>
public double BoxArea => Orientations.Count == 0 ? 0 : Orientations.Min(o => o.Width * o.Height);
/// <summary>Shortest side over all orientations; free space narrower than this is useless.</summary>
public double MinSide => Orientations.Count == 0 ? double.MaxValue
: Orientations.Min(o => System.Math.Min(o.Width, o.Height));
}
/// <summary>
/// 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.
/// </summary>
internal static class BoxCatalog
{
private const double AreaTieRelative = 1e-6;
private const double DimensionTie = 1e-7;
public static IReadOnlyList<BoxType> Build(NestJob job)
{
var types = new List<BoxType>(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<BoxOrientation>(), 0);
var candidates = new List<BoxOrientation>();
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<BoxOrientation>(), geometry.MaterialArea);
var minArea = candidates.Min(c => c.Width * c.Height);
var kept = new List<BoxOrientation>();
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
+165
View File
@@ -0,0 +1,165 @@
namespace OpenNest.Engine.Rectangles;
/// <summary>Axis-aligned rectangle in sheet-local packing coordinates.</summary>
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;
}
/// <summary>How a free position is scored; lower (Primary, Secondary) wins.</summary>
internal enum FitRule
{
/// <summary>Smallest leftover on the tighter side of the free rectangle.</summary>
BestShortSide,
/// <summary>Smallest leftover on the looser side of the free rectangle.</summary>
BestLongSide,
/// <summary>Smallest free rectangle that holds the item.</summary>
BestArea,
/// <summary>Lowest top edge, then leftmost: packs rows upward and keeps a clean top offcut.</summary>
BottomLeft,
/// <summary>Leftmost right edge, then lowest: packs columns rightward and keeps a clean right offcut.</summary>
LeftBottom,
/// <summary>Most perimeter touching the sheet edge or already placed items.</summary>
ContactPoint,
}
/// <summary>
/// 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.
/// </summary>
internal sealed class MaxRectsSheet
{
public const double Eps = 1e-9;
private readonly List<Rect> free = new();
private readonly List<Rect> 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<Rect> Used => used;
/// <summary>Best position for a w-by-h item under the rule, or null when nothing holds it.</summary>
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;
}
/// <summary>Commits an item and splits every free rectangle it intersects.</summary>
public void Place(Rect item)
{
var next = new List<Rect>(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<Rect> Prune(List<Rect> 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<Rect>(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));
}
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Shared settings and the OpenNest.Engine reference come from Directory.Build.props. -->
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Engine.Rectangles.Tests" />
</ItemGroup>
</Project>
+74
View File
@@ -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).
@@ -0,0 +1,149 @@
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Rectangles;
/// <summary>
/// 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.
/// </summary>
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<NestJobProgress>? 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);
}
/// <summary>
/// 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.
/// </summary>
private static SheetPlan Choose(
IReadOnlyList<BoxType> 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;
}
/// <summary>
/// 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.
/// </summary>
private static int PriorityDebt(IReadOnlyList<BoxType> 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);
}
+165
View File
@@ -0,0 +1,165 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Rectangles;
/// <summary>How the next box is chosen on a sheet.</summary>
internal enum PickMode
{
/// <summary>Every step places whichever remaining type/orientation scores best anywhere.</summary>
Global,
/// <summary>Types in (priority, largest box first) order; each fills until it no longer fits.</summary>
Ordered,
}
/// <summary>One placed box: which part type, which orientation, and its material bounds' corner.</summary>
internal readonly record struct Placed(BoxType Type, BoxOrientation Orientation, double Left, double Bottom);
/// <summary>A proposed single-sheet layout.</summary>
internal sealed record SheetPlan(
NestPlateStock Stock,
IReadOnlyList<Placed> Parts,
double MaterialArea,
Box? Envelope,
FitRule Rule,
PickMode Mode)
{
/// <summary>Converts box corners into job poses (rotate about the snapshot origin, then translate).</summary>
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));
}
/// <summary>
/// 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.
/// </summary>
internal static class SheetPacker
{
public static SheetPlan Pack(
IReadOnlyList<BoxType> types, IReadOnlyList<int> 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<Placed>();
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<BoxType> types, int[] left, MaxRectsSheet sheet, double s, FitRule rule,
List<Placed> 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<BoxType> types, int[] left, MaxRectsSheet sheet, double s, FitRule rule,
List<Placed> 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));
}
}
}
/// <summary>Lower score wins; on a tie the larger box goes first (strict, so input order breaks full ties).</summary>
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);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class CurvedExtremeSweepTests
{
[Fact]
public void CurvedAndSlopedPartsPassTheLayoutCheckAtEverySpacing()
{
var bad = new List<string>(); 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));
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
<ProjectReference Include="../OpenNest.Engine.Rectangles.csproj" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class RectanglesNestingEngineTests
{
[Fact]
public void HasPublicParameterlessConstructorForPluginDiscovery()
{
var engine = Activator.CreateInstance(typeof(RectanglesNestingEngine));
Assert.IsAssignableFrom<INestingEngine>(engine);
}
[Fact]
public void RectanglesFitOnOneSheetWithSpacing()
{
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
var result = new 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<RectanglesNestingEngine> { }