Files
OpenNest/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs
T
ajandClaude Opus 5.5 9273a44258 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>
2026-11-10 11:10:06 -05:00

163 lines
7.6 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.RectanglePacking;
using OpenNest.Geometry;
namespace OpenNest.Engine.NestingEngines.Rectangles;
/// <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. Higher priority numbers are always served first: a lower-number type is only placed
/// when no higher-number type still fits anywhere.
/// </summary>
internal static class SheetPacker
{
/// <summary>
/// How far a box may overhang the work area. Kept just inside the layout check's
/// <see cref="NestTolerances.WorkAreaSlack"/> so floating-point rounding cannot push an
/// accepted box over it.
/// </summary>
public const double OverhangAllowance = NestTolerances.WorkAreaSlack * 0.9;
/// <summary>
/// Packing size along one axis: a box that exceeds the sheet by no more than
/// <see cref="OverhangAllowance"/> packs as exactly the sheet's size. It then spans the whole
/// axis, so it has no neighbor there and the overhang lands only past the work-area edge.
/// </summary>
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,
double? maxBinWidth = null, double? maxBinHeight = null)
{
var work = stock.WorkArea;
var s = stock.PartSpacing;
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, binWidth), PackSize(o.Height + s, binHeight)))
.ToList(),
t.BoxArea))
.ToList();
var placed = MaxRectsPacker
.Pack(packTypes, remaining.ToArray(), sheet, rule, mode, token)
.Select(p => new Placed(types[p.Type], types[p.Type].Orientations[p.Size], p.X, p.Y))
.ToList();
var counts = new int[types.Count];
Box? envelope = null;
foreach (var p in placed)
{
counts[p.Type.Index]++;
var box = new Box(work.Left + p.Left, work.Bottom + p.Bottom, p.Orientation.Width, p.Orientation.Height);
envelope = envelope == null ? box : BoundingBox.Union(envelope, box);
}
// Summed per type in type order, the same form as the engine's demand area: plans placing
// the same parts get bit-identical areas, so the engine's material-area tie-break cannot
// be decided by placement-order rounding (which differs across platform math libraries).
var area = types.Sum(t => counts[t.Index] * t.MaterialArea);
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);
}
/// <summary>
/// Blocks every clamp keep-out into the sheet's free list before packing. In the
/// inflated-box model an item at position p covers material [p, p + size] plus a trailing
/// part-spacing tail; blocking bin-local [zone - clearance + s, zone + clearance] per axis
/// therefore means exactly: item material keeps at least the clamp clearance from
/// every clamp side, without paying any extra part-spacing tax there. When s exceeds the
/// inflated span the interval inverts (decision D11): block a zero-extent line at the
/// clamp edge facing the sheet interior — at (zone max + clearance) when the zone center
/// lies in the lower half of the bin on that axis, else at (zone min - clearance + s).
/// Parts on the interior side then keep the exact clearance; the unavoidable extra
/// exclusion (s - extent - 2*clearance) lands on the side toward the nearer sheet edge.
/// The old collapse to the zone centerline was NOT conservative: an item could start
/// 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, double binWidth, double binHeight)
{
if (stock.ClampZones.Count == 0)
return;
var clearance = stock.ClampClearance;
foreach (var zone in stock.ClampZones)
{
var b = zone.Bounds; // placement frame -> bin-local packing coordinates
double x0(double v) => v - work.Left;
double y0(double v) => v - work.Bottom;
var left = x0(b.Left) - clearance + s;
var right = x0(b.Right) + clearance;
if (right <= left)
{
var center = x0((b.Left + b.Right) * 0.5);
// D11: the interior-facing clamp edge (toward the farther sheet edge).
var line = center < binWidth * 0.5
? x0(b.Right) + clearance
: x0(b.Left) - clearance + s;
left = line;
right = line;
}
var bottom = y0(b.Bottom) - clearance + s;
var top = y0(b.Top) + clearance;
if (top <= bottom)
{
var center = y0((b.Bottom + b.Top) * 0.5);
var line = center < binHeight * 0.5
? y0(b.Top) + clearance
: y0(b.Bottom) - clearance + s;
bottom = line;
top = line;
}
sheet.Block(new Rect(left, bottom, right - left, top - bottom));
}
}
}