feat(engine): squeeze the Rectangles sheet that finishes the job

Auto Nest left N26-2A5 (14 door stops, 14 lock plates on a clamped 26x26
sheet) 8.0 high, while the shop's hand layout holds all 28 under 7.5 and
leaves a half-inch-deeper drop. Every MaxRects fit rule packs into the
whole sheet, so with room to spare it spreads the parts out and never
looks for the shortest strip.

When a rule places all the remaining demand, Rectangles now repacks it
into shorter and narrower bins, every rule and mode per probe, and offers
the tightest complete layout per axis to the stock loop, which still
picks by cost and offcut. Fit is not monotonic in bin size (7.5 packs,
7.62 and 7.74 do not), so probes walk down through stack heights (sums of
spaced part sides from the bin edge or a reached clamp) instead of
bisecting. A placement budget skips sheets finishing ~2000+ parts.

Benchmark, Rectangles on 80 recent production nests with salvage credit:
11 cheaper, 0 worse, all valid, total time 14.7 s -> 13.2 s.

The zero-clamp hash is re-captured (the squeezed layout is narrower);
the far-clamp test now compares poses, since the clamp can reorder an
identical layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-10 11:10:06 -05:00
1 parent 7dc471bbf8
commit 9273a44258
6 files changed
+362 -13

No files matched your search

