---
name: cad
description: Design parts and assemblies headlessly with the feature-tree CAD engine behind cad.mino.mobi — write a tree, build it, measure it, check interference, export STL or STEP, render views to PNG, save it to a repo — and hand the result over as a cad.mino.mobi link. Use when asked to model, modify, measure or check a mechanical part or assembly.
---

# CAD, headlessly

The model is a **feature tree** (JSON). Geometry is a cache. You edit the
tree, the engine builds it, you read numbers and pictures back, you iterate.
Never edit meshes. The schema is `README.md` next to this file; the trees
under `bench/` are the worked examples (`gear`, `plate`, `case`, `cam`,
`clock` …); the design record is `docs/CAD.md` in the monorepo.

Everything below needs **node 22 and nothing else**: the engine is
`cad.wasm`, committed. Run every command from this directory. Used this
before? `CHANGELOG.md` (also `cad.mino.mobi/CHANGELOG.md`) lists what
changed and when, newest first.

## The loop

```
write tree.json → build → measure → (check) → render → judge → edit → …
```

| step | command | reads back |
|---|---|---|
| resolve only | `node agent/build.mjs tree.json --check` | params, sketches, ops, or the first error with its op id |
| build, exact | `node agent/build.mjs tree.json [--faces] [--json r.json] [--stl a.stl] [--step a.step]` | volume, area, bbox, centroid, χ, watertight; `--faces` lists every **named** face with its geometry (plane / cylinder); a typed error with `unsupported` when the kernel cannot. Exit 1 unless ok and watertight |
| build, preview | `node agent/build.mjs tree.json --kernel manifold` | always builds, milliseconds, polygons, no names |
| measure | `node agent/measure.mjs tree.json --list` · `… <face>` · `… <faceA> <faceB>` | a cylinder's diameter; plane-to-plane, axis-to-axis, axis-to-plane distances, from exact geometry |
| interference (assemblies) | `node agent/check.mjs asm.json [--t seconds \| --sweep N [--period s] \| --grid [n]] [--json]` | interfering pairs with shared volume, at one instant or the worst through a cycle; exit 1 if any beyond expected touches (fixed- and screw-mated) |
| clearance (assemblies) | `node agent/check.mjs asm.json --clearance 1 [--sweep N \| --grid [n]]` | every pair's nearest approach from the exact meshes — crossing, contained, touching, or the distance — with a verdict; in a sweep each minimum is chased between samples; exit 1 on a collision or a pair closer than 1 mm |
| mechanism (assemblies) | `node agent/mechanism.mjs asm.json [--input NAME] [--at a=1,b=2] [--steps N] [--of COMP] [--span A B] [--point COMP=x,y,z] [--load COMP=fx,fy,fz]` | **what it DOES**: per input, every component's rate and its mechanical advantage (the reciprocal), travel and turn end to end, what stays still, and the dead points. `--span a b` is an invariant when it reads zero ("the link is a link"); `--load` gives the effort at the input that holds a force — newtons for an input in mm, N·m for one in degrees, watts for a drive — **lossless**, so a floor. No geometry is built: two poses per rate |
| measure across an assembly | `node agent/measure.mjs asm.json finger-r.pad finger-l.pad --t 0.5` | two parts' named faces posed at t: the kinematics measured directly |
| drawing | `node agent/drawing.mjs tree.json --out a.svg [--views front,top,iso] [--no-hidden] [--t s]` | an SVG engineering drawing: third-angle views, hidden lines dashed, the overall width, height and depth, every hole called out by count, diameter and depth when blind — and on a part, **internal dimensions**: an ordinate from a datum at the corner to every hole centre and pocket edge, a pitch line for an evenly spaced run, and a note naming each sketch loop with its size. Name your loops (`"name": "slotRlo"`) and they appear on the drawing. On an assembly, posed at `t`, reference components left out |
| report (assemblies) | `node agent/report.mjs asm.json --out asm.html [--t s] [--explode 0.6] [--max-parts 20]` | one self-contained HTML page: the assembly in three views, an exploded isometric with numbered balloons, a parts list, a drawing of every part, and the assembly steps read off the placements, references, mates and fits — every row linking back into the viewer. This is the thing to hand a person. A part the exact kernel cannot build is listed with its error, not dropped |
| audit a repo | `node agent/audit.mjs --at handle [--kernels]` | rebuild every published part and diff it against the invariants its revision recorded; `--kernels` also checks Truck and Manifold agree on volume |
| printable | `node agent/export.mjs doc.json --out DIR [--t s]` | one STL per part (and a posed assembly STL) |
| look | `node agent/render.mjs doc.json --out DIR [--views iso,top,front] [--t s] [--hide dial,case]` | a PNG per view + `report.json` — the same viewer a human sees. Needs Chromium once: `npm install && npx playwright-core install chromium`, or `CAD_CHROME=/path/to/chrome` |
| files | `node agent/drive.mjs ls\|get\|put\|log\|fork\|push\|rm …` | a file tree over ATProto records — see *Files* below |

