Skip to content

Packing a build plate

Packing.Pack lays several parts out on a printer’s build plate — build123d’s pack helper: 2D bin packing of each part’s silhouette footprint, so a part that overhangs its base still gets the room it needs. By default the packer is a deterministic shelf algorithm (parts sorted deepest-first, placed left-to-right into rows; no randomness, so the same parts always give the same plate) — simple and predictable rather than optimal. Quarter-turn rotation and nesting to the true outline are opt-in.

var bracketTop = SketchPlane.At((0, 0, 6), Vector3d.UnitX, Vector3d.UnitY);
var bracket = Shape.Extrude(Sketch.Rectangle(34, 18), 6)
.Drill(StandardHoles.Clearance(5),
LocationSet.At(new Vector2d(-12, 0), new Vector2d(12, 0)), 10, bracketTop);
var knob = Shape.Cylinder(9, 6).Translate(0, 0, 3)
.SmoothUnion(Shape.Sphere(6).Translate(0, 0, 8), 2);
var shim = Shape.Extrude(Sketch.RoundedRectangle(26, 12, 3), 2);
var parts = new Shape[] { bracket, bracket, knob, knob, shim };
var layout = Packing.Pack(parts, plateWidth: 90, plateDepth: 70, gap: 3);
var placed = layout.Apply(parts);
var scene = new Scene();
// The plate itself, drawn under the packed parts (display only - no boolean).
scene.Add(new Part("plate", Shape.Box(90, 70, 2).Translate(45, 35, -1), Palette.Slate));
for (int i = 0; i < placed.Count; i++)
scene.Add(new Part($"part {i}", placed[i]));

Five parts shelf-packed onto a build plate

Pack returns the layout (placements in input order, each with its offset and measured footprint); Apply — or the one-call Packing.Arrange — returns the translated shapes. Parts move in XY only: how each part sits in z is the model’s business, not the packer’s. A layout that does not fit refuses loudly, naming the first part that ran out of plate and its footprint, rather than stacking silently.

Rotation and outline nesting

PackOptions opens two doors, both off by default — a pack with neither is bit-for-bit the layout above:

OptionValuesWhat it changes
RotationNone (default), Quarter, FreeWhether the packer may turn a part about z.
NestingBoundingBox (default), OutlineWhether parts are kept apart by their boxes or by their true silhouettes.

An L bracket is the fixture where that matters: its outline fills only 57% of its own bounding box, and that missing 43% is exactly what a box packer throws away.

var bracket = Shape.Extrude(Sketch.Start(0, 0)
.LineTo(44, 0).LineTo(44, 14).LineTo(14, 14).LineTo(14, 38).LineTo(0, 38).Close(), 6);
var parts = Enumerable.Repeat(bracket, 6).ToArray();
var layout = Packing.Pack(parts, plateWidth: 96, plateDepth: 130, new PackOptions
{
Gap = 3,
Rotation = PackRotation.Quarter,
Nesting = PackNesting.Outline,
});
var placed = layout.Apply(parts);
var scene = new Scene();
scene.Add(new Part("plate", Shape.Box(96, 130, 2).Translate(48, 65, -1), Palette.Slate));
for (int i = 0; i < placed.Count; i++)
scene.Add(new Part($"part {i}", placed[i]));
var camera = new CameraState(-Math.PI / 2, 1.5, 180, (48, 65, 0));

Six L brackets nested and turned on a build plate

The brackets interlock, and three of the six are turned. Measured on those six parts, same plate and same 3 mm gap — UsedDepth is the plate strip consumed, Utilisation the packed outline area over it:

RotationNestingDepth usedUtilisation
NoneBoundingBox (the default)249.024%
NoneOutline154.239%
QuarterBoundingBox144.041%
QuarterOutline120.549%
var bracket = Shape.Extrude(Sketch.Start(0, 0)
.LineTo(44, 0).LineTo(44, 14).LineTo(14, 14).LineTo(14, 38).LineTo(0, 38).Close(), 6);
var parts = Enumerable.Repeat(bracket, 6).ToArray();
PackLayout Run(PackRotation rotation, PackNesting nesting) =>
Packing.Pack(parts, 96, 260, new PackOptions
{
Gap = 3,
Rotation = rotation,
Nesting = nesting,
});
var boxed = Run(PackRotation.Quarter, PackNesting.BoundingBox);
var nested = Run(PackRotation.Quarter, PackNesting.Outline);
// The outline fills well under two thirds of the box, which is what nesting has to win.
if (nested.PackedArea > 0.6 * nested.FootprintArea)
throw new Exception("fixture has no room to win");
// Both layouts hold the same parts, so only the plate they consume can differ.
if (Math.Abs(nested.PackedArea - boxed.PackedArea) > 1e-6)
throw new Exception("the two layouts do not carry the same parts");
if (nested.UsedDepth >= boxed.UsedDepth)
throw new Exception($"nesting used {nested.UsedDepth}, boxes {boxed.UsedDepth}");
Console.WriteLine($"boxes {boxed.UsedDepth:F1} deep at {boxed.Utilisation:P0}, " +
$"outlines {nested.UsedDepth:F1} at {nested.Utilisation:P0}");