@@ -36,8 +36,10 @@ public class RectanglesClampZoneTests
[Theory]
// Pre-clamp baseline hashes, captured from the unchanged packer at commit a3f1124 and
// re-captured when equal-cost sheet layouts began preferring the larger free offcut.
[InlineData("3D389D6B993B69634EB45FC2CB0309273DE1BCF57A7C2BD980C63B535667B0C6")]
// re-captured when equal-cost sheet layouts began preferring the larger free offcut, then
// when the finishing sheet began squeezing into a narrower bin (twelve parts now reach
// 20.25 along X instead of spreading over the sheet).
[InlineData("578D350691FF80C768BD4AE245F03863885669B7E9AB86A9CDCB0302B1F1DD01")]
public void ZeroClampLayoutIsBitIdenticalToBaseline(string hash) =>
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
new[] { Part("rect", Rectangle(10, 5), 12) },
@@ -65,6 +67,13 @@ public class RectanglesClampZoneTests
new[] { Part("square", Rectangle(10, 10), 30) },
new[] { Stock("sheet", 25, 45, spacing: 0.25) }))));
private static string[] Poses(NestJobResult result) => result.Plates
.SelectMany(sheet => sheet.Placements.Select(p => string.Join('|', sheet.PlateIndex, sheet.StockId, p.PartId,
p.X.ToString("R", CultureInfo.InvariantCulture), p.Y.ToString("R", CultureInfo.InvariantCulture),
p.Rotation.ToString("R", CultureInfo.InvariantCulture))))
.Order(StringComparer.Ordinal)
.ToArray();
// ---- clamp avoidance ---------------------------------------------------------------
[Fact]
@@ -149,7 +158,10 @@ public class RectanglesClampZoneTests
var plain = Engine().Solve(jobPlain);
LayoutAssert.Valid(jobClamped, clamped);
Assert.Equal(LayoutHash(plain), LayoutHash(clamped));
// No part may move. The zone splits the packer's free list, so the finishing-sheet
// squeeze can reach the same poses through a different rule and list them in another
// order; compare the poses, not their order.
Assert.Equal(Poses(plain), Poses(clamped));
}
// ---- large part spacing vs shallow clamp spans (PM d86d22c9, decision D11) --------
@@ -0,0 +1,69 @@
using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>
/// The sheet that finishes a job is re-packed into a shorter or narrower bin, so the drop it
/// leaves is as large as the packer can make it.
/// </summary>
public class RectanglesSqueezeTests
{
/// <summary>
/// N26-2A5: 14 door stops (4.168 x 1.5) and 14 lock plates (2 x 2) on a 26 x 26 sheet with a
/// front clamp. Every fit rule on the whole sheet stopped at 8.0 high; the shop's hand layout
/// holds all 28 under 7.5 (three rows of four stops, two stops on end over the clamp, a 2 x 2
/// block of plates, then a row of plates across the top), leaving an 18" drop.
/// </summary>
[Fact]
public void FinishingSheetPacksIntoTheShortestStripItFinds()
{
var clamp = new ClampZone("clamp-1", new Box(19.25, 0, 1.5, 0.625));
var job = Job(
[
Part("door stop", Rectangle(4.168108, 1.5), 14),
Part("lock plate", Rectangle(2, 2), 14),
],
[Stock("sheet", 26, 26, spacing: 0.25, edge: new Spacing(0.25, 0.25, 0.25, 0.25), clampZones: [clamp])]);
var result = new RectanglesNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Assert.True(Top(job, result.Plates[0]) <= 7.5 + 1e-9, $"layout top {Top(job, result.Plates[0])}");
}
[Fact]
public void SheetsThatDoNotFinishTheJobAreNotSqueezed()
{
// Four 9 x 9 squares fill each 20 x 20 sheet; only the last sheet could be squeezed, and
// it repeats the full layout, so every sheet keeps one pass of the twelve rule/mode packs.
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var calls = 0;
var result = new RectanglesNestingEngine { SheetPacked = _ => calls++ }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(12, calls);
}
private static double Top(NestJob job, NestJobPlateResult sheet) =>
sheet.Placements.Max(pose =>
{
var part = job.Parts.Single(p => p.Id == pose.PartId);
var geometry = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToArray();
foreach (var entity in geometry)
{
entity.Rotate(pose.Rotation);
entity.Offset(pose.X, pose.Y);
}
return geometry.Max(e => e.Top);
});
}
@@ -64,14 +64,29 @@ public sealed class RectanglesNestingEngine : INestingEngine
{
// Both private MaxRects modes return only after their normal demand/no-fit search;
// neither returns partial/budget-limited output. This is not a geometric impossibility proof.
var plans = PackAll(stock, left, null, null);
foreach (var plan in plans)
yield return Layout(plan);
// A sheet that finishes the job is also tried squeezed into a shorter or narrower bin.
foreach (var plan in SheetSqueeze.Run(types, left, stock, plans,
(width, height) => PackAll(stock, left, width, height), token))
yield return Layout(plan);
}
IReadOnlyList<SheetPlan> PackAll(NestPlateStock stock, IReadOnlyList<int> left, double? width, double? height)
{
var plans = new List<SheetPlan>(Modes.Length * Rules.Length);
foreach (var mode in Modes)
foreach (var rule in Rules)
{
var plan = SheetPacker.Pack(types, left, stock, rule, mode, token);
plans.Add(SheetPacker.Pack(types, left, stock, rule, mode, token, width, height));
SheetPacked?.Invoke(mode);
yield return new SheetLayout(stock, plan.Poses().ToArray(), plan.MaterialArea, plan.Envelope);
}
return plans;
}
static SheetLayout Layout(SheetPlan plan) =>
new(plan.Stock, plan.Poses().ToArray(), plan.MaterialArea, plan.Envelope);
}
private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
@@ -49,19 +49,36 @@ internal static class SheetPacker
public static double PackSize(double size, double sheet) =>
size > sheet && size <= sheet + OverhangAllowance ? sheet : size;
/// <summary>Packing bin size for a stock: its work area grown by one part spacing per axis.</summary>
public static (double Width, double Height) BinSize(NestPlateStock stock)
{
var work = stock.WorkArea;
return (work.Right - work.Left + stock.PartSpacing, work.Top - work.Bottom + stock.PartSpacing);
}
/// <param name="maxBinWidth">When set, a squeeze limit: bin-local packing width (box plus its
/// spacing tail) no box may pass, so material ends one part spacing short of it.</param>
/// <param name="maxBinHeight">The same limit along Y.</param>
public static SheetPlan Pack(
IReadOnlyList<BoxType> types, IReadOnlyList<int> remaining, NestPlateStock stock,
FitRule rule, PickMode mode, CancellationToken token)
FitRule rule, PickMode mode, CancellationToken token,
double? maxBinWidth = null, double? maxBinHeight = null)
{
var work = stock.WorkArea;
var s = stock.PartSpacing;
var sheet = new MaxRectsSheet(work.Right - work.Left + s, work.Top - work.Bottom + s);
BlockClamps(sheet, stock, work, s);
var (binWidth, binHeight) = BinSize(stock);
// A squeeze packs a smaller bin from the same corner, exactly as on a smaller sheet (the
// contact rule scores its edges too); the overhang allowance and clamp side choice still
// measure the real sheet.
var sheet = new MaxRectsSheet(
maxBinWidth is double w && w < binWidth ? w : binWidth,
maxBinHeight is double h && h < binHeight ? h : binHeight);
BlockClamps(sheet, stock, work, s, binWidth, binHeight);
var packTypes = types
.Select(t => new PackType(
t.Priority,
t.Orientations
.Select(o => (PackSize(o.Width + s, sheet.Width), PackSize(o.Height + s, sheet.Height)))
.Select(o => (PackSize(o.Width + s, binWidth), PackSize(o.Height + s, binHeight)))
.ToList(),
t.BoxArea))
.ToList();
@@ -104,7 +121,8 @@ internal static class SheetPacker
/// exactly on the centerline with material inside the clamp.
/// Empty zone lists change nothing: the free list evolves bit-identically (C2).
/// </summary>
private static void BlockClamps(MaxRectsSheet sheet, NestPlateStock stock, Box work, double s)
private static void BlockClamps(
MaxRectsSheet sheet, NestPlateStock stock, Box work, double s, double binWidth, double binHeight)
{
if (stock.ClampZones.Count == 0)
return;
@@ -121,7 +139,7 @@ internal static class SheetPacker
{
var center = x0((b.Left + b.Right) * 0.5);
// D11: the interior-facing clamp edge (toward the farther sheet edge).
var line = center < sheet.Width * 0.5
var line = center < binWidth * 0.5
? x0(b.Right) + clearance
: x0(b.Left) - clearance + s;
left = line;
@@ -132,7 +150,7 @@ internal static class SheetPacker
if (top <= bottom)
{
var center = y0((b.Bottom + b.Top) * 0.5);
var line = center < sheet.Height * 0.5
var line = center < binHeight * 0.5
? y0(b.Top) + clearance
: y0(b.Bottom) - clearance + s;
bottom = line;
@@ -0,0 +1,215 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.NestingEngines.Rectangles;
/// <summary>
/// Squeezes the sheet that finishes the job. The fit rules pack into the whole sheet, so when the
/// remaining demand leaves room they spread it out and rarely find the shortest strip that holds
/// it: on N26-2A5 (14 door stops and 14 lock plates on a 26 x 26 clamped sheet) every rule
/// stopped at 8.0 high while a 7.5 strip holds all 28. When some packing places all the remaining
/// demand, this re-packs it into a bin cut shorter (then, separately, narrower), running every rule
/// and mode at each probe, and returns the tightest complete layout per axis when it beats the
/// unsqueezed ones; the stock loop then picks by cost and edge drop as usual.
///
/// Whether a greedy packing fits is far from monotonic in the bin size (that job packs at 7.5 but
/// not at 7.62 or 7.74), and a tight layout ends where a stack of spaced part sides ends. So the
/// probes are those stack heights: sums of the parts' spaced sides along the axis, starting from
/// the bin edge or the far edge of a clamp keep-out the layout reaches. They walk down from the
/// best extent so far, each step spread over what is left of the range for the probes left, and a
/// failed probe never raises the floor. When there are too many sums to list, the walk steps
/// through the size range instead. A probe that places everything moves the walk down to that
/// layout's own extent, so the answer never keeps a probe's slack.
/// Deterministic, and bounded by a work budget over demand size: large jobs skip it entirely.
/// </summary>
internal static class SheetSqueeze
{
/// <summary>Most probes per axis.</summary>
internal const int MaxProbes = 12;
/// <summary>Box placements the squeeze may spend: probes x variants x demand, both axes.</summary>
internal const long PlacementBudget = 50_000;
/// <summary>Most stack heights listed before the walk falls back to stepping the size range.</summary>
private const int MaxStackHeights = 2048;
/// <summary>A size-range walk stops once less than this fraction of the bin is left to try,
/// and a squeezed layout must beat the whole-sheet ones by at least as much.</summary>
private const double Resolution = 1e-3;
/// <summary>
/// Probes sit this far past a stack height. MaxRects already lets a box overrun by its Eps
/// (1e-9), which covers a stack's float rounding; a margin past that Eps would hide the bin's
/// top edge from the contact-point rule, which scores edge contact within Eps.
/// </summary>
private const double ProbeMargin = 1e-10;
/// <param name="plans">The unsqueezed layouts of <paramref name="remaining"/> on this stock.</param>
/// <param name="packAll">Every rule and mode packed under a bin limit (width, height).</param>
public static IEnumerable<SheetPlan> Run(
IReadOnlyList<BoxType> types, IReadOnlyList<int> remaining, NestPlateStock stock,
IReadOnlyList<SheetPlan> plans, Func<double?, double?, IReadOnlyList<SheetPlan>> packAll,
CancellationToken token)
{
var demand = remaining.Sum();
bool Complete(SheetPlan p) => p.Parts.Count == demand && p.Envelope != null;
var complete = plans.Where(Complete).ToList();
if (demand == 0 || complete.Count == 0)
yield break;
var probes = (int)System.Math.Min(MaxProbes, PlacementBudget / (2L * demand * plans.Count));
if (probes == 0)
yield break;
var work = stock.WorkArea;
var s = stock.PartSpacing;
var (binWidth, binHeight) = SheetPacker.BinSize(stock);
foreach (var vertical in new[] { true, false })
{
double Extent(SheetPlan p) => vertical ? p.Envelope!.Top - work.Bottom : p.Envelope!.Right - work.Left;
var bin = vertical ? binHeight : binWidth;
var across = vertical ? binWidth : binHeight;
var baseline = complete.Min(Extent);
var hi = baseline + s;
var lo = LowerBound(types, remaining, across, s, vertical);
// How far across the squeezed axis the tightest whole-sheet layout reaches: only
// clamps within it can hold a stack, so a zone the demand never reaches changes nothing.
var widest = complete.Where(p => Extent(p) == baseline).Max(p => vertical
? p.Envelope!.Right - work.Left
: p.Envelope!.Top - work.Bottom);
var heights = StackHeights(types, remaining, stock, across, vertical, hi, widest);
SheetPlan? tightest = null;
// Probes walk down from the best extent so far, each spread over what is left of the
// range for the probes left. A failed probe only moves the walk below it: a smaller bin
// may still fit, so it never raises the floor.
var cursor = hi;
for (var probe = 0; probe < probes; probe++)
{
token.ThrowIfCancellationRequested();
var target = cursor - (cursor - lo) / (probes - probe);
double limit;
if (heights != null)
{
// The highest stack height at or under the target, else the next one down.
var next = heights.LastOrDefault(h => h > lo && h <= target + ProbeMargin);
if (next == 0)
next = heights.LastOrDefault(h => h > lo && h < cursor - ProbeMargin);
if (next == 0)
break;
limit = next + ProbeMargin;
}
else if (cursor - lo > Resolution * bin)
limit = target;
else
break;
var fits = packAll(vertical ? null : limit, vertical ? limit : null).Where(Complete).ToList();
if (fits.Count == 0)
{
cursor = limit - ProbeMargin;
continue;
}
// First of the tightest keeps enumeration order as the tie-break.
var best = fits.Aggregate((a, b) => Extent(b) < Extent(a) ? b : a);
tightest = best;
cursor = System.Math.Min(limit, Extent(best) + s) - ProbeMargin;
}
if (tightest != null && Extent(tightest) < baseline - Resolution * bin)
yield return tightest;
}
}
/// <summary>
/// A bin extent along the squeezed axis below which the demand cannot fit: every part needs its
/// shortest fitting side plus spacing, and the boxes' spaced area must fit across the bin.
/// Clamps only add to it, so ignoring them keeps the bound valid.
/// </summary>
private static double LowerBound(
IReadOnlyList<BoxType> types, IReadOnlyList<int> remaining, double across, double s, bool vertical)
{
double fit = 0, area = 0;
foreach (var t in types)
{
if (remaining[t.Index] == 0 || t.Orientations.Count == 0)
continue;
var sizes = Sizes(t, s, vertical).ToList();
var fitting = sizes.Where(z => z.Across <= across + SheetPacker.OverhangAllowance).ToList();
if (fitting.Count > 0)
fit = System.Math.Max(fit, fitting.Min(z => z.Along));
area += remaining[t.Index] * sizes.Min(z => z.Along * z.Across);
}
return System.Math.Max(fit, area / across);
}
/// <summary>
/// Every bin extent below <paramref name="below"/> that a stack of spaced part sides reaches from
/// the bin edge or the far edge of a clamp keep-out that starts within <paramref name="reach"/>
/// across the axis, sorted; null when there are more than
/// <see cref="MaxStackHeights"/>. Stacks ignore part counts, so some heights are unreachable;
/// they cost a probe each, never a missed layout.
/// </summary>
private static List<double>? StackHeights(
IReadOnlyList<BoxType> types, IReadOnlyList<int> remaining, NestPlateStock stock,
double across, bool vertical, double below, double reach)
{
var s = stock.PartSpacing;
var sides = types
.Where(t => remaining[t.Index] > 0)
.SelectMany(t => Sizes(t, s, vertical))
.Where(z => z.Across <= across + SheetPacker.OverhangAllowance && z.Along > 0)
.Select(z => z.Along)
.Distinct()
.ToArray();
var work = stock.WorkArea;
var bases = new List<double> { 0 };
foreach (var zone in stock.ClampZones)
{
var edge = (vertical ? zone.Bounds.Top - work.Bottom : zone.Bounds.Right - work.Left)
+ stock.ClampClearance;
var near = (vertical ? zone.Bounds.Left - work.Left : zone.Bounds.Bottom - work.Bottom)
- stock.ClampClearance;
if (edge > 0 && edge < below && near < reach)
bases.Add(edge);
}
// Keys on a 1e-6 grid merge sums that differ only by float rounding; each keeps its
// largest float value so a probe just past it holds every stack it stands for.
var seen = new Dictionary<double, double>();
var queue = new Queue<double>(bases);
while (queue.Count > 0)
{
var v = queue.Dequeue();
foreach (var side in sides)
{
var w = v + side;
if (w >= below - ProbeMargin)
continue;
var key = System.Math.Round(w, 6);
if (seen.TryGetValue(key, out var kept))
{
if (w > kept)
seen[key] = w;
continue;
}
if (seen.Count >= MaxStackHeights)
return null;
seen.Add(key, w);
queue.Enqueue(w);
}
}
return seen.Values.OrderBy(h => h).ToList();
}
private static IEnumerable<(double Along, double Across)> Sizes(BoxType type, double s, bool vertical) =>
type.Orientations.Select(o => vertical
? (o.Height + s, o.Width + s)
: (o.Width + s, o.Height + s));
}
+21 -1
View File
@@ -12,7 +12,7 @@ in `OpenNest.Engine/NestingEngines/<Name>/`, its tests in `OpenNest.Engine.Tests
| Engine | Best for | Method |
|---|---|---|
| Default | Any job; used when no engine is named | Runs Irregular, Rectangles and Circles, checks each layout with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost, then the largest compactness offcut; remaining ties keep the engine listed first here |
| Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost |
| Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; the sheet that finishes the job is also squeezed into a shorter or narrower bin; stock chosen sheet by sheet by salvage-credited look-ahead cost |
| Circles | Round parts: blanks, washers, flanges, gears by their tip circle | Each part packed as its minimum enclosing circle at centers tangent to two of walls, placed discs and clamp keep-outs, with hexagonal and square lattice seeds; bids only when every part is round; stock chosen as Rectangles does, plus whole-job runs preferring each stock and a tail re-plan |
| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan, then grouped variants that pack like parts together, then a sheet-reduction pass that packs edge parts first and turns a key part by its contacts; under a time limit it keeps trying seeded variants until the limit |
| FlatBar | Flat bar: parts about as wide as the bar | Each stock read as a bar and cut in one row: the CutList cut-list packer and a best-fill search on part lengths less their self-nesting gain, then a slide-left pass that nests shaped ends and refills freed length; stock chosen bar by bar by whole-plan cost |
@@ -56,6 +56,26 @@ Rectangles places irregular parts validly, but only as their bounding boxes; it
notch or hole. Box sides account for how the layout check flattens arcs, so round-edged parts stay
valid at box contact.
#### Squeezing the last sheet
The fit rules pack into the whole sheet, so when the last sheet has room to spare they spread the
remaining parts out and rarely find the shortest strip that holds them. On N26-2A5 (14 door stops
and 14 lock plates on a clamped 26 x 26 sheet) every rule stopped 8.0 high, while the hand layout
holds all 28 under 7.5 and leaves a half-inch-deeper drop. So when some rule places all the
remaining demand on a sheet, Rectangles packs that demand again into a bin cut shorter, and
separately narrower, running every rule and mode at each size. It keeps the tightest complete
layout along each axis that beats the whole-sheet ones, and the stock loop then picks among all of
them by cost and offcut as before (`SheetSqueeze`).
Whether a greedy packing fits is far from monotonic in the bin size (N26-2A5 packs at 7.5 but not
at 7.62 or 7.74), so the sizes probed are stack heights, not halves of a range: sums of the parts'
spaced sides along the axis, starting at the bin edge or at the far side of a clamp the layout
reaches. The probes walk down from the best extent so far, spread over the probes left, and a probe
that fails never raises the floor. With more than 2048 stack heights the walk steps through the
size range instead. Each probe repacks every rule and mode, so the squeeze is capped by a count of
box placements (at most 12 probes per axis and 50,000 placements in all): a sheet finishing more
than about 2,000 parts is not squeezed. Sheets that do not finish the job are never squeezed.
### Best plan so far
Whole-job engines report each better whole-job plan as `NestJobStage.PlanImproved` with the plan