---
name: cad-3d-print
version: 3.0.0
description: Parametric 3D modeling with trimesh for FDM printing. Generates STL with PNG preview and mesh validation. Headless, pure Python — uses direct mesh construction for reliable chamfers and fillets.
author: custom
tags: [3d-printing, cad, trimesh, stl, bambu, parametric]
---
# CAD 3D Print Skill v3
## Purpose
Design printable 3D parts with trimesh, export STL, auto-generate PNG preview, validate mesh. Optimized for Bambu Lab A1 Mini (180×180×180 mm, 0.4 mm nozzle).
## stack (headless on ZimaOS)
- trimesh — primitives, I/O, transformations, direct mesh construction
- manifold3d — boolean engine for simple unions/differences only
- matplotlib — headless PNG preview (Agg backend)
### Why not CadQuery?
CadQuery erfordert `libGL.so.1` (OpenCASCADE/OCP dependency) → **läuft nicht** auf headless ZimaOS wo Mesa fehlt. Install ist nicht möglich (kein apt/dnf/apk). Daher: **nur** trimesh.
### Why direct mesh construction for chamfers?
Mesh-booleans on open shells (hollow boxes) fail when subtracting co-planar or near-co-planar faces. The manifold engine cannot reliably determine inside/outside for open geometries. **Solution:** Build vertices and faces directly — deterministic, zero boolean failures.

