skills/lab-hardware-cad/references/build123d-patterns.md
An API cookbook for the geometry this skill actually needs. Every snippet here was run against build123d 0.11.1 on Python 3.12.
build123d offers two equivalent APIs.
# Builder mode: a context manager collects operations. mode= controls the boolean.
with BuildPart() as ex:
Box(80.0, 60.0, 10.0)
Cylinder(radius=11.0, height=10.0, mode=Mode.SUBTRACT)
part = ex.part
# Algebra mode: plain objects and operators.
part = Box(80.0, 60.0, 10.0) - Cylinder(radius=11.0, height=10.0)
Use builder mode for parts in this skill. Selectors (ex.edges(), ex.faces()) read naturally
from the builder, which is what you need for fillets and for placing features on found faces.
Algebra mode is a good fit for short, purely constructive shapes.
Do not mix the two styles inside one build().
gen.py imports the module, calls build(), and then reads interfaces(). Parameters must be
module-level so they can be overridden with --param.
"""One-line description of the part.
Process: SLA, tough resin. Orientation: bore axis vertical.
Interfaces:
- Rod bores: 30 mm cage system, Thorlabs ER series (cage-system-30mm).
"""
from build123d import *
# --- INTERFACE (fixed; do not tune) ---
rod_spacing_mm = 30.0 # cage-system-30mm
rod_bore_d_mm = 6.4 # rod_diameter 6.0 + 2 x 0.20 SLA free-sliding (fabrication-limits.md)
# --- DESIGN (free) ---
plate_t_mm = 8.9
aperture_d_mm = 25.4
def interfaces() -> list[dict]:
return [
{"feature": "cage rod bore spacing", "standard": "cage-system-30mm",
"dimension": "rod_spacing", "value": rod_spacing_mm, "intent": "match"},
{"feature": "cage rod bore diameter", "standard": "cage-system-30mm",
"dimension": "rod_diameter", "value": rod_bore_d_mm,
"intent": "envelope", "clearance": 0.4},
]
def build() -> Part:
half = rod_spacing_mm / 2
with BuildPart() as plate:
Box(rod_spacing_mm + 12.0, rod_spacing_mm + 12.0, plate_t_mm)
with Locations((half, half), (-half, half), (half, -half), (-half, -half)):
Hole(radius=rod_bore_d_mm / 2)
Hole(radius=aperture_d_mm / 2)
return plate.part
Most lab-hardware interfaces are internal features — a pocket, a bore, a slot — and none of
them appear in the part's outer bounding box. So check.py fit cannot find them by measuring the
STEP, and hand-copying the number into --value reintroduces exactly the transcription error the
skill exists to prevent. Declaring them closes the loop: gen.py records the declaration in the
manifest, and check.py interfaces verifies every entry.
Each entry needs standard, dimension, and value; feature, intent, and clearance are
optional:
| Key | Meaning |
|---|---|
standard | ID from check.py standards --list |
dimension | a dimension name inside that standard |
value | the number this model computed, in mm |
feature | human label for the check output (default: the dimension name) |
intent | match if this part must itself conform; envelope if the feature must accept any conforming part (default: match) |
clearance | total intended clearance in mm, both sides (default: 0) |
Write interfaces() as a function, and compute derived dimensions inside functions. A
module-level INTERFACES = [...] list is also accepted, but it is evaluated at import — before
--param is applied — so any value derived from an overridden parameter is recorded wrong. The same
applies to the geometry: derive inside build() or a helper, never at module level.
# Wrong: --param plate_tol_mm=0 silently leaves pocket_l_mm at the old value
pocket_l_mm = plate_l_mm + plate_tol_mm + 2 * pocket_clearance_mm
# Right: recomputed on every call, so overrides land
def pocket_l_mm() -> float:
return plate_l_mm + plate_tol_mm + 2 * pocket_clearance_mm
gen.py warns when it sees a static INTERFACES list together with --param.
interfaces() compares declared numbers against the standards database; it never touches the
solid. checks() is its measured counterpart: a list of go/no-go gauges evaluated by boolean
intersection against the part build() actually produced. gen.py runs them on every
generation and fails the build if one fails; check.py geometry re-runs them against an
exported STEP.
The principle: every geometric requirement in the request maps to one entry. Something must
pass through (a screw, a beam, a probe) → a clear region. Something must fit into a void (a
plate into a pocket) → a clear box the size of the mating part at maximum material condition.
Something must remain (a ridge, a ledge, a screw seat) → a material region. A stated size
limit → a bbox_* bound. These are exactly the errors is_valid, the bounding box, and a
declared-number check cannot see.
def checks() -> list[dict]:
top = plate_t_mm / 2
return [
# a clear region: no material may intrude (screw shafts, through the part)
{"feature": "M6 screws pass all four bores",
"clear": {"cylinder": 6.0, "axis": "z", "at": bolt_xy()}},
# a keep-out with an explicit span (a beam corridor along x at height z)
{"feature": "beam clear at 15 mm above the bench",
"clear": {"cylinder": 5.0, "axis": "x", "at": [(0.0, 15.0)]}},
# a gauge part that must drop into a pocket: the mating part at MMC
{"feature": "SLAS plate at MMC drops into the pocket",
"clear": {"box": (128.01, 85.73, pocket_depth_mm()),
"at": [(0.0, 0.0, floor_t_mm + pocket_depth_mm() / 2)]}},
# a counterbore that really is a counterbore: recess open, seat present.
# The second entry is what catches a recess that punched through.
{"feature": "counterbore recess open at the top",
"clear": {"cylinder": cbore_d_mm - 0.2, "axis": "z", "at": bolt_xy(),
"span": (top - cbore_depth_mm + 0.1, top + 0.1)}},
{"feature": "screw seat present below the recess",
"material": {"cylinder": cbore_d_mm - 0.2, "axis": "z", "at": bolt_xy(),
"span": (-top + 0.1, top - cbore_depth_mm - 0.1)},
"min_mm3": 50.0},
# a user-stated hard limit, measured from the solid
{"feature": "clears the objective turret", "bbox_z": {"max": 15.0}},
]
Semantics:
| Key | Meaning |
|---|---|
clear / material | region that must contain no material / must contain material |
{"cylinder": DIA, "axis": "x"|"y"|"z", "at": [(a, b), ...], "span": (lo, hi)} | at is 2D in the plane perpendicular to the axis — axis z: (x, y); axis x: (y, z); axis y: (x, z). Omit span to run through the whole part |
{"box": (dx, dy, dz), "at": [(x, y, z), ...]} | axis-aligned box gauges centred at each position |
tol_mm3 / min_mm3 | pass thresholds per position (both default 0.01) |
bbox_x…bbox_z, bbox_min/mid/max | {"min": mm, "max": mm} bounds on the measured bounding box |
Size the gauges from the same named constants as the geometry only when the requirement is relational (the recess sits above the seat). When the requirement is absolute — a mating part's MMC, a user's height limit, a beam position — write the gauge from the requirement's own numbers, so a wrong parameter cannot shrink the gauge to match the wrong geometry.
For a one-off question without editing the model, check.py probe runs a single gauge from the
command line, and check.py bores prints a census of every cylindrical face (diameter, axis,
position, span, sweep) to reconcile against the model's intent.
Locations places the objects created inside it. It is the workhorse for bolt patterns.
with Locations((10.0, 0.0), (-10.0, 0.0)): # two positions on the current plane
Hole(radius=3.3)
with Locations((0.0, 0.0, floor_t_mm)): # offset in z
Box(10.0, 10.0, 5.0, mode=Mode.SUBTRACT)
with GridLocations(9.0, 9.0, 12, 8): # x spacing, y spacing, x count, y count
Hole(radius=1.5)
GridLocations centres the grid on the origin. A microplate well grid is dimensioned from the
plate corner instead, so compute absolute positions and pass them to Locations:
a1_x_mm, a1_y_mm, pitch_mm = 14.38, 11.24, 9.0 # slas-well-positions-96
origin_x = -plate_l_mm / 2
origin_y = plate_w_mm / 2
wells = [
(origin_x + a1_x_mm + pitch_mm * col, origin_y - a1_y_mm - pitch_mm * row)
for row in range(8) for col in range(12)
]
with Locations(*wells):
Hole(radius=well_clear_d_mm / 2)
By default objects are centred on the origin. align moves the datum, which is usually what you
want for a pocket that starts at a floor:
Box(x, y, z, align=(Align.CENTER, Align.CENTER, Align.MIN)) # sits on z = 0
Box(x, y, z, align=(Align.MIN, Align.MIN, Align.MIN)) # corner at the origin
Getting this wrong is the classic "pocket cut through the floor" bug, and it is exactly what the snapshot catches.
Hole cuts through the whole part; CounterBoreHole and CounterSinkHole add a head recess.
CounterBoreHole cuts downward from the workplane it is placed on, with the recess at that
plane. On a centred Box the default workplane is the mid-height of the part, so a 2-tuple
location buries the screw seat inside the plate — or, on a thin plate, lets the recess swallow the
top entirely, leaving a straight bore the screw head falls through. Place it on the top face
(or give the location an explicit z at the top):
with BuildPart() as plate:
Box(60.0, 60.0, 10.0) # spans z = -5 .. +5
top = plate.faces().sort_by(Axis.Z)[-1]
with Locations(top):
with Locations((20.0, 20.0)):
CounterBoreHole(radius=6.6 / 2, counter_bore_radius=11.0 / 2,
counter_bore_depth=6.5)
Size counter_bore_depth from the screw head height, not from habit: an M6 socket head cap
screw head is 6.0 mm tall, a 1/4-20 head 6.35 mm (screw_head_height in the breadboard
standards). A 4 mm counterbore leaves either head 2 mm proud — do not call that flush. After
generating, confirm in the snapshot (or a section) that the recess is at the top face and the
seat ledge exists; both failure modes here pass is_valid and the bounding box untouched.
Remember that printed holes come out undersize — see references/fabrication-limits.md.
Selectors find edges and faces to fillet, chamfer, or build on. The three you need:
part.edges().filter_by(Axis.Z) # keep edges parallel to Z (the vertical corners)
part.edges().group_by(Axis.Z)[-1] # the group with the highest Z (the top edges)
part.faces().sort_by(Axis.Z)[-1] # the single highest face
part.edges().filter_by(GeomType.CIRCLE) # only circular edges
filter_by keeps everything matching. group_by partitions into lists ordered by the key, so
[-1] is the last group and [0] the first. sort_by orders individual items.
with BuildPart() as ex:
Box(80.0, 60.0, 10.0)
chamfer(ex.edges().group_by(Axis.Z)[-1], length=4.0) # chamfer the top face edges
fillet(ex.edges().filter_by(Axis.Z), radius=5.0) # round the vertical corners
These broad selectors are only safe on a part that is still a plain box. Once the part has
pockets, bores, notches, or micro-relief, filter_by(Axis.Z) and group_by(Axis.Z)[-1] also
select the edges of those features, and the fillet either throws a kernel error
(Failed creating a fillet, BRep_API: command not done) or — worse — succeeds and silently eats
a wall or a 0.3 mm ridge. Both happen in practice. So:
GeomType) so only the intended edges remain.part.max_fillet(edges) when the nearby geometry is tight — it returns
the largest radius the kernel can actually build on that edge set.is_valid.For a profile that is not a primitive, sketch it and extrude:
with BuildPart() as bracket:
with BuildSketch() as profile:
Rectangle(40.0, 20.0)
with Locations((15.0, 0.0)):
Circle(radius=4.0, mode=Mode.SUBTRACT)
extrude(amount=6.0)
This is also the route to a laser-cut DXF: the sketch is the cut profile.
gen.py handles these, but for reference:
export_step(part, "part.step", unit=Unit.MM) # authoritative
export_stl(part, "part.stl", tolerance=1e-3, angular_tolerance=0.1)
# 2D profile for laser cutting. section() is a module-level operation, NOT a
# method on the shape -- part.section(...) raises AttributeError.
from build123d.exporters import ColorIndex # NOT exported by `from build123d import *`
profile = section(part, Plane.XY.offset(z_mm), mode=Mode.PRIVATE)
profile = profile.moved(Location((0, 0, -z_mm))) # back to z = 0, or the DXF writer
# warns about a non-planar shape
exporter = ExportDXF(unit=Unit.MM)
exporter.add_layer("CUT", color=ColorIndex.RED) # laser shops key power/speed to layers
exporter.add_shape(profile, layer="CUT")
exporter.write("part.dxf")
Cut the section through material, not at z = 0: a part modelled sitting on the build plate has
only a degenerate face there. gen.py --dxf defaults to the part's mid-height and takes --dxf-z
to override.
STEP preserves exact BREP geometry; STL is a triangulated approximation. Always keep STEP as the source of truth and regenerate meshes from it, never the reverse.
Useful for asserting an interface inside the model itself:
bbox = part.bounding_box()
print(bbox.size.X, bbox.size.Y, bbox.size.Z)
print(part.volume, part.area)
print(part.is_valid) # a property in 0.11.1, not a method
print(part.center(CenterOf.MASS))
is_valid being a property rather than a method is a real difference from older releases and from
some documentation. Access it without parentheses.
is_valid is a property. part.is_valid() raises TypeError: 'bool' object is not callable.section() is a module-level operation, not a method. part.section(Plane.XY) raises
AttributeError. Call section(part, plane, mode=Mode.PRIVATE).intersect() returns a ShapeList with no .volume; the & operator returns a Solid that
has one. check.py clearance handles both.inspect.py in a directory that lands on sys.path. It shadows the
standard library inspect module, which breaks typing_extensions and therefore build123d
itself. This is why the bundled script is check.py.builder.part, not the builder.Mode.SUBTRACT needs an existing body. Subtracting from an empty context does nothing
silently.Rectangle(w, h) along a path on a surface leaves half the profile below the surface — a
"0.3 mm ridge" that is really 0.15 mm proud. Pass align= (and an explicit x_dir on the
profile plane) so the profile sits where you think it does, then measure the result.Curve has no .length. Sum the edges instead: sum(e.length for e in curve.edges()).None, an empty Compound, or a ShapeList with no .volume — guard before reading
.volume in any interference check.ColorIndex and LineType live in build123d.exporters, not in the top-level namespace;
from build123d import * does not bring them in, and add_layer(color=1) fails.