Any of these takes `bench:<name>` or an `at://` URI in place of a path.

Face names are stable and semantic: an extrude `plate` has `plate.start`,
`plate.end`, `plate.side[k]`, and loops with a `name` give `plate.rim[0..3]`,
`plate.pivot[2][1]`; a `gear` op `g` gives `g.tooth[i].flank.r.0`, `g.tip`,
`g.bore[k]`. Use names, never face indices.

**Names survive a boolean.** A cut destroys the face indices, not the
surfaces, so a face that survives keeps its feature's name and a face the
tool made carries the tool's own loop name (`slot.pivot[0]`) with its
geometry. A body with cuts in it is a placement target and a measure
argument like any other. A face that lies on no named surface is
`<op>.face[k]`, after the op that last changed the body; `{"op": "name",
"face": "slot.pivot[0]", "as": "pinBore"}` adds an alias when the name you
want is not the one the sweep gave.

**Three ways to give an extrude its thickness**, and one of them is always
right: `depth` (along the sketch plane's normal), `from`/`to` (both along
that same normal, so a solid that does not start at the plane needs no sign
juggling), or `through: true` on a `cut`, which sizes the tool from the
body's own extent. Prefer `through` for a cut that goes all the way: the
overhang is what made watertightness look like a coin flip — the kernel
knows the number and you do not. A cut whose tool misses the body is an
error naming the gap and the axis, not a trap.

## Which kernel

- **Manifold** (preview; `check.mjs`; `export.mjs` fallback): milliseconds,
  always builds, polygons, no face names.
- **Truck** (`build.mjs`, `measure.mjs`): exact B-rep with named faces and
  geometry; fast on sweeps, **fails on many booleans** (`boolean union
  failed`, `unsupported`). Prefer even-odd regions over booleans: a hole is
  a loop inside a loop, a pattern of holes is a `pattern` of a sketch, and
  the profile of an `extrude` is a list of sketches.
- **OCCT** (fillets, chamfers, shells, the booleans Truck fails): in the
  page on demand (*exact with OCCT*), and in the bake-off harness under
  node (`bakeoff/`, needs `cd bakeoff && npm install`, 66 MB). Not in
  `build.mjs`.

Sketch curves: lines, arcs (`via`), cubic Béziers (`ctrl`), and splines
through points (a `spline` segment inside a `path`, or a closed `spline`
loop — `bench/cam.json`). Splines are Catmull–Rom, one exact cubic per span,
one face per span, named `id.<loop name>[k]` (`cam.cam[7]`) or `id.span[k]`
when the loop is unnamed. There are no lofts, sweeps along a path, or
free-form surfaces yet; say so rather than approximating with polygons.

## Assemblies

A document with `components` (each a `part` from `parts` — an inline tree,
`bench:<name>`, or the **AT URI of a published part** (its head, or a
revision URI to pin a version) — optional `params` overrides, `at`,
`rotate`, `phase`, `repeat`, or a nested `assembly`), `mates` and a `drive`
(`{component, rpm}` or an `escapement`). Gear phases are automatic. See
`bench/clock.json`. Kinematics are a chain from the driven component, not a
constraint solver — placements are yours to get right; `check.mjs` tells
you when you have not.

**Mates** propagate from the driven component outward, in either direction:

| mate | fields | b does |
|---|---|---|
| `gear` | `za`, `zb` | turns −za/zb × a |
| `belt` | `ra`, `rb` (or `za`, `zb`) | turns +ra/rb × a — pulleys, chain, same sense |
| `fixed` | | turns and travels with a |
| `screw` | `lead`, `axis?` | travels `lead` per turn of a, along `axis` (b's local, default +z); does not turn |
| `rack` | `r` (or `m`, `z`), `axis?` | travels r·θ along its axis per θ of a — a pinion on a rack |
| `slider` | `ratio?` | travels ratio × a's travel |
| `revolute` | `input`, `axis?`, `scale?`, `offset?` | carries a's travel and turns `scale·input + offset` degrees about `axis` (b's local, default +z) on top of a's turn |
| `prismatic` | `input`, `axis?`, `scale?`, `offset?` | carries a's turn and travels `scale·input + offset` along `axis` (b's local, default +z) |

**A document may have more than one input, and need no drive at all.**
`drive` is the input that runs with time; `inputs` declares the others —
named axes of the document's own motion, each with a range, in scope by
name in every expression:

```json
"inputs": {
  "grip": { "min": 0, "max": 12, "steps": 5, "unit": "mm" },
  "roll": { "min": 0, "max": 180, "steps": 5, "unit": "deg" }
}
```

A `revolute` or `prismatic` joint is what consumes one. Two jaws opening
from one input are two prismatic joints, one with `scale: -1`. A wrist is a
revolute. **Do not write an input into a placement expression and then mate
the component as well** — that moves it twice, and `check` refuses it.

`derived` may be written over an input (`{"yn": "r * sin(deg(swing))"}`), and
so may any placement; an input's own `min`/`max` may not, since they are read
before the derived values are.

A component placed on another's face with `at: "@rotor.end"` follows the
anchor *point* — including when that component's own placement is an
expression over an input, so a bushing can sit on a link eye that moves; add `"rigid": true` to take the anchor's whole pose, so the
component's `offset` and a joint's travel turn with it. That is how a jaw
rides a rotor and slides on it at the same time — `bench/grip.json` is the
worked example, and it is four components, three mates and no expressions.
An anchor works the same at any depth: inside a sub-assembly it takes that
sub-assembly's placement **once**, whatever the sub-assembly is doing. (It took
it twice before 2026-09-18 — an arm moved 100 mm put its anchored pin at 210
instead of 110. If you have a nested anchor written out as an expression to
work around that, the workaround is now the bug: it is right only while the
sub-assembly sits at the origin.) The difference between the two anchors is a
frame: a plain one puts the component at a point ON the other and leaves its
axes the WORLD's, so it does not turn when the sub-assembly turns; `rigid` and
`rotate.align` take the anchor's pose, so they do.

Numbers in a mate are expressions in the document's scope. A component's
pose is its placement, then its travel, then its turn about its own z.
Travel is carried between components **in world**: a `fixed` follower
placed at 90° to the part it rides moves the same world direction, in its
own frame that is a different axis. A component placed by reference on
another (`@platform.pivot[i]`) already follows it, so a `fixed` mate
between the two adds nothing — the solver skips it rather than travelling
twice. `bench/lift.json` is a lead screw, a nut and a platform.

**Repeat.** `"repeat": 4` makes `id[0]` … `id[3]` with `i` in scope for
`at`, `rotate`, `offset`, references, `params` and the document's `derived`
— six bolts on a bolt circle are one component: `"at": ["r*cos(2*pi*i/6)",
"r*sin(2*pi*i/6)", 0]`, or `"derived": { "bx": "r*cos(2*pi*i/6)" }` and
`"at": ["bx", "by", 0]`. A derived that mentions `i` is evaluated per
instance; outside a repeat `i` is 0.

