Skill Tracer
Skills › Scientific Agent Skills › lab-hardware-cad › build123d-patterns.md

build123d-patterns.md

381 lines · skills/lab-hardware-cad/references/build123d-patterns.md at 5f2bc03

Open on GitHub
1# build123d 0.13.0 patterns
2
3An API cookbook for the geometry this skill needs, reviewed against build123d 0.13.0
4on Python 3.12 with OCCT 8.0.1. The complete carrier, cage model, exports, selectors,
5counterbore and native gauges are exercised in the repository suite. Snippets with
6undefined surrounding variables are illustrative fragments to adapt to the model.
7

build123d 0.13.0 patterns

An API cookbook for the geometry this skill needs, reviewed against build123d 0.13.0 on Python 3.12 with OCCT 8.0.1. The complete carrier, cage model, exports, selectors, counterbore and native gauges are exercised in the repository suite. Snippets with undefined surrounding variables are illustrative fragments to adapt to the model.

Raw lines 1 to 7
8## Builder mode or algebra mode
9
10build123d offers two equivalent APIs.
12```python
13# Builder mode: a context manager collects operations. mode= controls the boolean.
14with BuildPart() as ex:
15 Box(80.0, 60.0, 10.0)
16 Cylinder(radius=11.0, height=10.0, mode=Mode.SUBTRACT)
17part = ex.part
19# Algebra mode: plain objects and operators.
20part = Box(80.0, 60.0, 10.0) - Cylinder(radius=11.0, height=10.0)
21```
23**Use builder mode for parts in this skill.** Selectors (`ex.edges()`, `ex.faces()`) read naturally
24from the builder, which is what you need for fillets and for placing features on found faces.
25Algebra mode is a good fit for short, purely constructive shapes.
27Do not mix the two styles inside one `build()`.

Builder mode or algebra mode

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().

Raw lines 8 to 28
29## The model file contract
31`gen.py` imports the module, calls `build()`, and then reads `interfaces()`. Parameters must be
32module-level so they can be overridden with `--param`.
34```python
35"""One-line description of the part.
37Process: SLA, tough resin. Orientation: bore axis vertical.
38Interfaces:
39 - Rod bores: 30 mm cage system, Thorlabs ER series (cage-system-30mm).
40"""
41from build123d import *
43# --- INTERFACE (fixed; do not tune) ---
44rod_spacing_mm = 30.0 # cage-system-30mm
45rod_bore_d_mm = 6.4 # rod_diameter 6.0 + 2 x 0.20 SLA free-sliding (fabrication-limits.md)
46# --- DESIGN (free) ---
47plate_t_mm = 8.9
48aperture_d_mm = 25.4
51def interfaces() -> list[dict]:
52 return [
53 {"feature": "cage rod bore spacing", "standard": "cage-system-30mm",
54 "dimension": "rod_spacing", "value": rod_spacing_mm, "intent": "match"},
55 {"feature": "cage rod bore diameter", "standard": "cage-system-30mm",
56 "dimension": "rod_diameter", "value": rod_bore_d_mm,
57 "intent": "envelope", "clearance": 0.4},
58 ]
61def build() -> Part:
62 half = rod_spacing_mm / 2
63 with BuildPart() as plate:
64 Box(rod_spacing_mm + 12.0, rod_spacing_mm + 12.0, plate_t_mm)
65 with Locations((half, half), (-half, half), (half, -half), (-half, -half)):
66 Hole(radius=rod_bore_d_mm / 2)
67 Hole(radius=aperture_d_mm / 2)
68 return plate.part
69```

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.

