---
name: burin
description: Design or modify a 3D-printable part/case in burin, a Lisp-scripted SDF/CSG modeler, updating the live-running preview window and exporting STL when done. Invoke when asked to create, change, or export a part, enclosure, or case using burin, or when a burin window/server is mentioned as running.
argument-hint: <description of the part or change to make>
allowed-tools: Bash(curl *), Bash(cd *), Bash(which burin), Bash(admesh *), Bash(burin *), Bash(/Applications/burin.app/Contents/MacOS/burin *), Bash(rm -f *.png), Read, Write, Edit
---

Iterate on a live burin scene by editing its Lisp source and pushing the
whole script to the running window over HTTP — the user sees the model
update immediately, no export needed until they're happy with it.

## Paths

This skill targets the installed, released burin app (the compiled binary),
not a source checkout — there's no build step and no separate CLI tool to
fall back on.

| Platform | Binary location |
|---|---|
| macOS | `/Applications/burin.app/Contents/MacOS/burin` (standard install location for the packaged app) |
| Linux | on PATH once packaged — resolve with `which burin` |
| Windows | not yet packaged as of this writing — ask the user for the install path |

| Resource | Path |
|---|---|
| Fixture/design scripts | `scenes/*.lisp` (always save designs here, never only in /tmp) |

## Step 0 — check the server is up

```bash
curl -s -m 3 -X POST --data '(+ 1 1)' http://localhost:8080/eval
```

Expect `2`. If it doesn't respond (connection refused / empty reply), the
window isn't running or has crashed — tell the user to launch the burin app
(double-click it, or run the binary from the table above), optionally with
`--scene scenes/whatever.lisp`, then retry. Don't try to launch the GUI
yourself in the background — it's an interactive window the user watches.

## Step 1 — write the source file first

Before touching the live scene, write (or edit) the full script under
`scenes/`. This is the source of truth — the live window's state is
disposable and never hand-edited directly. Use `defun`/`define` to keep
repeated shapes (standoffs, screw bosses, button cutouts) DRY.

**`(clear)` must be the first line of the script, before any `define` that
builds shapes — never last.** `(clear)` resets the node pool and invalidates
every node handle that already exists. If you `(define body (union ...))`
and then `(clear)` and then `(show body)`, `body` now points at invalidated
nodes. This does *not* error clearly: the engine logs a generic `tape
overflow — scene too deep/large` warning and silently renders nothing, which
reads exactly like a real size/depth problem and is not one — it happens
even for a 5-node scene. If a script that looks structurally fine produces
that warning, check `(clear)`'s position before suspecting the geometry.

**Verify non-trivial geometry yourself before pushing it live**, the same
way you'd check code before showing it to someone: spin up a disposable,
separate instance of the binary against your `.lisp` file with `--shot`
(see "Camera angle overrides" below) and actually look at the resulting
PNG. The live window's own camera can't be trusted for this — see the
caveat under that section. Reserve "just say what changed, don't
re-describe geometry you can't see" (Step 2) for describing the push to
the *user*, not as a reason to skip checking your own work first.

## Step 2 — push it live

```bash
curl -s -X POST --data-binary @scenes/the_file.lisp http://localhost:8080/eval
```

A successful push prints the root node, e.g. `#<node 42>`. `ERR` (or a
connection failure) means the script has a bug — check parens and argument
counts against the DSL table below before retrying. The user is
watching the window in real time; after a successful push, just say what
changed — don't re-describe geometry you can't see rendering yourself.

**One design per file, one `(show ...)` per push.** burin only has one live
root at a time. For a multi-part design (case halves, etc.) push whichever
part the user wants to look at; don't try to preview both halves assembled
by concatenating two scripts by hand — string surgery on `(show ...)` forms
is exactly how a stray dangling expression crashed the interpreter during
development (a real, reproducible parser robustness gap: malformed/
incomplete Lisp input can crash rather than error). If an assembled view is
genuinely useful, write it as its own clean `.lisp` file, don't paste
fragments together.

