# build123d 0.11.1 patterns
An API cookbook for the geometry this skill actually needs. Every snippet here was run against
build123d 0.11.1 on Python 3.12.
## Builder mode or algebra mode
build123d offers two equivalent APIs.
```python
# 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()`.
## The model file contract
`gen.py` imports the module, calls `build()`, and then reads `interfaces()`. Parameters must be
module-level so they can be overridden with `--param`.
```python
"""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
```
## Declaring interfaces
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.
```python
# 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`.
## Declaring geometry checks
`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.
```python
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.
## Positioning
`Locations` places the objects created inside it. It is the workhorse for bolt patterns.
```python
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`:
```python
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)
```
## Alignment
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:
```python
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.
## Holes
`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):
```python
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
Selectors find edges and faces to fillet, chamfer, or build on. The three you need:
```python
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.
```python
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:
- Fillet or chamfer the **outer body before adding internal features**, or filter the selection
down deliberately (by position, length, or `GeomType`) so only the intended edges remain.
- Bound the radius with `part.max_fillet(edges)` when the nearby geometry is tight — it returns
the largest radius the kernel can actually build on that edge set.
- Make every fillet/chamfer radius a named parameter, and on a kernel failure back the value off
rather than fighting the selector.
- Then check the snapshot: a consumed feature is obvious in the picture and invisible in
`is_valid`.
## Sketch then extrude
For a profile that is not a primitive, sketch it and extrude:
```python
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.
## Exports
`gen.py` handles these, but for reference:
```python
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.
## Measuring in code
Useful for asserting an interface inside the model itself:
```python
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.
## Things that bite
- **`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.
- **Never name a script `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 objects are not parts.** Return `builder.part`, not the builder.
- **`Mode.SUBTRACT` needs an existing body.** Subtracting from an empty context does nothing
silently.
- **A swept or extruded profile is centred on its path/plane unless you align it.** Sweeping a
`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())`.
- **The boolean of touching or disjoint solids is empty, not an error.** Depending on the path you
get `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.
- The OpenCascade kernel raises assorted exception types. Catch broadly around boolean operations
and report the failure rather than letting a traceback escape.
## Sources
- build123d documentation —
- Introductory examples (builder vs algebra, selectors, fillets) —
- Import/export reference —