"""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
Raw lines 29 to 70
71## Declaring interfaces
73Most lab-hardware interfaces are **internal features** — a pocket, a bore, a slot — and none of
74them appear in the part's outer bounding box. So `check.py fit` cannot find them by measuring the
75STEP, and hand-copying the number into `--value` reintroduces exactly the transcription error the
76skill exists to prevent. Declaring them closes the loop: `gen.py` records the declaration in the
77manifest, and `check.py interfaces` verifies every entry.
79Each entry needs `standard`, `dimension`, and `value`; `feature`, `intent`, and `clearance` are
80optional:
82| Key | Meaning |
83| --- | --- |
84| `standard` | ID from `check.py standards --list` |
85| `dimension` | a dimension name inside that standard |
86| `value` | the number **this model computed**, in mm |
87| `feature` | human label for the check output (default: the dimension name) |
88| `intent` | `match` if this part must itself conform; `envelope` if the feature must accept any conforming part (default: `match`) |
89| `clearance` | total intended clearance in mm, both sides (default: 0) |
91**Write `interfaces()` as a function, and compute derived dimensions inside functions.** A
92module-level `INTERFACES = [...]` list is also accepted, but it is evaluated at import — before
93`--param` is applied — so any value derived from an overridden parameter is recorded wrong. The same
94applies to the geometry: derive inside `build()` or a helper, never at module level.
96```python
97# Wrong: --param plate_tol_mm=0 silently leaves pocket_l_mm at the old value
98pocket_l_mm = plate_l_mm + plate_tol_mm + 2 * pocket_clearance_mm
100# Right: recomputed on every call, so overrides land
101def pocket_l_mm() -> float:
102 return plate_l_mm + plate_tol_mm + 2 * pocket_clearance_mm
103```
105`gen.py` warns when it sees a static `INTERFACES` list together with `--param`.

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:

KeyMeaning
standardID from check.py standards --list
dimensiona dimension name inside that standard
valuethe number this model computed, in mm
featurehuman label for the check output (default: the dimension name)
intentmatch if this part must itself conform; envelope if the feature must accept any conforming part (default: match)
clearancetotal 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.

Raw lines 71 to 106
107## Declaring geometry checks
109`interfaces()` compares declared numbers against the standards database; it never touches the
110solid. `checks()` is its measured counterpart: a list of **go/no-go gauges** evaluated by boolean
111intersection against the part `build()` actually produced. `gen.py` runs them on every
112generation and fails the build if one fails; `check.py geometry` re-runs them against an
113exported STEP.
115The principle: **every geometric requirement in the request maps to one entry.** Something must
116pass through (a screw, a beam, a probe) → a `clear` region. Something must fit into a void (a
117plate into a pocket) → a `clear` box the size of the mating part at maximum material condition.
118Something must remain (a ridge, a ledge, a screw seat) → a `material` region. A stated size
119limit → a `bbox_*` bound. These are exactly the errors `is_valid`, the bounding box, and a
120declared-number check cannot see.
122```python
123def checks() -> list[dict]:
124 top = plate_t_mm / 2
125 return [
126 # a clear region: no material may intrude (screw shafts, through the part)
127 {"feature": "M6 screws pass all four bores",
128 "clear": {"cylinder": 6.0, "axis": "z", "at": bolt_xy()}},
129 # a keep-out with an explicit span (a beam corridor along x at height z)
130 {"feature": "beam clear at 15 mm above the bench",
131 "clear": {"cylinder": 5.0, "axis": "x", "at": [(0.0, 15.0)]}},
132 # a gauge part that must drop into a pocket: the mating part at MMC
133 {"feature": "SLAS plate at MMC drops into the pocket",
134 "clear": {"box": (128.26, 85.98, pocket_depth_mm()),
135 "at": [(0.0, 0.0, floor_t_mm + pocket_depth_mm() / 2)]}},
136 # a counterbore that really is a counterbore: recess open, seat present.
137 # The second entry is what catches a recess that punched through.
138 {"feature": "counterbore recess open at the top",
139 "clear": {"cylinder": cbore_d_mm - 0.2, "axis": "z", "at": bolt_xy(),
140 "span": (top - cbore_depth_mm + 0.1, top + 0.1)}},
141 {"feature": "screw seat present below the recess",
142 "material": {"cylinder": cbore_d_mm - 0.2, "axis": "z", "at": bolt_xy(),
143 "span": (-top + 0.1, top - cbore_depth_mm - 0.1)},
144 "min_mm3": 50.0},
145 # a user-stated hard limit, measured from the solid
146 {"feature": "clears the objective turret", "bbox_z": {"max": 15.0}},
147 ]
148```
150Semantics:
152| Key | Meaning |
153| --- | --- |
154| `clear` / `material` | region that must contain no material / must contain material |
155| `{"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 |
156| `{"box": (dx, dy, dz), "at": [(x, y, z), ...]}` | axis-aligned box gauges centred at each position |
157| `tol_mm3` / `min_mm3` | pass thresholds per position (both default 0.01); choose from the feature volume, not blindly |
158| `bbox_x`…`bbox_z`, `bbox_min/mid/max` | `{"min": mm, "max": mm}` bounds on the measured bounding box |
160A `material` gauge asserts only that its overlap exceeds `min_mm3`, not that the
161whole region is filled. Set a requirement-based minimum; 0.01 mm³ alone does not
162prove a full ridge or screw seat. Empty positions and non-finite inputs are rejected.
164Size the gauges from the same named constants as the geometry **only when the requirement is
165relational** (the recess sits above the seat). When the requirement is absolute — a mating part's
166MMC, a user's height limit, a beam position — write the gauge from the requirement's own numbers,
167so a wrong parameter cannot shrink the gauge to match the wrong geometry.
169For a one-off question without editing the model, `check.py probe` runs a single gauge from the
170command line, and `check.py bores` prints a census of every cylindrical face (diameter, axis,
171position, span, sweep) to reconcile against the model's intent.

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.

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.26, 85.98, 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:

KeyMeaning
clear / materialregion 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_mm3pass thresholds per position (both default 0.01); choose from the feature volume, not blindly
bbox_x…bbox_z, bbox_min/mid/max{"min": mm, "max": mm} bounds on the measured bounding box

A material gauge asserts only that its overlap exceeds min_mm3, not that the whole region is filled. Set a requirement-based minimum; 0.01 mm³ alone does not prove a full ridge or screw seat. Empty positions and non-finite inputs are rejected.

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.

Raw lines 107 to 172
173## Positioning
175`Locations` places the objects created inside it. It is the workhorse for bolt patterns.
177```python
178with Locations((10.0, 0.0), (-10.0, 0.0)): # two positions on the current plane
179 Hole(radius=3.3)
181with Locations((0.0, 0.0, floor_t_mm)): # offset in z
182 Box(10.0, 10.0, 5.0, mode=Mode.SUBTRACT)
184with GridLocations(9.0, 9.0, 12, 8): # x spacing, y spacing, x count, y count
185 Hole(radius=1.5)
186```
188`GridLocations` centres the grid on the origin. A microplate well grid is dimensioned from the
189plate corner instead, so compute absolute positions and pass them to `Locations`:
191```python
192a1_x_mm, a1_y_mm, pitch_mm = 14.38, 11.24, 9.0 # slas-well-positions-96
193origin_x = -plate_l_mm / 2
194origin_y = plate_w_mm / 2
195wells = [
196 (origin_x + a1_x_mm + pitch_mm * col, origin_y - a1_y_mm - pitch_mm * row)
197 for row in range(8) for col in range(12)
199with Locations(*wells):
200 Hole(radius=well_clear_d_mm / 2)
201```

Positioning

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)
Raw lines 173 to 202
203## Alignment
205By default objects are centred on the origin. `align` moves the datum, which is usually what you
206want for a pocket that starts at a floor:
208```python
209Box(x, y, z, align=(Align.CENTER, Align.CENTER, Align.MIN)) # sits on z = 0
210Box(x, y, z, align=(Align.MIN, Align.MIN, Align.MIN)) # corner at the origin
211```
213Getting this wrong is the classic "pocket cut through the floor" bug, and it is exactly what the
214snapshot catches.

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:

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.

Raw lines 203 to 215
216## Holes
218`Hole` cuts through the whole part; `CounterBoreHole` and `CounterSinkHole` add a head recess.
220**`CounterBoreHole` cuts downward from the workplane it is placed on, with the recess at that
221plane.** On a centred `Box` the default workplane is the mid-height of the part, so a 2-tuple
222location buries the screw seat inside the plate — or, on a thin plate, lets the recess swallow the
223top entirely, leaving a straight bore the screw head falls through. Place it on the **top face**
224(or give the location an explicit z at the top):
226```python
227with BuildPart() as plate:
228 Box(60.0, 60.0, 10.0) # spans z = -5 .. +5
229 top = plate.faces().sort_by(Axis.Z)[-1]
230 with Locations(top):
231 with Locations((20.0, 20.0)):
232 CounterBoreHole(radius=6.6 / 2, counter_bore_radius=11.0 / 2,
233 counter_bore_depth=6.5)
234```
236Size `counter_bore_depth` from the **screw head height**, not from habit: an M6 socket head cap
237screw head is 6.0 mm tall, a 1/4-20 head 6.35 mm (`screw_head_height` in the breadboard
238standards). A 4 mm counterbore leaves either head 2 mm proud — do not call that flush. After
239generating, confirm in the snapshot (or a section) that the recess is at the top face and the
240seat ledge exists; both failure modes here pass `is_valid` and the bounding box untouched.
242Remember that printed holes come out undersize — see `references/fabrication-limits.md`.

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):

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.

Raw lines 216 to 243
244## Selectors
246Selectors find edges and faces to fillet, chamfer, or build on. The three you need:
248```python
249part.edges().filter_by(Axis.Z) # keep edges parallel to Z (the vertical corners)
250part.edges().group_by(Axis.Z)[-1] # the group with the highest Z (the top edges)
251part.faces().sort_by(Axis.Z)[-1] # the single highest face
252part.edges().filter_by(GeomType.CIRCLE) # only circular edges
253```
255`filter_by` keeps everything matching. `group_by` partitions into lists ordered by the key, so
256`[-1]` is the last group and `[0]` the first. `sort_by` orders individual items.
258```python
259with BuildPart() as ex:
260 Box(80.0, 60.0, 10.0)
261 chamfer(ex.edges().group_by(Axis.Z)[-1], length=4.0) # chamfer the top face edges
262 fillet(ex.edges().filter_by(Axis.Z), radius=5.0) # round the vertical corners
263```
265**These broad selectors are only safe on a part that is still a plain box.** Once the part has
266pockets, bores, notches, or micro-relief, `filter_by(Axis.Z)` and `group_by(Axis.Z)[-1]` also
267select the edges of those features, and the fillet either throws a kernel error
268(`Failed creating a fillet`, `BRep_API: command not done`) or — worse — succeeds and silently eats
269a wall or a 0.3 mm ridge. Both happen in practice. So:
271- Fillet or chamfer the **outer body before adding internal features**, or filter the selection
272 down deliberately (by position, length, or `GeomType`) so only the intended edges remain.
273- Bound the radius with `part.max_fillet(edges)` when the nearby geometry is tight — it returns
274 the largest radius the kernel can actually build on that edge set.
275- Make every fillet/chamfer radius a named parameter, and on a kernel failure back the value off
276 rather than fighting the selector.
277- Then check the snapshot: a consumed feature is obvious in the picture and invisible in
278 `is_valid`.

Selectors

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:

  • 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.
Raw lines 244 to 279
280## Sketch then extrude
282For a profile that is not a primitive, sketch it and extrude:
284```python
285with BuildPart() as bracket:
286 with BuildSketch() as profile:
287 Rectangle(40.0, 20.0)
288 with Locations((15.0, 0.0)):
289 Circle(radius=4.0, mode=Mode.SUBTRACT)
290 extrude(amount=6.0)
291```
293This is also the route to a laser-cut DXF: the sketch is the cut profile.

Sketch then extrude

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.

Raw lines 280 to 294
295## Exports
297`gen.py` handles these, but for reference:
299```python
300export_step(part, "part.step", unit=Unit.MM) # authoritative
301export_stl(part, "part.stl", tolerance=1e-3, angular_tolerance=0.1)
303# 2D profile for laser cutting. section() is a module-level operation, NOT a
304# method on the shape -- part.section(...) raises AttributeError.
305from build123d.exporters import ColorIndex # NOT exported by `from build123d import *`
307profile = section(part, Plane.XY.offset(z_mm), mode=Mode.PRIVATE)
308profile = profile.moved(Location((0, 0, -z_mm))) # back to z = 0, or the DXF writer
309 # warns about a non-planar shape
310exporter = ExportDXF(unit=Unit.MM)
311exporter.add_layer("CUT", color=ColorIndex.RED) # laser shops key power/speed to layers
312exporter.add_shape(profile, layer="CUT")
313exporter.write("part.dxf")
314```
316Prefer a section strictly inside the material. A section coincident with an outer face
317can be ambiguous or omit features; do not assume `z = 0` intersects the desired profile. `gen.py --dxf` defaults to the part's mid-height and takes `--dxf-z`
318to override.
320STEP preserves exact BREP geometry; STL is a triangulated approximation. **Always keep STEP as the
321source of truth** and regenerate meshes from it, never the reverse.
323The STL exporter's `angular_tolerance` is in **radians** (0.1 ≈ 5.7°), although
324most modeling rotations use degrees. STL carries no unit metadata; this skill
325exports/imports its mesh coordinates as mm. `import_stl` returns a triangulated
326Face in 0.13.0, not a watertight BREP solid: use STEP for volume, gauges and fit.

Exports

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")

Prefer a section strictly inside the material. A section coincident with an outer face can be ambiguous or omit features; do not assume z = 0 intersects the desired profile. 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.

The STL exporter's angular_tolerance is in radians (0.1 ≈ 5.7°), although most modeling rotations use degrees. STL carries no unit metadata; this skill exports/imports its mesh coordinates as mm. import_stl returns a triangulated Face in 0.13.0, not a watertight BREP solid: use STEP for volume, gauges and fit.

Raw lines 295 to 327
328## Measuring in code
330Useful for asserting an interface inside the model itself:
332```python
333bbox = part.bounding_box()
334print(bbox.size.X, bbox.size.Y, bbox.size.Z)
335print(part.volume, part.area)
336print(part.is_valid) # a property in 0.13.0, not a method
337print(part.center(CenterOf.MASS))
338```
340`is_valid` being a property rather than a method is a real difference from older releases and from
341some documentation. Access it without parentheses.

Measuring in code

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.13.0, 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.

Raw lines 328 to 342
343## Things that bite
345- **`is_valid` is a property.** `part.is_valid()` raises `TypeError: 'bool' object is not callable`.
346- **`section()` is a module-level operation, not a method.** `part.section(Plane.XY)` raises
347 `AttributeError`. Call `section(part, plane, mode=Mode.PRIVATE)`.
348- **`intersect()` returns a `ShapeList`** with no `.volume`; the `&` operator returns a shape/part that
349 has one. `check.py clearance` handles both.
350- **Never name a script `inspect.py`** in a directory that lands on `sys.path`. It shadows the
351 standard library `inspect` module, which breaks `typing_extensions` and therefore build123d
352 itself. This is why the bundled script is `check.py`.
353- **Builder objects are not parts.** Return `builder.part`, not the builder.
354- **`Mode.SUBTRACT` needs an existing body.** Subtracting from an empty context does nothing
355 silently.
356- **A swept or extruded profile is centred on its path/plane unless you align it.** Sweeping a
357 `Rectangle(w, h)` along a path on a surface leaves half the profile below the surface — a
358 "0.3 mm ridge" that is really 0.15 mm proud. Pass `align=` (and an explicit `x_dir` on the
359 profile plane) so the profile sits where you think it does, then measure the result.
360- **`Curve` has no `.length`.** Sum the edges instead: `sum(e.length for e in curve.edges())`.
361- **The boolean of touching or disjoint solids is empty, not an error.** Depending on the path you
362 get `None`, an empty `Compound`, or a `ShapeList` with no `.volume` — guard before reading
363 `.volume` in any interference check.
364- **`ColorIndex` and `LineType` live in `build123d.exporters`**, not in the top-level namespace;
365 `from build123d import *` does not bring them in, and `add_layer(color=1)` fails.
366- The OpenCascade kernel raises assorted exception types. Catch broadly around boolean operations
367 and report the failure rather than letting a traceback escape.

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 shape/part 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.
Raw lines 343 to 368
369## Sources
371- build123d documentation — <https://build123d.readthedocs.io/en/stable/>
372- Introductory examples (builder vs algebra, selectors, fillets) —
373 <https://build123d.readthedocs.io/en/stable/introductory_examples.html>
374- Import/export reference — <https://build123d.readthedocs.io/en/stable/import_export.html>
376- OpenCascade cylindrical surface parameter bounds — <https://occt3d.com/dev/doc/refman/html/class_b_rep_adaptor___surface.html>
377- build123d 0.13.0 release — <https://github.com/gumyr/build123d/releases/tag/v0.13.0>
379`bores` reports cylindrical UV bounds. For irregularly trimmed faces the parameter
380rectangle may contain gaps; a 360° bound does not prove an uninterrupted bore or
381distinguish a bore from a boss. Use sections or clear gauges for that claim.

Sources

  • build123d documentation — <https://build123d.readthedocs.io/en/stable/>
  • Introductory examples (builder vs algebra, selectors, fillets) — <https://build123d.readthedocs.io/en/stable/introductory_examples.html>
  • Import/export reference — <https://build123d.readthedocs.io/en/stable/import_export.html>
  • OpenCascade cylindrical surface parameter bounds — <https://occt3d.com/dev/doc/refman/html/class_b_rep_adaptor___surface.html>
  • build123d 0.13.0 release — <https://github.com/gumyr/build123d/releases/tag/v0.13.0>

bores reports cylindrical UV bounds. For irregularly trimmed faces the parameter rectangle may contain gaps; a 360° bound does not prove an uninterrupted bore or distinguish a bore from a boss. Use sections or clear gauges for that claim.

Raw lines 369 to 381