How the two work

A quarter turn is exact — (x, y) → (−y, x) is a sign swap, never a cos — so a turned part’s measured bounds are its transposed footprint to the last bit. For BoundingBox nesting a quarter turn only transposes the footprint, so the four poses collapse to two and the choice is not a per-part one (a shelf is as deep as its deepest member): the packer runs the whole plate under both global preferences, landscape and portrait, and keeps the shallower one. Ties break on used width, then on the landscape preference.

Outline nesting grows each silhouette by half the gap and requires the grown outlines to be disjoint. That is the same statement as “these parts are at least gap apart”, reached through one existing operation — Region2dOffset’s dilation — rather than a new distance predicate, and the grown outlines are then searched bottom-left-first on a raster. Because a through hole is a hole in the silhouette, small parts nest inside a ring:

var ring = Shape.Extrude(Sketch.Circle(30).WithHole(Sketch.Circle(20)), 4);
var disc = Shape.Cylinder(7, 4);
var parts = new Shape[] { ring, disc, disc, disc };
var boxed = Packing.Pack(parts, 70, 200, new PackOptions { Gap = 2 });
var nested = Packing.Pack(parts, 70, 200, new PackOptions
{
Gap = 2,
Nesting = PackNesting.Outline,
});
// The three discs land inside the bore, so the plate strip is just the ring's own.
var centre = new Vector2d(
nested.Placements[0].Footprint.Center.X + nested.Placements[0].Offset.X,
nested.Placements[0].Footprint.Center.Y + nested.Placements[0].Offset.Y);
for (int i = 1; i < parts.Length; i++)
{
var here = new Vector2d(
nested.Placements[i].Footprint.Center.X + nested.Placements[i].Offset.X,
nested.Placements[i].Footprint.Center.Y + nested.Placements[i].Offset.Y);
// bore radius 20 - disc radius 7 - gap 2 = 11
if ((here - centre).Length > 11 + 1e-9)
throw new Exception($"disc {i} did not nest in the bore");
}
Console.WriteLine($"boxed {boxed.UsedDepth:F0} deep, nested {nested.UsedDepth:F0}");

What it costs, and what it refuses

The raster is conservative: a cell is marked occupied if the grown outline touches it at all, so an empty overlap proves the parts really are apart — a coarse grid can only refuse a legal placement, never accept an illegal one. Two consequences are worth planning for.

Placements are quantized to PackOptions.Resolution (default min(plateWidth, plateDepth) / 256, so the grid is a fixed size whatever the plate). A fit with no slack at all is therefore refused where the box packer takes it — four 40 × 10 bars turned upright span exactly 4 × 10 + 5 × 2 = 50 on a 50 mm plate, and outline nesting cannot land them. Use BoundingBox there, or a finer Resolution.

A finer raster is not monotonically better. It refuses fewer placements, but it also changes which placement the greedy search meets first: measured on the six brackets, cell sizes 4 / 2 / 1 / 0.5 / 0.25 give depths 120.0 / 106.0 / 112.0 / 109.5 / 108.2. Treat it as a cost/quality knob, not a convergence parameter.

Bottom-left-first only nests when the plate is tight. With room to spare, the lowest free spot is beside the previous part rather than inside its concavity, so a roomy plate reproduces row packing and outline nesting buys only its own quantization loss. The gains above are all on plates barely wider than the parts — which is when packing is worth doing at all.

PackRotation.Free is refused by name. A continuous orientation has no finite candidate set, so the packer could neither search it exhaustively nor break ties deterministically; it needs a no-fit polygon per part pair per angle, or an optimiser with a stated stopping rule. Quietly sampling a few angles would be a search that is not the one it claims to be.

Cost, win-x64, L brackets on an 86 × 400 plate at the default raster with quarter turns: 68 ms for 4 parts, 83 for 8, 233 for 16, 435 for 24 — the silhouette and its offset are once per part, the raster search is the rest.

Exporting the plate as one STL

The packed shapes merge into a single print file through the ordinary mesh path:

var parts = new Shape[]
{
Shape.Box(20, 12, 5),
Shape.Cylinder(7, 6),
Shape.Extrude(Sketch.RoundedRectangle(24, 10, 3), 3),
};
var placed = Packing.Arrange(parts, plateWidth: 80, plateDepth: 40, gap: 3);
var meshes = placed
.Select(shape => (shape.ToMesh(), Matrix4d.Identity))
.ToList();
string path = Path.Combine(Scratch, "plate.stl");
StlWriter.WriteFile(meshes, path);
var info = new FileInfo(path);
if (!info.Exists || info.Length < 84 + 50)
throw new Exception("expected a non-empty binary STL");
Console.WriteLine($"wrote {info.Length} bytes to plate.stl");

The gap is honored between parts and to the plate edges, and the footprints come from the mesh at the requested quality — the same measured-bounds caveat as Shape.Bounds (a curved extreme reads a chord’s sagitta small at coarse quality; the default quality plus a millimetre-scale gap has plenty of margin).