**Place by feature.** `"at": "@platform.pivot[i]"` puts the component's
origin on that named face of that component — a bore's centre on its
sketch plane, a plane's centroid — and `"rotate": { "align":
"@platform.pivot[i]" }` turns its local +z onto the bore's axis or the
plane's normal (`deg` then spins about it, `offset` moves in the aligned
frame). The op prefix may be left off (`pivot[2]` finds `plate.pivot[2]`);
bracket contents are expressions. The referenced component must be
declared earlier in the same document, and the reference follows it
through its motion, so a bolt on a plate that turns orbits — no mate
needed. `check.mjs`, `build.mjs`, the MCP tools and the viewer all resolve
references (the exact kernel names the faces); a missing face lists what
there is.

**Sweep and clearance.** `node agent/check.mjs asm.json --sweep 24` checks
24 instants over one period of the drive (a turn, two beats, or `--period`
seconds) and reports each pair's worst overlap and when. Add
`--clearance 1` for what a reviewer reads first: every pair's nearest
approach (crossing, contained, touching, or the distance in mm) with a
verdict, and in a sweep the minimum of each pair is chased between
samples by a golden-section search, so a graze between two instants is
found, not missed. The verdicts:

| verdict | means | passes |
|---|---|---|
| `collision` | crossing, one inside the other, any depth — or a mated touch past its budget | no |
| `contact` | touching with no depth, and no clearance was demanded | yes |
| `expected` | a touch the mates imply (fixed, screw) or a `fits` entry declares with `"contact": true` | yes |
| `fit` | a pair with a declared fit, within its `[min, max]` | yes |
| `close` | nearer than the clearance you asked for, or under a fit's `min` | no |
| `loose` | over a fit's `max` | no |
| `clear` | farther than the clearance | yes |

A running fit is not "close": declare it, and it is judged against its
own numbers rather than the clearance you demand of everything else —

```json
"fits": [ { "a": "screw", "b": "nut", "min": 0.05, "max": 0.15 },
          { "a": "platform", "b": "bolt[*]", "min": 0.05, "max": 0.15 },
          { "a": "nut", "b": "platform", "contact": true } ]