**Nicht verwechseln:** Ein größerer offset beim oberen Teil (z.B. bottom shell with wall=2.0 + top shell with wall=1.0) erzeugt nur eine **gestufte Öffnung** — keinen Chamfer. Der Volumenverlust ist dann ~0 cm³. Ein echter Chamfer braucht schräge Seitenflächen.
## When to Use
Use whenever the user asks to design, model, modify, iterate, or export a 3D part for printing.
## Workflow (New Design)
1. Understand part: dimensions, function, intended use (indoor/outdoor/flex)
2. Write Python with ALL parameters at top, then helpers, then MODEL block
3. Save to /DATA/AppData/hermes/Projekte/3d/<name>.py
4. Run: terminal("cd /DATA/AppData/hermes/Projekte/3d && python <name>.py")
5. Open the PNG — use vision skill to self-verify if unsure
6. Report paths, dimensions, preview
## Workflow (Iteration)
When user asks to modify an existing design ("make wall thicker", "5mm wider"):
1. Read existing .py from /DATA/AppData/hermes/Projekte/3d/
2. Change ONLY the PARAMETERS block — DO NOT rewrite geometry from scratch
3. Save same filename, re-run
4. Show new preview
## Output Directory
/DATA/AppData/hermes/Projekte/3d/
Each model produces: <name>.py, <name>.stl, <name>.png
## Standard Code Template
```python
import trimesh
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
import os
OUTPUT_DIR = "/DATA/AppData/hermes/Projekte/3d"
os.makedirs(OUTPUT_DIR, exist_ok=True)
NAME = "my_part"
# ─── PARAMETERS ────────────────────────────────────────────
WIDTH = 40
DEPTH = 30
HEIGHT = 20
WALL = 2.0
# ───────────────────────────────────────────────────────────
# ─── HELPERS ───────────────────────────────────────────────
def make_box(w, d, h, center=(0,0,0)):
    m = trimesh.creation.box(extents=[w, d, h])
    m.apply_translation(center)
    return m
def make_cylinder(r, h, center=(0,0,0), axis='z', sections=64):
    m = trimesh.creation.cylinder(radius=r, height=h, sections=sections)
    if axis == 'x':
        m.apply_transform(trimesh.transformations.rotation_matrix(np.pi/2, [0,1,0]))
    elif axis == 'y':
        m.apply_transform(trimesh.transformations.rotation_matrix(np.pi/2, [1,0,0]))
    m.apply_translation(center)
    return m
def drill(mesh, x, y, diameter, axis='z'):
    idx = {'x':0, 'y':1, 'z':2}[axis]
    length = mesh.extents[idx] * 1.5
    cyl = make_cylinder(diameter/2, length, (x, y, 0), axis=axis)
    return mesh.difference(cyl)
def _chamfered_box(outer_l, outer_w, outer_h, wall, floor, chamfer):
    """Build an open-top box with chamfered inner top edges.
    Uses DIRECT MESH CONSTRUCTION — no booleans, no co-planar failures.
    The chamfer removes sharp 90° inner top corners."""
    il = outer_l - 2*wall
    iw = outer_w - 2*wall
    ol, ow, h, f, c = outer_l, outer_w, outer_h, floor, chamfer
    # 24 vertices
    v = np.array([
        # 0-3: Boden unten (z=0)
        [-ol/2, -ow/2, 0], [ol/2, -ow/2, 0], [ol/2, ow/2, 0], [-ol/2, ow/2, 0],
        # 4-7: Außen z=f (Boden oben / Wand unten)
        [-ol/2, -ow/2, f], [ol/2, -ow/2, f], [ol/2, ow/2, f], [-ol/2, ow/2, f],
        # 8-11: Außen z=h (Wand oben)
        [-ol/2, -ow/2, h], [ol/2, -ow/2, h], [ol/2, ow/2, h], [-ol/2, ow/2, h],
        # 12-15: Innen z=f (Wand unten innen)
        [-il/2, -iw/2, f], [il/2, -iw/2, f], [il/2, iw/2, f], [-il/2, iw/2, f],
        # 16-19: Innen z=h-c (Chamfer unten)
        [-il/2, -iw/2, h-c], [il/2, -iw/2, h-c], [il/2, iw/2, h-c], [-il/2, iw/2, h-c],
        # 20-23: Chamfer z=h (Chamfer oben — nach außen verschoben)
        [-il/2-c, -iw/2-c, h], [il/2+c, -iw/2-c, h], [il/2+c, iw/2+c, h], [-il/2-c, iw/2+c, h],
    ])
    # Faces (triangles, CCW = outward normal)
    faces = [
        # Boden unten
        [0, 2, 1], [0, 3, 2],
        # Außenwände unten (z=0 → z=f)
        [0, 1, 5], [0, 5, 4],
        [1, 2, 6], [1, 6, 5],
        [2, 3, 7], [2, 7, 6],
        [3, 0, 4], [3, 4, 7],
        # Außenwände oben (z=f → z=h)
        [4, 5, 9], [4, 9, 8],
        [5, 6, 10], [5, 10, 9],
        [6, 7, 11], [6, 11, 10],
        [7, 4, 8], [7, 8, 11],
        # Innenboden (z=f, Ring: Außen minus Innen)
        [4, 5, 13], [4, 13, 12],
        [5, 6, 14], [5, 14, 13],
        [6, 7, 15], [6, 15, 14],
        [7, 4, 12], [7, 12, 15],
        # Innenwände (z=f → z=h-c)
        [12, 13, 17], [12, 17, 16],
        [13, 14, 18], [13, 18, 17],
        [14, 15, 19], [14, 19, 18],
        [15, 12, 16], [15, 16, 19],
        # Chamfer-Flächen (schräg, z=h-c → z=h)
        [16, 17, 21], [16, 21, 20],
        [17, 18, 22], [17, 22, 21],
        [18, 19, 23], [18, 23, 22],
        [19, 16, 20], [19, 20, 23],
    ]
    mesh = trimesh.Trimesh(vertices=v, faces=np.array(faces))
    mesh.merge_vertices()
    return mesh
def open_top_box(w, d, h, wall, chamfer=0.0):
    """Hollow box with floor of wall mm, open top. Floor sits at z=0.
    chamfer > 0 rounds the inner top edges via direct mesh construction."""
    if chamfer > 0:
        return _chamfered_box(w, d, h, wall, wall, chamfer)
    # Fallback: simple hollow box via boolean (reliable when no chamfer)
    outer = make_box(w, d, h, center=(0, 0, h/2))
    inner = make_box(w-2*wall, d-2*wall, h, center=(0, 0, wall + h/2))
    return outer.difference(inner)
def closed_hollow_box(w, d, h, wall):
    outer = make_box(w, d, h)
    inner = make_box(w-2*wall, d-2*wall, h-2*wall)
    return outer.difference(inner)
# ───────────────────────────────────────────────────────────
# ─── MODEL ─────────────────────────────────────────────────
result = make_box(WIDTH, DEPTH, HEIGHT)
# ───────────────────────────────────────────────────────────
bb = result.extents
warnings = []
if max(bb) > 180:
    warnings.append(f"Exceeds A1 Mini build volume: {bb[0]:.1f}x{bb[1]:.1f}x{bb[2]:.1f} mm")
if not result.is_watertight:
    warnings.append("Mesh not watertight — slicer may produce gaps")
if not result.is_winding_consistent:
    warnings.append("Inconsistent winding")

stl_path = f"{OUTPUT_DIR}/{NAME}.stl"
png_path = f"{OUTPUT_DIR}/{NAME}.png"
result.export(stl_path)

fig = plt.figure(figsize=(8, 6))
ax  = fig.add_subplot(111, projection='3d')
ax.add_collection3d(Poly3DCollection(
    result.vertices[result.faces], alpha=0.85, edgecolor='k',
    linewidth=0.15, facecolor='#7aa6d8'))
v = result.vertices
ax.set_xlim(v[:,0].min(), v[:,0].max())
ax.set_ylim(v[:,1].min(), v[:,1].max())
ax.set_zlim(v[:,2].min(), v[:,2].max())
ax.set_box_aspect([np.ptp(v[:,i]) for i in range(3)])
ax.set_xlabel('X (mm)'); ax.set_ylabel('Y (mm)'); ax.set_zlabel('Z (mm)')
ax.set_title(f"{NAME} — {bb[0]:.1f}x{bb[1]:.1f}x{bb[2]:.1f} mm")
plt.tight_layout()
plt.savefig(png_path, dpi=110, bbox_inches='tight')
plt.close()

print(f"OK {NAME}")
print(f"   Size:       {bb[0]:.1f} x {bb[1]:.1f} x {bb[2]:.1f} mm")
print(f"   Volume:     {result.volume/1000:.1f} cm3")
print(f"   Watertight: {result.is_watertight}")
print(f"   STL:        {stl_path}")
for w in warnings:
    print(f"   WARN: {w}")
```
## FDM Tolerance Reference
| Fit type | Adjust from nominal |
|----------|--------------------|
| Clearance (loose) | +0.2 to +0.4 mm |
| Sliding fit | +0.1 to +0.2 mm |
| Press fit (tight) | −0.1 to −0.2 mm |
| Screw | Clearance hole | Self-tap into plastic |
| M2 | 2.2 mm | 1.6 mm |
| M3 | 3.2 mm | 2.5 mm |
| M4 | 4.3 mm | 3.3 mm |
| M5 | 5.3 mm | 4.2 mm |
## Bambu A1 Mini Constraints
| Parameter | Value |
|-----------|-------|
| Build volume | 180 × 180 × 180 mm |
| Nozzle | 0.4 mm |
| Min wall thickness | 1.2 mm |
| Min feature size | 0.4 mm |
| Max overhang w/o support | 45° |
| Materials | PLA, PETG, TPU, ABS |
## trimesh API Cheat Sheet
**Primitives**
```python
trimesh.creation.box(extents=[w, d, h])
trimesh.creation.cylinder(radius=r, height=h, sections=64)
trimesh.creation.icosphere(subdivisions=3, radius=r)
trimesh.creation.cone(radius=r, height=h)
```
**Transformations**
```python
mesh.apply_translation([x, y, z])
mesh.apply_transform(trimesh.transformations.rotation_matrix(angle_rad, axis_vec))
mesh.apply_scale(factor)
```
**Booleans (manifold3d auto, use only for simple closed solids)**
```python
a.difference(b)    # subtract — unreliable on open shells
a.union(b)         # combine
a.intersection(b)  # overlap
```
**Direct Mesh Construction (recommended for chamfers/fillets)**
```python
trimesh.Trimesh(vertices=np.array([...]), faces=np.array([...]))
mesh.merge_vertices()
mesh.fix_faces()   # correct winding direction
```
**Properties**
```python
mesh.extents              # [x, y, z]
mesh.volume               # mm³
mesh.is_watertight
mesh.is_winding_consistent
```
**Export**
```python
mesh.export("file.stl")
```
## Chamfer / Fillet — When to Use What
| User asks for | Approach | Helper |
|--------------|----------|--------|
| "chamfer the inner top edge" | Direct mesh construction with chamfered vertices | `_chamfered_box()` |
| "round the edge" (curved, not flat) | Same approach but with more chamfer segments | Extend `_chamfered_box()` with intermediate z-layers |
| "chamfer on a solid box" (closed) | Boolean with a chamfer prism — works because solid is closed | `make_box().difference(chamfer_prism)` |
| "real 45° chamfer" on hollow box (user explicitly corrected stepped opening) | Outer solid minus straight cavity minus chamfer prism | `_chamfer_prism()` + two-stage `difference()` |

