[Upstream sync] K-Dense-AI/scientific-agent-skills (github) — 9 added, 27 modified #43
+273
@@ -0,0 +1,273 @@
|
||||
---
|
||||
title: "build123d 0.11.1 patterns"
|
||||
task: ""
|
||||
lineage_type: import
|
||||
upstream_source: https://github.com/K-Dense-AI/scientific-agent-skills/blob/b2a92ba0/skills/lab-hardware-cad/references/build123d-patterns.md
|
||||
upstream_sha: b2a92ba0
|
||||
imported_at: 2026-08-14
|
||||
prompt_class: unknown
|
||||
upstream_changes: accepted
|
||||
author: upstream
|
||||
validated: false
|
||||
---
|
||||
|
||||
# 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.1 # cage-system-30mm, clearance on 6.0 mm rods
|
||||
# --- 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.1},
|
||||
]
|
||||
|
||||
|
||||
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`.
|
||||
|
||||
## 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.
|
||||
|
||||
```python
|
||||
with BuildPart() as plate:
|
||||
Box(60.0, 60.0, 10.0)
|
||||
with Locations((20.0, 20.0)):
|
||||
CounterBoreHole(radius=6.6 / 2, counter_bore_radius=11.0 / 2, counter_bore_depth=4.0)
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
**Fillet last, and check the snapshot.** A radius larger than the adjacent feature silently
|
||||
consumes it, or throws a kernel error. Fillet radii should be named parameters so they are easy to
|
||||
back off.
|
||||
|
||||
## 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.
|
||||
profile = section(part, Plane.XY.offset(z_mm), mode=Mode.PRIVATE)
|
||||
exporter = ExportDXF(unit=Unit.MM)
|
||||
exporter.add_shape(profile)
|
||||
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.
|
||||
- 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 — <https://build123d.readthedocs.io/en/latest/>
|
||||
- Introductory examples (builder vs algebra, selectors, fillets) —
|
||||
<https://build123d.readthedocs.io/en/latest/introductory_examples.html>
|
||||
- Import/export reference — <https://build123d.readthedocs.io/en/latest/import_export.html>
|
||||
Reference in New Issue
Block a user