```

`[*]` matches every instance of a repeat and either order of `a` and `b`
matches; numbers may be expressions. **`[*]` on BOTH sides means the same
index** — `{"a": "arm-pin[*]", "b": "bush[*]"}` is four pairs, not sixteen —
and `over` walks an index through an expression on either side when the
pairing is not one-to-one:

```json
{ "a": "link[k]", "b": "bush[2*k]", "min": 0.1, "max": 0.3, "over": { "k": 4 } }
```

A fit naming a component the document does not have is an error, so an
enumerated list cannot rot silently when a repeat count changes. A
sub-assembly's `fits` reach the top like its mates, prefixed with its id.

**A `fixed` mate means bolted, not "may be the same solid".** A touch the
mates imply is `expected` only up to a budget: 1 mm³ of shared volume (or a
thousandth of the smaller part, whichever is larger) and 0.1 mm of depth.
Past that it is a `collision` like any other, and `check.mjs` exits 1. A
real press fit declares its own:

```json
{ "a": "bush[0]", "b": "arm", "contact": true, "interfere": { "max": 5, "depth": 0.2 } }
```

Only the pairs within four times the clearance (and at least 1 mm) are
refined — a pair 10 mm away cannot graze.

**A component placed by an expression over `t` or `theta` must not also
carry a mate**: the placement moves it and the mate moves it again, so the
two travels add (measured: four pins placed over a derived `yn` and
fixed-mated to the travelling arm stretched their links 40 → 34 mm).
`check` refuses such a document and names the component.

**Over two inputs the question is a grid, not a period.** `--grid` (and the
MCP tool's `grid: true`) checks every combination of the document's inputs —
grip × roll, every node — and reports the state where each pair came
closest. A path through that space is not a proof: it visits a curve and
says nothing about the corners it misses, and the corners are exactly where
a jaw at full stroke meets a post at one angle of the wrist. There is no
refinement between nodes (between two nodes of a grid lies a plane, not an
interval); ask for more steps, and the answer prices them.

Clearance needs no kernel, so the MCP `interference` tool runs it on the
server (`clearance`, `sweep`, `grid`, `period`); shared volumes still need Manifold,
which is local. **A big assembly does not fit in one server request**, and
the server says so instead of dying:

- `incomplete: "parts"` — it builds a few new part meshes per call and
  caches them; call again with the same arguments until the sweep runs.
- `done: false` with `next` — the sweep covered instants `window.from` to
  `window.from + window.sampled - 1`. Call again with `from: next` until
  `done`, then take the smallest distance per pair across the windows.
  `ok` and `done` are separate: `ok` says nothing bad was found in what was
  sampled, `done` says the sweep finished. Every answer carries `cost` —
  what one instant costs in triangle-pairs, the server's budget, how many
  instants that buys, and an estimate at the other resolutions — so `res`
  can be chosen without probing for the cliff.
- `incomplete: "too-big"` — one instant costs more than the server may
  spend. Pass `res: 128` or `res: 64` (coarser meshes: chord error 0.005
  or 0.01 mm instead of 0.0025), check fewer components, or run it locally.

Locally there is no budget: `agent/check.mjs` sweeps the whole cycle in one
go, and for anything the size of the clock that is the faster path. Distances
come from the exact meshes at a fine tessellation (chord tolerance
0.0025 mm), so a designed 0.1 mm reads 0.098; set a fit's `min` with that
in mind.

**Reference geometry.** `"reference": true` on a component draws it
translucent and keeps it out of the interference check, the clearance
table and the export — a placeholder pin, the mating part you are
designing against. `hidden` is display only; every tool still counts a
hidden component.

**Measure across an assembly.** `node agent/measure.mjs asm.json
finger-r.pad finger-l.pad --t 0.5` (and the MCP `measure` tool with `t`)
poses the assembly and measures two parts' named faces against each other
— plane to plane, axis to axis — which tests the kinematics directly
instead of your own pose arithmetic.

**What the mechanism DOES: rates, advantage, effort, dead points.**
`node agent/mechanism.mjs asm.json` (and the MCP `mechanism` tool)
differentiates the poser against each of the document's inputs — and
against `t` when it has a drive — so every velocity ratio in the assembly
falls out of two poses per number. It builds **no geometry** and runs no
kernel, so it costs nothing next to a clearance sweep, and it is the
instrument for the questions `check` cannot answer:

| ask | how |
|---|---|
| how far does this part move per unit of the input? | the rate, per component |
| what does the linkage multiply the force by? | the **mechanical advantage** — the reciprocal of that rate, by virtual work |
| what torque holds this load? | `--load jaw-r=0,100,0` → N for an input in mm, N·m for one in degrees, W for a drive |
| is this invariant actually invariant? | `--span a b` reading **zero everywhere on the input** — "the link is a link", "rolling does not change the grip". It reports the rate at the state you asked about *and* at its worst over the whole input, because a zero at one state may only be a dead point |
| where does it go dead or toggle? | a rate passing through zero — reported per input, with the state it happens at |
| how far does everything travel, end to end? | travel and turn, read from the two END poses (so an escapement's impulsive motion is not under-resolved by sampling) |
| what does the tip of this arm do? | `--point hand=24,0,0` — a point in the part's own coordinates, so a hand pinned at its boss reads zero at the origin and r·ω at its tip |

Two things it is not. It is **lossless**: friction, preload and backlash
are not modelled, so an effort is a FLOOR, not the answer — size a brake
above it, not at it. And it asks only about degrees of freedom the
document already has: it is not a constraint solver (a loop you closed by
expression is still yours to get right), not contact statics (where a
force is carried through a touch rather than a joint), and not FEA. Those
are not built. What it replaces is the hand-written oracle: a ratio a
document claims can be graded against a number here instead of against
someone's prose.

The **assembly report** carries all of this as its *Motion* section — a
rate curve per input with the dead points marked, and a table of rate,
advantage, travel and turn — so it reaches a person without their running
anything.

**Audit a corpus.** Every published revision carries the invariants it
was judged by. `node agent/audit.mjs --at <handle> --kernels` rebuilds
every head in a repo and diffs volume, χ, watertightness and face count
against the record, and checks Truck and Manifold still agree on volume
within `--tol` (1 % by default; chord error on small round parts is
~0.3 %). The publish workflow runs it on the bench repo after every
publish.

**Placements are expressions**, so the pose math for anything the mates
cannot express (a lead screw and its nut, a crank and its slider, a link
that closes a loop) lives in the document. An assembly may carry `params`
(numbers or expressions over each other, any order) and `derived`, a
second map resolved the same way at each instant — any order, since a
record's keys come back from a PDS sorted — with two reserved variables: `t` (seconds) and `theta` (the driven component's angle in
degrees; the escape wheel's for an escapement). Every `at` element,
`rotate.deg`, `rotate.axis` element and the drive's numbers take a number
or an expression over params + derived + t + theta. Component `params`
overrides are bound in the assembly scope at t = 0 when they can be
(`"length": "L"`), else handed to the part. `bench/crank.json`:

```json
{ "params": { "r": 10, "L": 30 },
  "derived": { "th": "deg(theta)", "px": "r * cos(th)", "py": "r * sin(th)",
               "reach": "sqrt(L^2 - py^2)", "xs": "px + reach",
               "phi": "rad2deg(atan2(-py, reach))" },
  "components": [
    { "id": "crank", "part": "crank", "params": { "length": "r" } },
    { "id": "rod",   "part": "rod",   "at": ["px", "py", 1], "rotate": { "axis": [0, 0, 1], "deg": "phi" } },
    { "id": "block", "part": "block", "at": ["xs", 0, 2] } ],
  "drive": { "component": "crank", "rpm": 30 } }
