[Upstream sync] K-Dense-AI/scientific-agent-skills (github) — 9 added, 27 modified #43

Open
promptadmin wants to merge 36 commits from upstream-sync/scientific-agent-skills-20260814-b2a92b-psgv into main
Showing only changes of commit c774604c7f - Show all commits
@@ -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>