**Two-stage boolean chamfer (for real 45° slope)**
```python
outer   = make_box(L, W, H, center=(L/2, W/2, H/2))
cavity  = make_box(L-2*wall, W-2*wall, H-floor-chamfer_h,
                   center=(L/2, W/2, floor + (H-floor-chamfer_h)/2))
step1   = outer.difference(cavity)   # closed solid minus lower cavity = valid closed mesh
prism   = _chamfer_prism(L-2*wall, W-2*wall, chamfer_h, setback=chamfer_h,
                         center=(L/2, W/2, H-chamfer_h))
result  = step1.difference(prism)    # subtract top chamfer prism from closed step1
```
This avoids the "chamfer disappears / volume unchanged" pitfall because the prism has actual slanted faces.
## Common Patterns
```python
# Plate with 4 corner holes
plate = make_box(50, 30, 3)
for x, y in [(-20, -10), (20, -10), (-20, 10), (20, 10)]:
    plate = drill(plate, x, y, 3.2)

# Project box with chamfered inner top opening
box = open_top_box(80, 60, 30, wall=2.0, chamfer=2.0)

# Project box with side cable cutout
box = open_top_box(80, 60, 30, wall=2.0)
cutout = make_cylinder(4, 10, center=(0, 30, 25), axis='y')
box = box.difference(cutout)   # OK here because box is closed solid

# L-bracket via union
horiz = make_box(40, 20, 3, center=(0, 0, 1.5))
vert  = make_box(40, 3, 20, center=(0, 8.5, 10))
bracket = horiz.union(vert)

# Rounded box corners (subtract cylinders from vertical edges)
def round_vertical_edges(mesh, w, d, h, r, arc=32):
    hw, hl = w/2 - r, d/2 - r
    for sx in [-1, 1]:
        for sy in [-1, 1]:
            cyl = trimesh.creation.cylinder(radius=r, height=h+2, sections=arc)
            cyl.apply_translation([sx*hw, sy*hl, h/2])
            mesh = mesh.difference(cyl)
    return mesh

# Open-top box with rounded corners
outer = make_box(30, 180, 50, center=(0,0,25))
outer = round_vertical_edges(outer, 30, 180, 50, 5.0)
inner = make_box(27, 177, 48.5, center=(0,0,25.75))  # wall=1.5
inner = round_vertical_edges(inner, 27, 177, 48.5, 3.5)  # inner_r = outer_r - wall
result = outer.difference(inner)
# Volumen check: R=5mm → ~35 cm³ (vs ~38 cm³ without rounding) confirms real material loss
```
## Decision Tree — "Make X" → Approach
| Part | Approach |
|------|----------|
| Plate + holes | make_box + drill loop |
| Enclosure / project box with chamfer | `_chamfered_box()` or `open_top_box(..., chamfer=2.0)` |
| Enclosure / project box (no chamfer) | `open_top_box` or `closed_hollow_box` |
| Enclosure with rounded corners | `make_box` + `round_vertical_edges()` + inner cavity boolean |
| Mount / bracket | boxes combined via union |
| Stand / wedge | box rotated via rotation_matrix |
| Knob / cap | cylinder ± details |
| Cable clip | box minus inner cylinder |
**Always parameterize for one-line iteration**
## Support Files
- `references/zimaos-cad-notes.md` — platform-specific pitfalls (CadQuery impossible, np.ptp fix)
- `templates/starter.py` — copy-paste template with all helpers + preview + validation + export
- `scripts/verify_cad_env.py` — run before first use to check all deps are installed
## Pitfalls
| Symptom | Cause | Fix |
|---------|-------|-----|
| `ImportError: libGL.so.1` | CadQuery/OCP needs Mesa | **Do NOT use CadQuery on headless ZimaOS**. Use trimesh only. |
| `AttributeError: 'TrackedArray' has no 'ptp'` | numpy >=2.0 removed `.ptp()` on views | Use `np.ptp(arr)` instead of `arr.ptp()` — applies to all trimesh vertex arrays. |
| Mesh booleans fail / produce garbage | Open shell + co-planar subtraction | Use `_chamfered_box()` for chamfers on hollow boxes — never use `difference()` on open shells for chamfers. |
| Chamfer disappears / mesh broken | Boolean engine can't handle open shell geometry | Switch to direct mesh construction (`_chamfered_box`). |
| Chamfer "works" but volume unchanged | Stacked shells with different wall offsets make a stepped opening, not a true chamfer | Verify via `volume` — if chamfer +0.5mm should reduce ~1.5–2 cm³. If not: use `_chamfered_box()` with slanted faces. |
| `extrude_polygon` takes `shapely.geometry.Polygon`, not numpy arrays or Path2D | trimesh API: `creation.extrude_polygon(polygon, height)` expects a Shapely Polygon | Use `trimesh.creation.box()` or `make_box()` for rectangular solids. For rounded boxes, use `make_box` + `round_vertical_edges()` instead of polygon extrusion. |\n| `ModuleNotFoundError: No module named 'shapely'` | shapely not installed on ZimaOS | Avoid shapely-dependent APIs (`extrude_polygon`, Path2D). Use trimesh primitives + booleans on closed solids instead. |\n| Matplotlib permission warning | tries to write `~/.config/matplotlib` | Harmless; or set `MPLCONFIGDIR=/tmp/mpl` before running. |
| `trimesh.viewer.windowed` requires `pyglet<2` | Calling `scene.save_image()` on headless ZimaOS | **Do NOT use `scene.save_image()`.** It needs pyglet → GL libraries → not available without a display. Use matplotlib Agg backend instead (template already does this). |
| `ImportError: Library "GL" not found` | pip installed `pyglet<2` but no OpenGL on headless | Same as above — pyglet cannot render without GL. matplotlib Agg is the headless-safe path. |
## After Every Export — Always Tell the User
1. STL file path
2. Size X × Y × Z mm + volume in cm³
3. Show the PNG preview inline so user can verify visually
DO NOT include print recommendations (orientation, supports, material, layer height) — user does not want them.