```

Mind the two angle helpers: `deg(x)` turns degrees *into* radians (for
`sin`/`cos`), `rad2deg(x)` turns radians into degrees (for `rotate.deg`).
`check.mjs --t` and the viewer's *spin* sweep the real motion; a sub-
assembly keeps its own params and derived.

## Files

Parts live in repos as records: a `com.minomobi.cad.part` head names a
path and points at an immutable `com.minomobi.cad.revision` (the tree, its
parents, the kernel and invariants it was judged by). History is the
parents chain and crosses repos, so a fork keeps its lineage.

```bash
node agent/drive.mjs ls --at minomobi.com                      # the published bench: parts/<name>, train, clock
node agent/drive.mjs get clock --at minomobi.com > clock.json  # a tree to build / check / render / export
node agent/drive.mjs put clock/wheel tree.json -m "72 teeth"   # your local drive (~/.cad-drive.json)
node agent/drive.mjs log clock/wheel                           # its history
node agent/drive.mjs fork at://did:…/com.minomobi.cad.part/… parts/gear   # theirs → yours, lineage kept
CAD_HANDLE=you.bsky.social CAD_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \
  node agent/drive.mjs push clock/wheel --login                # local → your own repo, public
```

`--login` takes an **app password** (Bluesky settings → app passwords),
never the account password. What you push is public and yours; the files
tab on cad.mino.mobi lists it, anyone can open it by AT URI and fork it.
Fork a bench part rather than editing the bench when the change is yours.

## Handing over

Any tree or assembly opens in the viewer as a link: base64url the JSON into
`https://cad.mino.mobi/#t=<…>`, or `?part=<bench name>`, or — best — save
it to a repo and hand over `https://cad.mino.mobi/?at=<AT URI>`: a file the
human can open, fork, and read the history of. The human sees the part,
the report, the named faces, the measure tool, the section plane (pin a face,
press `s`, and pan pushes the cut through the part) and the interference check;
give them the link and the numbers you judged by.

**Hand over the `part` head, not a `revision`,** and hand it over once. A
head link always resolves to that file's newest revision, and a page left
open on one keeps itself there: it re-reads the head of the document and of
every part the document references, and reloads in place when one moves. So
the next revision you save reaches the page the person already has open —
no new link, no reload, no signing in again. Use a `revision` URI only when
you mean *this version for ever*.

## A worked task

"Add a 72-tooth wheel to the train on a new arbor and prove it meshes."

1. `node agent/drive.mjs get train --at minomobi.com > train.json`; read it.
   Components are `arbor1`, `wheel1` and a sub-assembly `stage2` (its own
   `arbor` and `wheel`, addressed as `stage2/arbor`, `stage2/wheel`); the
   parts are the published arbor and gear by AT URI with `params`
   overrides; mates are `gear` with tooth counts; the drive turns `arbor1`.
2. Add a component `{ "id": "wheel3", "part": "wheel", "params": { "z": 72 },
   "at": [x, y, z] }` at the centre distance `m·(za+zb)/2` from its mate,
   and a `gear` mate `{ "a": "stage2/wheel", "b": "wheel3", "za": …, "zb": 72 }`.
3. `node agent/build.mjs train.json` — every distinct part builds
   watertight. `node agent/check.mjs train.json --t 0.5` — no clash through
   the motion. `node agent/render.mjs train.json --out shots` — look.
4. `node agent/drive.mjs put train/three-stage train.json -m "third wheel"`,
   then push with `--login` and hand over the `?at=` link.

## Honesty

Report what `watertight` and `χ` say, not what the picture looks like. A
part that builds but is **not watertight is not a part**: its volume is not
to be trusted and it will not print or export cleanly. `agent/build.mjs`
exits 1 on it and the MCP `build` tool returns `ok: false` with
`built: true` — those two mean the same thing, deliberately. A Truck failure
is not a modelling failure — try the region form, then OCCT in the page.
Volumes are in mm³ when `units` is `mm`. If the renderer is not installed,
say so rather than describing a picture you did not see.