## Step 3 — when the user is happy, export

There is no headless export path in this workflow — exporting is a GUI-only
action. Tell the user to:

1. Set the **cell** slider (grid size, mm — `0.5` is the default/fast; drop
   to `0.3` or finer when the design has features under ~5mm, like screw
   holes or thin walls, so they're actually resolved).
2. Set the **decimate** slider (mesh-simplification error tolerance, mm —
   default `0.01` is visually lossless at print resolution and shrinks flat
   panels 50-150× in triangle count; `0` disables it).
3. Click **Export STL...**, which opens a native save dialog — they pick the
   destination path.

Ask the user for the path they saved to, then verify it:

```bash
admesh out.stl   # confirm: Total disconnected facets 0, Backwards edges 0
```

Always run `admesh` after an export and read the three numbers above —
don't just trust that the export succeeded.

## Modeling rules (found the hard way)

- **Never let a cut sit exactly flush with the surface it pierces.**
  Always overshoot by a margin (a few mm for a big cavity, `+1` for a
  pilot hole or slot). A flush/coincident boundary is barely-defined
  numerically and has caused both a marching-cubes tearing bug *and*
  visible raymarch-preview noise on unrelated nearby geometry — in one
  case the exported STL was fine and the live preview looked broken, which
  is confusing to debug from a screenshot alone. If a "why does this look
  wrong in the live view but Blender/the STL is fine" report comes in,
  check for a flush cut before assuming the renderer is buggy.
- **Check that separate solids don't secretly overlap before you union
  them.** Compute `distance(center_a, center_b)` vs `radius_a + radius_b`
  (or the box equivalent) for anything placed near existing geometry —
  screw bosses next to mounting standoffs are the classic case. An overlap
  silently merges into one lumpy blob that's watertight and manifold (so
  `admesh` won't catch it) but geometrically wrong. Verify a "should be
  separate" pair with `admesh`'s **Number of parts** count on an isolated
  export of just those two shapes, not just "still manifold."
- Standard screw clearances already validated in this project: M3
  self-tap pilot hole radius `1.3mm`, M3 clearance hole radius `1.6mm`,
  M2.5 self-tap pilot radius `1.0mm`. Boss outer radius `3.5-4mm` leaves a
  reasonable wall around the pilot hole.
- **World convention is Z-up**: `cylinder`/`capsule` are along their own
  local **Z** axis by default (confirmed against `sdf_math.h` and the
  camera's `up = {0,0,1}` in `main.c`) and X/Y form the footprint plane —
  real project scenes (`pi_zero_case_base.lisp`, `gameboy_case_back.lisp`)
  both say so explicitly. A previous version of this doc claimed Y-axis /
  X-Z footprint; that was wrong. Since a panel's thickness axis is usually
  Z, a vertical through-hole needs no `rotate` at all — just `translate` +
  an overshot half-height.
- **`rotate` sign convention, verified against source** (`quat_from_euler_deg`
  + `quat_rotate` in `sdf_math.h`/verified via `main_cli.c`'s own test:
  `(rotate 0 0 90 ...)` on something at +X lands it on +Y): standard
  right-hand rule, extrinsic X-then-Y-then-Z. Cyclically, `rotate rx 0 0`
  sends +Y→+Z, `rotate 0 ry 0` sends +Z→+X, `rotate 0 0 rz` sends +X→+Y,
  all for positive angles. Don't guess-and-check this by eye in the live
  window — derive it once per axis and reuse it; it's easy to get a sign
  backwards and end up with geometry that folds back on itself.
- **Chaining many small primitives along an axis (a manual "loft") works,
  but only with real overlap.** `defun` supports ordinary recursion, so a
  smooth taper (a tapering muzzle, a lathe profile, anything a single
  primitive can't express) is usually best built as a recursive chain of
  `smooth-union`ed slices spaced `step` mm apart, each a thin
  `cylinder`/`capsule` of half-height `hh` and a radius sampled from a
  profile function (`cond` with piecewise-linear segments works fine). The
  slices must satisfy `hh > step/2` — `hh = step/2` only makes adjacent
  slices *touch*, not overlap, and produces a visible "beads on a string"
  look instead of a smooth surface, even though nothing errors.
- **`(difference a b)` / `(smooth-difference k a b)` = `a` minus `b` — the
  first argument is the base, the second is the cavity being cut away.**
  This is easy to get backwards when the two operands are themselves named
  intermediate `define`s several layers deep (`(smooth-difference kh
  mouth-line head)` computes "mouth-line minus head" ≈ nothing, not "head
  minus mouth-line") — a swapped pair here doesn't error, it just quietly
  collapses the base shape to near-nothing. Name the variables `base` /
  `cavity` rather than `a`/`b` to make a swap easier to spot on re-reading.

## Two-part screw-together case pattern (reusable template)

World convention: X/Y are the footprint
plane, Z is the stacking/thickness axis, each half sits flat on the print
bed at `z=0` in its own file (the two halves are independent exports —
their world coordinates don't need to match, only their hole *positions*
relative to their own footprint).

- **Deep half** (tray): outer box minus an inner cavity box whose top
  overshoots the outer box's top by a margin (see rule above) — this is
  the one that holds screw bosses (full height, pilot hole all the way
  through) and anything bulky (battery, PCB).
- **Shallow half** (lid/panel): a flat plate (or thin panel with its own
  cutouts) with clearance holes at the same X/Y positions as the deep
  half's bosses.
- Both halves share `wall`, `internal-x`/`internal-z`, and `boss-inset` —
  since scripts don't share state across files, duplicate these constants
  in both files and say so in a comment (drift between the two is a real
  risk if the footprint changes later).

## Camera angle overrides (GUI, for reproducing a specific reported view)

```bash
# path from the Paths table above, e.g. on macOS:
/Applications/burin.app/Contents/MacOS/burin --scene scenes/x.lisp --cam-yaw-deg 20 --cam-pitch-deg 70 --frames 30 --shot out.png
```

Both flags are clamped to ±89° pitch internally — do not construct a
one-off build that removes the clamp; a pitch near ±90° degenerates the
camera's forward/up cross product. Use this to check what the *user*
reported seeing, not as a substitute for just asking them to orbit and
re-screenshot if a description is ambiguous. This same mechanism is also
your primary way to check your *own* geometry before pushing it live (see
Step 1) — a throwaway `--shot` run is a fresh process, so its camera
auto-frames correctly regardless of what the live window's camera is doing.

Two things that will bite you here:
- **The live window's own camera only auto-frames once per process
  launch** (on the first piece of content shown after it starts). If the
  user already looked at a small test shape earlier in the session and you
  push something much bigger or smaller, their camera won't refit — it'll
  look like nothing rendered even though the geometry is fine. That's a
  camera problem, not a geometry bug: tell the user to scroll/orbit to find
  it, or restart the app for a fresh auto-fit. Don't assume "user reports
  seeing nothing" means your last push was broken — check with `--shot`
  (a fresh process auto-frames independently) before debugging the
  geometry.
- **`--shot <path>` ignores directories in `<path>` and always saves to the
  app's own working directory using just the basename** — passing an
  absolute scratchpad path still lands the PNG in the project root. Expect
  this and clean up the stray file afterward rather than being surprised
  it's not where you asked.

## The scene DSL (v1 — this is the whole vocabulary)

Scripts are raw tinylisp S-expressions.  Primitives are centered on their
local origin; placement is done exclusively with `translate`/`rotate`/`scale`
combinators.

| Form | Meaning |
|---|---|
| `(sphere r)` | radius r, mm |
| `(box hx hy hz)` | **half**-extents, mm |
| `(cylinder h r)` | h = **half**-height, along local Z axis |
| `(capsule h r)` | h = **half**-height, along local Z axis |
| `(torus R r)` | major radius R, tube radius r, ring in local XY plane |
| `(union a b ...)` | variadic |
| `(intersection a b ...)` | variadic |
| `(difference a b ...)` | `a` minus (`b` ∪ `c` ∪ ...) — OpenSCAD semantics |
| `(smooth-union k a b)` | binary, blend radius k mm |
| `(smooth-difference k a b)` | binary |
| `(smooth-intersection k a b)` | binary |
| `(translate tx ty tz child)` | mm |
| `(rotate rx ry rz child)` | degrees, Euler XYZ extrinsic |
| `(scale s child)` | **uniform scalar only** (non-uniform SDF scale is out of scope) |
| `(show x)` | set the live scene root — the only way a node becomes current |
| `(clear)` | empty the scene and reset the node pool (old handles invalid) |

The core Lisp is also there: `define`, `defun`, `lambda`, `let*`, `if`,
`cond`, arithmetic `+ - * /`, comparisons `< >`, lists.  Example:

```lisp
(define wall 2)
(show (difference (sphere 10) (sphere (- 10 wall))))   ; hollow shell
```

**Modeling pitfall: never make a cut sit exactly flush with another surface.**
If you're carving an "open top" tray by subtracting a cavity box whose top
face lands at *exactly* the same coordinate as the outer box's top face,
the SDF is only barely-defined right at that coincident seam — cheap
insurance is to make the cavity overshoot past the surface it's piercing by
a real margin (a few mm), the same way `translate`d pilot holes and slots
should always be modeled a bit taller/longer than the wall they cut through.
Skipping this showed up two different ways during development: a
marching-cubes float-precision bug at certain cell sizes, and — worse —
visible noise in the live raymarch preview on any *other* feature (like a
screw boss) whose own surface happened to land at that same coincident
height, even though the exported STL was correct throughout. Cheap to avoid,
easy to reintroduce by accident.

Fixture scenes live in `scenes/`.

## HTTP API

Started automatically by the burin app on launch (port 8080, `--port` to
change). This skill only drives `/eval` (Step 2) — export goes through the
GUI button (Step 3), not the `/export` endpoint, since a GUI-only release
is what's actually installed.

```sh
# mutate the live scene (the window updates immediately):
curl -X POST --data '(show (sphere 10))' http://localhost:8080/eval
```

`POST /eval` — body is Lisp source, evaluated against the live interpreter
under a mutex; returns the printed result as text/plain.

## Export quality

Exports are watertight, correctly wound manifolds (verified with admesh:
0 disconnected edges, 0 backwards edges) — triangles are oriented by the SDF
gradient, so slicers need no repair step. File size is exactly
`84 + 50 × triangle_count` bytes.

**Mesh simplification.** Marching cubes samples a uniform grid, so a
perfectly flat wall comes out as hundreds of needlessly small coplanar
triangles instead of the 2 a real CAD tool would use — this matters a lot
for boxy parts like enclosures. `src/decimate.c` runs a quadric
edge-collapse pass (the same technique behind Blender's Decimate modifier)
after marching cubes: it repeatedly merges the cheapest edge (least surface
deviation) until the cheapest one left would exceed the `decimate` error
tolerance. Flat regions collapse to near-minimal triangle counts (a boxy
enclosure typically shrinks 80-400×); curved surfaces keep whatever detail
the tolerance requires. The default (0.01mm) is far below FDM print
resolution, so it's effectively lossless for printing — raise it for more
aggressive reduction at the cost of visible faceting on curves. Safety
checks (the QEM "link condition" plus a resulting-face-area guard, both
found by testing against real CSG scenes, not just primitives) keep the
output watertight — verify with `admesh`.
