# CADSolver command schema — WI-5.1 (CADSF-99)

A **command file** is a single JSON document describing a sequence of
modelling operations to run against the CADSolver kernel. It maps 1:1 onto
the Phase-3 construction APIs (`ops/primitives.js`, `ops/transform.js`,
`ops/extrude.js`) plus `save` (`StepWriter`). It is the file format consumed
by `node cli.js --run <file.json>` and by the browser "AI instructions" panel
(WI-5.2 / CADSF-100).

## Top-level shape

```json
{
  "version": 1,
  "ops": [
    { "op": "box", "id": "plate", "dx": 100, "dy": 60, "dz": 10 }
  ]
}
```

- `version` (required, number) — schema version. Currently `1`.
- `ops` (required, non-empty array) — an ordered list of operation objects,
  executed in array order. Each op may build a NEW solid, or act on a solid
  produced by an EARLIER op (referenced by `id`).

## Units & coordinate conventions

- Linear units are **millimetres**, angles are **radians**, matching the
  kernel's internal STEP convention (`StepParser.units`/synthetic mm context
  used by `ops/primitives.js`).
- Right-handed coordinate system, **+Z is up**. Primitives are built with
  their local +Z axis unless an op-specific placement says otherwise.
- Polygons for `extrude`/profiles for `revolve` are `[x, y]` / `[r, z]` point
  arrays, **not closed** (do not repeat the first point).
- `extrude` outer loops must be wound **CCW**; hole loops must be wound
  **CW** (standard planar straight-skeleton convention — get this backwards
  and the hole either doesn't subtract or the solid isn't watertight).

## Referencing prior results (`id` / `of`)

Every op MAY carry an `"id": "<name>"` string to name its result for later
ops. If omitted, the op's own array index (as a string, e.g. `"0"`) is used
as its implicit id. Ops that act on an existing solid take an `"of"` field
set to a prior op's id.

### Addressing ONE body of a multi-body op (CADSB-120)

Some ops produce more than one body: `extrude` of a sketch with several disjoint
closed profiles (one solid per profile), `pattern` (seed + clones), and `import`
(every solid in the file). Each body is addressable as

```
"<opId>#<bodyIndex>"      e.g.  "f7#0", "f7#1"
```

with `bodyIndex` 0-based, in the op's own output order (profile order, then
seed-then-clones, then file order). A **bare** `"<opId>"` still means body 0, so
existing documents are unchanged — but a bare reference to an op that produced
several bodies is reported on that op's result as `ambiguousRefs` (naming the
body used and every body it could have meant) instead of resolving silently. An
out-of-range index is refused with the real body count and the valid ids; a
malformed suffix (`"f7#"`, `"f7#x"`, `"f7#-1"`) is refused as malformed rather
than treated as an unknown id.

`sketch` references are NOT body-addressable — a sketch is not a solid.

### Addressing EVERY body of a multi-body op (CADSF-725)

```
"<opId>#*"                e.g.  "p6#*"
```

means *every* body that op produced, in build order, as one reference — a whole
pattern as a single operand. It is accepted only where a **set** of bodies is
meaningful: today that is the boolean verbs' `tool` (see below). Everywhere
else `"p6#*"` reads exactly as the bare `"p6"` does, i.e. the primary body,
because a mass-properties query or a face selector has no meaning over a set.
`*` cannot occur in an op id, so nothing that parsed before parses differently.

The parser is `commands/body-ref.js`, shared by the validator, the executor and
(via `web/src/browser-entry.js`'s `BodyRef` export) the viewer.

## Op reference

### Primitives (`ops/primitives.js`)

| op | required fields | optional fields |
|----|------------------|------------------|
| `box` | `dx`, `dy`, `dz` (>0) | `origin` ([x,y,z], default [0,0,0] = min corner), `rotate` |
| `cylinder` | `r`, `h` (>0) | `origin` (base center, default [0,0,0]), `rotate` |
| `cone` | `r`, `h` (>0) | `origin` (base center, default [0,0,0]), `rotate` |
| `sphere` | `r` (>0) | `origin` (center, default [0,0,0]), `rotate` |
| `torus` | `R`, `rmin` (>0, `R`>`rmin`) | `origin` (center, default [0,0,0]), `rotate` |
| `frustum` | `r0`, `r1`, `h` (>0) | `origin` (base center, default [0,0,0]), `rotate` |
| `polyhedron` | `kind` (one of `tetrahedron`, `hexahedron`, `octahedron`, `dodecahedron`, `icosahedron`, `trapezohedron10`), `R` (circumradius, >0) | `origin` (center, default [0,0,0]), `rotate` |

### Extrude & revolve (`ops/extrude.js`)

| op | required fields | optional fields |
|----|------------------|------------------|
| `extrude` | `depth` (non-zero; positive sweeps along the profile plane's normal, negative the other way — CADSF-101448), plus a profile source — either `outer` ([[x,y],...], >=3 pts, CCW) OR `profile` (see below) | `holes` (array of CW polygons, when `outer` is used) |
| `revolve` | `profile` ([[r,z],...], >=3 pts, r>=0) | `angle` (radians, (0, 2π], default 2π — partial sweeps get planar end caps), `arcs` (array of `{seg, center:[cr,cz], ccw?}` — see below) |

`extrude`'s profile source is exactly one of:

- **inline `outer` + `holes`** (the original form) — you hand-wind the loops:
  `outer` CCW, `holes` CW (see the winding note above).
- **`profile: { outer, holes? }`** — the same inline loops nested under one key.
- **A free-form line/arc loop** (CADSF-2606) anywhere a loop goes — `outer`, a
  `holes` entry, `profile.outer`/`holes`, and `pad`'s `outer`:
  `{ start:[x,y], segments:[ {to:[x,y]} | {to:[x,y], center:[x,y], ccw}, … ] }`,
  a straight segment or a circular arc about `center` (`ccw` default true), in
  order, the last `to` equal to `start` (>= 2 segments when one is an arc, >= 3
  when all are straight). The arcs reach the kernel exact
  (`ops/extrude.js` `extrudeArcProfile`, CYLINDRICAL_SURFACE walls) — the form
  `ops/dxfProfiles.js` emits for a DXF loop with fillets or a scallop that is
  none of the `{circle}`/`{slot}`/`{rect}` primitives. Winding is inferred by
  containment like a primitive's (a loop wound against its role is reversed
  and reported in `notes`; `orient:false` does not skip it), and a bad loop is
  refused by code before anything is built: `PROFILE_ARC_LOOP_SHAPE`
  (malformed, not closed, no area), `PROFILE_ARC_BAD_CENTER` (an arc's ends not
  equidistant from its centre), `PROFILE_ARC_ZERO_SEGMENT` (a segment whose two
  ends coincide), `E_PROFILE_SELF_INTERSECTING`. There is no ellipse / spline
  segment in the inline form — draw those in a sketch.
- **`profile: { sketch: "<id>" }`** — pull the closed profiles out of a sketch
  built earlier in the document (`sketch_begin`/`sketch_entity`/`sketch_end`,
  below). **The sketch orients the loops FOR you**: whatever way you drew each
  loop, `profiles()` emits outer boundaries CCW and holes CW before they reach
  the extruder, so a sketch-authored plate is correct regardless of draw order
  or winding. A sketch that resolves to several disjoint profiles produces one
  solid per profile, all grouped under the extrude op's `id`.

`revolve` details (CADSF-159, `ops/revolve.js`):

- The profile is a closed polygon in the **r-z half plane** (r >= 0), swept
  about the Z axis. Orientation (CW/CCW) is normalized internally. Straight
  segments emit CYLINDRICAL_SURFACE (constant r), PLANE (constant z), or
  CONICAL_SURFACE (sloped); every turned part — shafts, bosses, hubs, pulleys,
  flanges — is this op.
- **`arcs`** marks profile segments as circular arcs: `seg` is the segment
  index (`profile[seg]` → `profile[seg+1]`), `center` is the arc centre in
  (r,z). Both endpoints must be equidistant from the centre. `ccw` is the
  travel direction about the centre (default: the short way; **required** for
  half/full-circle arcs, where "short way" is ambiguous). Arcs emit
  TOROIDAL_SURFACE — or SPHERICAL_SURFACE when centred on the axis. A torus is
  four quarter arcs; a ball is two quarter arcs plus an axis segment. Arcs
  bulging across the axis (min r < 0) are rejected.
- **`angle`** sweeps a partial revolution: side faces plus two planar caps in
  the start/end half planes. Full 360° (the default) emits seamless
  "full-u band" faces instead — one face per profile segment, bounded by the
  vertex circles — which is the layout the tessellator meshes accurately.
- Volume in `analytic.volume` is the exact shell integral `(angle/2)·|∮r²dz|`
  (arcs use a closed-form antiderivative), independently cross-checked in
  tests against Pappus's centroid theorem `V = angle·R̄·A`.
- Profiles that only touch the axis at points or segments are fine (solid
  cylinder, cone, sphere); segments **on** the axis emit no face.

### Sweep (`ops/sweep.js`) — CADSF-201 (MODELER M4.1)

| op | required fields | optional fields |
|----|------------------|------------------|
| `sweep` | `section` (a closed line/arc loop, see below), `path` (a polyline with `fillets`, or explicit line/arc legs) | `holes` (array of inner loops, wound the other way — a bent pipe), `up` ([x,y,z], the path plane's normal), `maxBandAngle` (radians, default π/2 rigid / π/24 general — how finely an arc leg is banded); **general sweep (CADSF-250):** `scale`, `twist`, `guides`, `guideMode`, `stations`, `sectionTolerance` |

Sweep a closed profile along an **analytic** path.

- **`section`** is `{ start: [a,b], segments: [ { to:[a,b] } | { to:[a,b],
  center:[ca,cb], ccw? }, ... ] }` — the same closed line/arc spelling
  `ops/extrude.extrudeArcProfile` takes, drawn **CCW** in the section's own
  (a, b) frame. The path runs through `(a,b) = (0,0)` and **+b is the path
  plane's normal**, so `a` is the offset to the left of travel and `b` is the
  offset out of the path plane. The last segment must close onto `start`.
- **`path`** is either
  `{ points: [[x,y,z],...], fillets: r | [r, ...] }` — a **coplanar** polyline
  whose interior corners are replaced by tangent corner arcs of the given
  radius (one number for every corner, or one per interior corner in order) —
  or `{ start: [x,y,z], segments: [ { to } | { to, center, ccw? }, ... ] }`,
  which spells the legs out directly (a bare arc leg included).
- **The path must be tangent-continuous (G1).** A polyline corner with no
  fillet radius is **refused**: sweeping a section round a sharp corner needs a
  mitre (a boolean), and doing it with shared section rings yields a closed but
  self-intersecting solid that a watertightness check cannot see. For the same
  reason a turn whose radius is smaller than the section's reach toward the
  inside of that turn is refused — the solid would fold through the path axis.
- **Surfaces are exact.** A straight leg translates the section (PLANE walls
  for its line segments, CYLINDRICAL_SURFACE for its arcs); an arc leg *rotates*
  the section about that arc's own centre, which is a partial revolve, so its
  walls are CYLINDRICAL/CONICAL/PLANE for line segments and TOROIDAL (or
  SPHERICAL, centred on the axis) for arcs. This holds only while the section
  is **rigid** — see the general sweep below.
- The result is ONE shell: adjacent legs share the section curves at the
  station between them (interior stations carry no face at all), so it is
  watertight by construction and only the two ends become planar caps.
- `analytic.volume` is exact by Pappus — the section area times the arc length
  of the section centroid's trajectory — and `analytic.legs` reports each leg's
  own contribution.

### General sweep (`ops/sweepGeneral.js`) — CADSF-250 (SURF S6)

Add any of `scale`, `twist` or `guides` to the **same** `sweep` op and the
section stops being rigid: it may grow, rotate about the path tangent, or
follow guide curves. None of those three can be built from the analytic
surfaces above (a section growing by an arbitrary law does not sweep a cone,
and a rotating one has no STEP surface at all), so the walls become **ruled
degree-1 × degree-1 B-spline bands** between station rings — the same
construction `ops/loft.js`'s ruled path uses, with the same shared-EDGE_CURVE
watertightness argument. A sweep with none of these fields is byte-for-byte the
CADSF-201 result; nothing about the rigid path changed.

- **`scale`** — the variable profile. A positive number scales the section
  uniformly; a **law** string `s(t)` varies it, where `t` is **normalised arc
  length** along the path (0 at the start, 1 at the end — *not* blend's rim
  angle); `{ a, b }` scales the section's two axes independently (a round
  section becomes elliptical). Must stay strictly positive: 0 collapses the
  section onto the path and a negative value turns it inside out, both refused.
  Laws use the same expression DSL as `blend.law` (`commands/laws.js`) and may
  read document parameters.
- **`twist`** — rotation about the path tangent, in **radians**, positive CCW
  looking along the direction of travel. A bare **number** is the TOTAL twist,
  ramped linearly (`PI/2` = a quarter turn end to end), which is what a CAD
  sweep dialog's "twist angle" means; a **law** `phi(t)` is the angle itself.
- **`guides`** — 3D polylines the section follows. Each guide must be offset
  from the path and must cross every section plane. At each station the
  least-squares **similarity** (uniform scale + rotation) carrying every guide's
  starting offset onto its current one is applied to the section: one guide
  makes the surface pass exactly through it, several split the difference.
  `guideMode` selects `"both"` (default), `"scale"`, `"twist"` or `"none"`
  (measure without applying). An explicit `scale` **multiplies** the guide's
  scale and `twist` **adds** to its rotation, so the two controls compose.
- **Two discretisations, both reported.** `sectionTolerance` (mm, default 0.02)
  is the chord tolerance the section's arcs are polygonised to — honoured
  against the largest scale the sweep reaches, so it holds where the section is
  biggest. `stations` / `maxBandAngle` / `maxTwistStep` bound how finely the
  path is banded. `analytic.volume` is the **exact** volume of the shell that
  was built (a 2 × 2 Gauss rule is exact for a bilinear patch), and
  `analytic.idealVolume` is the smooth body's volume by generalised Pappus:
  the gap between them is `analytic.discretization`, and
  `analytic.section.areaDeviation` is what the polygonised section gave up.
  Both converge second-order — measured (tests/test_cadsf-250.js): halving
  `maxBandAngle` on an L-path sweep cuts the band deficit 0.3763% → 0.0943%
  (3.99×), and a 5× tighter `sectionTolerance` cuts a circle's section deficit
  0.8371% → 0.1711% (28 → 62 nodes).
- Everything the rigid sweep refuses is refused here for the same reasons, plus
  a scale that reaches zero, a station where the *scaled, twisted* section
  reaches past the centre of an arc leg's turn, and a guide that fails to cross
  some section plane.

### Tube sweep, non-planar path (`ops/tubeSweep.js`) — CADSF-1017

`tube_sweep` is a **separate verb**, not a mode of `sweep`, because it takes a
different path. `sweep` (and the general sweep above) fits **one plane** through
the whole path and refuses the first point off it
(`sweep_path_not_planar`); `tube_sweep` takes a path that is **not** planar and
carries the section in the plane **normal to the path tangent**, so the swept
volume is *path length × section area* rather than *area × height*. (The other
non-planar op, `ops/helicalSweep.js`, is a **twisted extrude**: its profile stays
parallel to global XY, so by Cavalieri its volume is `area × height` whatever the
turn count. That is right for a helical gear tooth and wrong for a coil spring.)

- **`section`** / **`holes`** — the *same* grammar as `sweep`: a closed
  line/arc loop in its own `(a,b)` frame, outer CCW, holes CW, with the path
  running through `(a,b) = (0,0)`. A bare polygon `[[a,b], ...]` is accepted too.
- **`path`** — one of three spellings:
  - `{ helix: { radius, pitch, turns, hand, axis, origin, phase, nodesPerTurn } }`
    — `pitch` is the rise per full turn (0 is a closed circle), `hand` is
    `"right"` (default) or `"left"`, and `nodesPerTurn` defaults to **32**, which
    is also its minimum. `pathLength` is reported from the closed form, not the
    chords.
  - `{ points: [[x,y,z], ...] }` — a 3-D polyline, **not** required to be
    coplanar. Its nodes are the sweep's stations.
  - `{ bspline: { ctrl, degree, samples } }` — a clamped-uniform B-spline control
    net, sampled at `samples` stations (default 128).
- **`bandStations`** (default 9) — how many stations one skin band spans, or
  `"auto"` (CADSB-100112: the longest bands whose merged wall faces stay merged,
  reported in `analytic.bandPlan`), and
  **`sectionTolerance`** (default 0.02 mm) — the section's arc chord tolerance.
  Both appear in `analytic` so the discretisation is a number, not an assumption.
- **`up`** — seeds the frame's `+a` direction at the first station. Optional: the
  frame is well defined without it.
- CADSF-100679 — the rest of `ops/tubeSweep.js`'s options, forwarded by the
  document op (before this they validated and were silently dropped):
  **`exactSection`** (`true`: a full-circle loop is carried as the exact rational
  circle; default the polygon, which the boolean takes), **`twist`** (radians,
  total, linear in arc length), **`frame`** (`"rmf"` default; `"axial"` — the
  plane through a helix axis, a screw motion; `"fixed"` — translated, normal held
  constant), **`wallFaces`** (`"segment"` default; `"strip"` — one wall face per
  section node), **`surfaces`** (`"analytic"`: a `{ points, bendRadius }` round
  tube as exact cylinders + torus segments, CADSF-2154) and `path.bendRadius`.
  Any other field on a `tube_sweep` op is **refused by name** at validation
  (`ops[i].<field> is not a "tube_sweep" field`, with the field it most likely
  meant). The report carries the built body's exact volume as its claim
  (`analyticVolume`, CADSB-1337), and `watertight` is the B-rep verdict (closed
  shell + the light check), so neither tessellates a long tube.

**The frame is rotation-minimising (Bishop), by double reflection** (Wang,
Jüttler, Zheng & Liu, ACM TOG 27(1), 2008) — *not* Frenet, which spins with the
path's torsion and inverts at an inflection. Two reflections compose to a
rotation, so the frame stays orthonormal exactly. **The wall is CADSF-959's
skin**, reused rather than re-derived: one degree-1 × degree-3
`B_SPLINE_SURFACE_WITH_KNOTS` per section node per band over shared rail
`B_SPLINE_CURVE_WITH_KNOTS` edges, plus two planar end caps, welded into one
watertight solid. `analytic.volume` is the **exact** closed-form volume of that
shell; `analytic.idealVolume` is `pathLength × area`.

Two refusals this op owns, by name: **`tube_sweep_section_exceeds_curvature`**
(the section reaches as far from the path as the path's smallest radius of
curvature — the inside wall would fold through itself) and
**`tube_sweep_path_cusp`** (the tangent turns more than `maxTurnAngle`, default
π/2, between two nodes: a cusp has no normal plane to carry the section in).

### Loft (`ops/loft.js`) — CADSF-215/216 (MODELER M4.2a/M4.2b)

| op | required fields | optional fields |
|----|------------------|------------------|
| `loft` | one profile source: **either** `profileA` + `profileB` ([[x,y],...], >=3 pts each) **or** `profiles` (see below) | `zA`/`zB` (heights of A/B, default 0/1); `zs` (heights for raw-polygon `profiles`) |

Loft closed planar profiles into a solid. Two forms:

- **`profileA` + `profileB`** — a 2-section **ruled** loft (CADSF-215): straight
  lines are ruled between arc-length-matched points → degree-1 (bilinear) B-spline
  side faces. `zA`/`zB` are the two profiles' z-plane heights (default 0/1).
- **`profiles: [ { pts:[[x,y],...], z }, ... ]`** (or a bare array of polygons
  `[[[x,y],...], ...]` with a parallel `zs: [...]`) — a **cubic** loft through
  **≥3 sections** (CADSF-216): matched section points are interpolated by a cubic
  (degree-3) B-spline in the loft direction → degree-3 `B_SPLINE_SURFACE_WITH_KNOTS`
  side faces that pass through every intermediate section (e.g. square → rounded
  square → circle). 2 sections routes to the ruled path.

Profiles may have the **same or different vertex counts**; correspondence is by
normalised arc length with default rotational seam alignment, and winding is
handled for you (profiles may be CW). Both forms are watertight by construction
(shared EDGE_CURVEs, outward normals) and STEP round-trip clean. Explicit user
guide points are a follow-up ticket. Give `profileA`/`profileB` **or** `profiles`,
not both.

### Transform / copy / mirror / pattern (`ops/transform.js`)

| op | required fields | optional fields |
|----|------------------|------------------|
| `copy` | `of` | — |
| `transform` | `of` | `translate` ([tx,ty,tz]), `rotate` (general, below) or `rotateZ` ({angle, center:[x,y,z]}) — one rotation spelling or the other, never both; applied rotate-then-translate |
| `mirror` | `of`, `normal` ([nx,ny,nz], plane through origin) | — |
| `pattern` | `of`, `type` (`"linear"` \| `"circular"` \| `"directions"`), `count` (integer >=2) | `step` ([dx,dy,dz], linear), `center` ([x,y,z], circular/directions, default [0,0,0]), `totalAngle` (radians, circular, default 2π), `directions` ([[x,y,z], …], directions) |

**`directions` — the orientation pattern (CADSF-725).** N instances on N
direction vectors, each turned to point along its own: the pattern every
feature-on-a-curved-surface part wants (teeth on a sphere, nozzles on a dome,
an icosahedral placement), which used to cost one `transform` per instance.

Instance *i* is the seed **rigidly rotated about `center`** by the minimal
rotation carrying `directions[0]` onto `directions[i]`. So instance 0 is the
seed itself, untouched, and the seed must be authored lying along
`directions[0]` — exactly as a circular pattern's seed must be authored at angle
0. Because the motion is a rotation about `center`, a seed at distance r from
`center` stays at distance r: point the tooth outward once and every instance is
both moved to its place and turned to face outward. `count` must equal
`directions.length` (one vector per instance, instance 0 included); a mismatch
is refused rather than resolved in favour of either. Antipodal directions pick a
deterministic perpendicular axis, so the pattern rebuilds identically on replay.
The whole result is consumable as one boolean `tool` via `"<opId>#*"`.

**`rotate` (CADSF-534).** The general rotation, in two spellings — give one or
the other, never both and never neither:

    rotate: { rx?, ry?, rz?, center? }        radians about the WORLD X/Y/Z axes,
                                              applied X first, then Y, then Z
    rotate: { axis: [x,y,z], angle, center? } radians about an arbitrary axis
                                              (the axis need not be unit length)

`center` is the fixed point. It defaults to **[0,0,0] for `transform`** (the
world origin — what `rotateZ` has always meant by an omitted centre) and to
**the primitive's own `origin` for a primitive verb**, so `{ op: 'cylinder', r:
5, h: 20, rotate: { rx: 1.5708 } }` lays that cylinder on its side instead of
swinging it around the world origin. `rotateZ` is unchanged and still accepted;
`rotate: { rz: a }` is the same rotation.

`pattern` keeps the original solid (instance 0) and produces `count-1`
clones; the report groups them all under the op's `id`.

**Circular pattern tolerance snapping (CADSF-3099).** For circular patterns,
`totalAngle` within 1e-5 radians of 2π is snapped to exactly 2π and reported.
When `totalAngle` is not a full turn (|totalAngle - 2π| > 1e-5), the `count`
copies are spaced by totalAngle/(count-1) rather than totalAngle/count; in this
case, a warning is issued if the first and last copies coincide within 1e-5
radians — a partial pattern whose sweep rounds to a full turn and silently places
the last copy on the first.

### Booleans (`ops/boolean.js`) — EXPERIMENTAL (WI-3.6 / CADSF-94)

| op | required fields | optional fields |
|----|------------------|------------------|
| `subtract` | `of` (base id), `tool` (tool id, group ref, or array) | `keepInputs` (bool, default `false`) |
| `union` | `of` (base id), `tool` (tool id, group ref, or array) | `keepInputs` (bool, default `false`) |
| `intersect` | `of` (base id), `tool` (tool id, group ref, or array) | `keepInputs` (bool, default `false`) |

**Several tools in one op (CADSF-725).** `tool` takes any of three spellings:

```jsonc
"tool": "pin"                      // one body — unchanged
"tool": "p6#*"                     // every body of op p6 — a whole pattern group
"tool": ["a", "b#1", "p6#*"]       // an explicit list; entries may be groups
```

The tools are folded into the base **in array order** (within a group, build
order), which is what a caller was doing by hand with N chained calls — except
it happens inside one op, so the intermediates are never tessellated, never
reported and never handed back as handles to thread. Sequential folding is the
right semantics for all three verbs: subtracting tools one at a time equals
subtracting their union, uniting them one at a time equals uniting the lot, and
intersecting one at a time equals intersecting them all (an intersect that goes
empty stops there and reports `empty`). Duplicate entries are applied once. The
result entry carries `toolCount` when more than one tool was folded; a
single-string `tool` is the length-one case and its result is unchanged, field
for field. `_boolProv` is the LAST fold's survivor/split map — the lineage the
surviving shell actually has.

Analytic booleans on the plane/quadric subset: the tool must **pierce planar
faces of the base**, with every intersection curve a line or circle (drill a
hole through/into a box, cut a slot, add a boss sticking out of a face).
`intersect` (A ∩ B, CADSF-230) takes the exact axis-aligned box path when both
operands are AABBs, else clips the base by each face half-space of a **convex
planar tool** — exact and watertight for arbitrarily-oriented convex tools
(rotated blocks, any-angle prisms). The
executor refuses these ops unless run with **`--experimental`**
(`node cli.js --run doc.json --experimental`); unsupported overlap patterns
fail with a descriptive error rather than producing bad geometry. By default
(**Combine-Add semantics** — the mainstream MCAD unite/combine-add convention) the
op produces **ONE** body and both input solids are *consumed* (soft-deleted) —
the result re-uses their faces, so referencing `of`/`tool` ids after a boolean
is undefined. Pass **`keepInputs: true`** to retain the operands alongside the
result (the "keep input bodies" toggle in the viewer's op dialog); note the
whole-document topology then carries both the inputs and the merged body.

**Not yet a command-schema op: selective/partial boolean + cell decomposition
(CADSF-236).** `ops/cellBool.js` (re-exported from `ops/boolean.js` as
`decomposeCells`/`selectiveBoolean`) imprints two operands once, classifies
the arrangement into cells, and lets the caller pick an arbitrary subset to
keep — a strict superset of `subtract`/`union`/`intersect`, each of which is
one fixed cell selection. It exists at the `ops`/`@cadsolver/kernel` layer
today (`cad.ops.boolean.selectiveBoolean(...)`); it is not wired into this
JSON command schema or `commands/executor.js`, so there is no `op` name for
it here yet. See [docs/SELECTIVE_BOOLEAN.md](../docs/SELECTIVE_BOOLEAN.md).
Similarly, `ops/offset.js`'s general body offset (`offsetBody`, uniform or
per-face variable distance, CADSF-262) is ops-layer only — no command op
exposes it.

### Modify (`ops/blend.js`, `ops/chamfer.js`, `ops/surgery.js`) — CADSF-188

Thin executor wrappers over the existing modify ops, the same code paths the
browser-entry "Blend all edges" / "Chamfer all edges" buttons already use.
Every op here **retires** its `of` solid (the input is soft-deleted the same
way `subtract`/`union` consume their operands) — reference the op's own `id`
afterward, not the source id.

| op | required fields | optional fields |
|----|------------------|------------------|
| `face_blend` | `of`, `left`, `right` (each a **face selection** naming ONE face - a raw STEP face id or a selector object such as `{ "normal": [0,1,0], "smallest": true }`) | `middle` (the face chain the band CONSUMES between the two sides; DERIVED by walking the planar faces between them when omitted, and an ambiguous derivation refuses `face-blend-chain-ambiguous` listing every candidate rather than guessing); `radius` (>0) - **omit it** with exactly one `middle` face and the radius is SOLVED from the three-face tangency, which is the FULL ROUND (SolidWorks' Full Round Fillet has no radius field either) and comes back on `analytic.radius` with `analytic.solvedRadius: true`; `crossSection` (`"rolling-ball"` (default, the only one built) \| `"constant-width"` \| `"disc"`); `holdLine` (a curve id - recognised and refused by name, CADSF-1818) |
| `blend` | `of` | `radius` (constant fillet), OR `corner` + `mid` (tip-weighted fillet), OR `min` + `max` with optional integer `lobes` >= 1 and `phase` in radians (variable-radius rim fillet, CADSF-392 — the picked `edges` must form a circular rim: a bore mouth or a blind hole's floor), OR `law` (a **law expression** `r(t)` in the rim angle, CADSF-266 — see [Laws](#laws-expression-driven-parameters) below) — exactly one of the four forms is required, mixing them is refused; `edges` (an **edge selection** — see [Declarative edge/face selectors](#declarative-edgeface-selectors-cadsf-633) — a raw STEP edge id, a selector object such as `{ "direction": [0,0,1], "convexity": "convex", "expect": 4 }`, or a list mixing both) to fillet ONLY those edges; `continuity` (`"G1"` \| `"G2"` \| `"G3"`, default `"G1"` — CADSF-433, the CROSS-SECTION axis) and `rho` (0 < rho < 1, the conic-rho shape parameter, only with `continuity` `"G2"`/`"G3"`); **grouped blend** (CADSF-542, all three require `edges`): `perEdge` (`[{ "edge": "#26", "radius": 6 }, …]` — a radius for named edges of the selection, the rest take `radius`/`corner`), `order` (`"auto"` (default, largest radius first) \| `"input"`), `setback` (`"auto"` only); **variable-radius chain** (CADSF-1043, requires `edges` naming an open chain): `variable` (`{ "radii": [2, 6], "law": "linear" }` — one radius control per chain VERTEX, `law` `"linear"` (default) \| `"smooth"`) |
| `chamfer` | `of`, `distance` (>0) | `edges` (the same **edge selection** `blend` takes — raw ids, selector objects, or both); `propagate` (boolean, default `true` — expand each pick along its tangent-continuous chain) |
| `ease_edges` | `of`, and `radius` (>0) OR `rule` | CADSF-101389 designer edge treatment: fillets every EXPOSED edge (convex, interior dihedral under `sharpDeg`, default 170) and none on a MATING face. `rule` is a material kind (`hardwood`/`softwood` 2 mm, `plywood` 1 mm, `veneer-laminate` 0.8 mm, `aluminium`/`plastic` 1.5 mm) or a furniture-construction `finish-edge-*` entry id; `steel` / soft parts refuse `ease-rule-no-radius`. `excludeFaces` (raw face ids: mating faces named directly); `mateWith` (`"auto"` default: faces TOUCHING any other live body per ops/contacts.js; `"none"`; or a body ref / list); `fallback` (`"chamfer"` default \| `"none"`), `chamferDistance` (>0, default = radius). Retires `of`; the entry's `ease` block names `method` (fillet \| chamfer \| none), `eased` / `excluded` / `skipped` edge ids, `refusals[]` and `remaining` |
| `shell` | `of`, `thickness` (>0) | `open` — a **face selection** (a raw STEP face id, a selector object such as `{ "normal": [0,0,1] }`, or a list mixing both) naming the faces to remove. Omitted, or an empty list, means a sealed hollow (CADSB-460) |
| `split_plane` | `of`, `point` ([x,y,z], any point on the cut plane), `normal` ([nx,ny,nz]) | `keep` (`"above"` \| `"below"`, default `"above"`) |
| `split_face` | `of`, `faceId` (raw STEP entity id string, viewer face pick), `point` ([x,y,z], any point on the cut plane), `normal` ([nx,ny,nz]) | — |
| `split_periodic_face` | `of`, `faceId` (raw STEP entity id string, viewer face pick — must be a periodic single-seam CYLINDRICAL_SURFACE face) | `angle` (radians, default π — diametrically opposite the existing seam) |
| `split_edge` | `of`, `edgeId` (raw STEP entity id string, viewer edge pick) | `point` ([x,y,z], snapped onto the edge's own curve) OR `t` (traversal parameter, strictly inside `(0,1)`) — exactly one of the two is needed |
| `delete_face` | `of`, `faceId` (ONE face: a raw STEP entity id string, e.g. `"#45"`, from a viewer face pick — NOT a command-doc op id — or, CADSF-1851, a declarative face selector that must match exactly one face, e.g. `{ "surface": "plane", "normal": [1,0,1] }`; see the one-face note under the freeform verbs) | `heal` (boolean, default `false` — heal the wound by extending the neighbouring faces instead of re-capping it, CADSF-537) |
| `move_face` | `of`, `faceId` (raw STEP entity id string, viewer face pick — must be PLANAR) | at least one of `translate` ([dx,dy,dz] mm), `offset` (mm along the face's own outward normal), `rotate` (`{ axis: [x,y,z], angle: <radians>, origin?: [x,y,z] }` — note `origin`, not `center`); plus `check` (`"none"` \| `"local"` (default) \| `"full"`) |
| `offset_faces` | `of`, `faces` (a NON-EMPTY **face selection** — the SET), `distance` (mm, signed, non-zero: + grows the solid outward along each face's own normal) | `propagate` (`"tangent"` (default — tangent-connected faces ride along, a fillet band r becomes r±d) \| `"none"`); `tangentDeg` (tangency threshold, degrees); `check` (`"none"` \| `"local"` (default) \| `"full"`) — CADSF-1619 |
| `replace_face` | `of`, `faceId` (ONE face: raw id or one-face selector) and EXACTLY ONE of `surface` (`{ kind: "plane", origin, normal }` \| `{ kind: "cylinder", origin, axis, radius }`) or `withFace` (a face whose PLANE/CYLINDER surface is copied — raw id on any body, or a one-face selector on this body) | `offset` (mm — a plane along the replaced face's outward normal, a cylinder's radius); `check` (`"none"` \| `"local"` \| `"full"` (default)) — CADSF-1619 |
| `constrain_face` | `of`, `faceId` (ONE face: raw id or one-face selector), `relation` (`"coplanar"` \| `"parallel"` \| `"perpendicular"` — two planes; `"coaxial"` — two cylinders), `to` (the reference face: raw id on any body, or a one-face selector on this body) | `offset` (mm, coplanar/parallel only — the target plane shifted along the moved face's outward normal); `method` (`"replace"` default for a plane \| `"move"` default for a cylinder \| `"offset"`); `pivot` ([x,y,z], a turn's pivot; default the face's boundary centroid); `check` — CADSF-100944, `ops/constrainFace.js` |
| `dimension_face` | `of`, `faceId` (ONE face), `value` (mm, or radians for `angular`) | `from` (the reference face; required for `linear` / `angular`, unused by `radial`); `kind` (`"linear"` \| `"angular"` \| `"radial"`; inferred when omitted); `method`, `pivot`, `check` as `constrain_face` — CADSF-100944 |
| `defeature` | `of`, and at least one of `holeDiameter` / `bossDiameter` / `filletRadius` / `chamferDistance` (mm size thresholds) or `faces` (a face selection: suppress the blend networks those faces belong to) | `blendNetworks` (bool, default `true`: fillets go as whole networks — bands + sphere/torus corner patches — restored to the sharp corner by `ops/blendSuppress.js`); `maxPasses` (int) — confident features only; `report.removed` / `report.skipped` (every kept feature by named reason) and `analytic.verification` (validity before/after, volume/area delta) — CADSF-1623 |
| `draft` | `of`, `faces` (a NON-EMPTY **face selection** — raw ids, selector objects, or both), `neutral` (`{origin, normal}` \| `{face}` \| `{points}` — exactly one spelling) | `angle` (radians, every selected face) and/or `angles` (`{ "<faceId>": radians }`, per face — together they must cover every entry of `faces`); `pull` ([x,y,z], default the neutral plane's normal); `parting` (boolean — split a face that straddles the neutral plane); `strictCheck` (boolean) |

`blend` mirrors `ops/blend.js`'s `blendConvexEdges` contract: convex edges of
a convex solid (box or convex polyhedron) get rounded; a non-convex or
non-planar-faced input throws a descriptive error rather than silently
no-op'ing.

With `edges` supplied, `blend` instead routes to `ops/blend.js`'s
`blendSelectedEdges` (CADSF-135): only the listed straight, convex edges are
filleted and the rest stay sharp. Two cases are supported:

- **Independent edges** — pairwise vertex-disjoint, each end meeting a cap
  face perpendicular to the edge (always true for a box), so the fillet's end
  arc is absorbed into that cap.
- **Shared-vertex corners (CADSF-229)** — two or three selected CONVEX edges
  meeting at a common vertex blend with a proper corner patch, reusing the
  trihedral-corner sphere-octant construction from `blendConvexEdges`
  (CADSF-119): a real spherical-triangle corner face, closed either by the two
  (2-of-3) or three (3-of-3, the full local corner) selected edges' own end
  arcs. The 2-of-3 case additionally emits a small flat-ish "wedge" face
  bridging the sphere back to the un-filleted third edge's original vertex.
  Scope (correct-or-refuse): the shared vertex must be a simple 3-edge
  (valence-3), mutually PERPENDICULAR trihedral corner (box-like) with every
  edge there convex; a higher-valence vertex, non-perpendicular faces, or a
  concave edge sharing the vertex is rejected with a descriptive error rather
  than guessed at. See `ops/blend.js`'s `blendSelectedEdges` header and
  `tests/test_cadsf-229.js` for the analytic volume derivation.

#### Declarative edge/face selectors (CADSF-633)

`blend.edges`, `chamfer.edges`, `shell.open` and `draft.faces` used to take raw
STEP entity ids and nothing else. **A raw id does not exist until the body is
built, and every feature op replaces the body it edits**, so an id written into
a document is invalidated by anything that runs before it — which made two of
the first three gestures in any CAD tutorial ("fillet these four edges",
"shell, remove the top face") unauthorable from a static document.

Each of those fields now accepts, interchangeably:

- a **raw id string** (`"#26"`) — unchanged, still what a viewport pick emits;
- a **selector object** — names the entity by geometry;
- a **list mixing both** — a viewer pick plus a rule.

Selectors are resolved by `commands/selectors.js` **at op-execution time,
against the body as it then is**, so they survive a rebuild and every feature
that precedes them. Filters are ANDed; all are optional.

| Field | Kinds | Meaning |
|---|---|---|
| `normal` | face | outward normal within `tolDeg` of this direction |
| `direction` | edge | straight edge parallel to this, **unsigned** (an edge runs whichever way its vertices were authored) |
| `surface` | face | `plane` \| `cylinder` \| `cone` \| `sphere` \| `torus` \| `spline` \| `curved` \| `any` |
| `curve` | edge | `line` \| `circle` \| `ellipse` \| `spline` \| `curved` \| `any` |
| `convexity` | edge | `convex` \| `concave` \| `tangent` — the same dihedral test `ops/blend.js` applies, so a `convex` pick is one the fillet agrees is convex |
| `radius` | both | number, or `{ min, max }` — mm |
| `length` | edge | number or `{ min, max }` — mm (arc length for a circular edge) |
| `area` | face | number or `{ min, max }` — mm² |
| `at` | both | `{ x?, y?, z?, tol? }` — the entity's centre sits here |
| `bbox` | both | `{ min:[x,y,z], max:[x,y,z], tol? }` — the entity lies entirely inside this world box |
| `createdBy` | both | an earlier op's `id` (or a list of them) — "the faces the boss made" |
| `largest` / `smallest` | both | keep the extreme by area (faces) / length (edges); `longest`/`shortest` are the edge aliases |
| `topmost` / `bottommost` | both | keep the extreme in ±Z |
| `extreme` | both | `{ direction:[x,y,z], pick?: "max"\|"min" }` — the same, along any direction |
| `tolDeg` | both | angular slack for `normal`/`direction`, in DEGREES (default 5) |
| `expect` / `all` | both | **the count contract** — see below |

Pickers run **after** the filters: `{ "normal": [0,0,1], "topmost": true }` is
"the highest of the up-facing faces", never "the highest face, if it points up".

**The count is part of the selector.** `expect` defaults to **exactly one**;
`expect: 4` demands four; `expect: { min, max }` a range; `all: true` means
"however many match". This is refusal-first, and it is the reason a selector is
*safer* than a raw id rather than merely more portable — a document that says
"these four edges" and finds five refuses instead of quietly filleting the
fifth. Every failure carries a named `reasonCode` and lists the candidates it
considered, with the per-clause exclusion counts:

| `reasonCode` | When |
|---|---|
| `selector-no-match` | matched nothing (never treated as an empty selection) |
| `selector-ambiguous` | matched more than `expect` allows |
| `selector-underfull` | matched fewer than `expect` requires |
| `selector-bad-spec` | unknown field, zero-length vector, conflicting pickers, `{}` |
| `selector-unknown-op` | `createdBy` names an op that has not run |

The op's result entry carries a `selectors` array recording each selector and
the ids it resolved to, so a feature tree can show what a declarative selection
actually took.

```json
{ "op": "box",   "id": "box", "dx": 100, "dy": 60, "dz": 50 }
{ "op": "blend", "id": "rounded", "of": "box", "radius": 10,
  "edges": { "direction": [0, 0, 1], "convexity": "convex", "expect": 4 } }
{ "op": "shell", "id": "hollow", "of": "rounded", "thickness": 5,
  "open":  { "normal": [0, 0, 1] } }
```

`draft.angles` stays keyed by **raw id**: it names one specific face, which is
the one case a selector is not for.

#### Grouped blend: several radii in one call (CADSF-538 / CADSF-542)

`perEdge`, `order` and `setback` route the same `blend` verb to
`ops/blendGroup.js` — one selection, a radius per edge, and a stated
classification for every vertex where the picked edges meet (`free`, `tangent`,
`miter`, `ball`, `setback`, `mixed-radius`, `partial`, `unsupported`). All three
require `edges`: they are statements about a SELECTION, and the blanket
"round every convex edge" spelling has none.

```json
{ "op": "blend", "id": "f1", "of": "blk", "radius": 5, "edges": ["#47", "#57"],
  "perEdge": [{ "edge": "#47", "radius": 5 }, { "edge": "#57", "radius": 2 }] }
```

The ORDER is derived, not left to the caller, because it is geometric: a blend
band ends on whatever it runs into, and a large band cannot end on a small
finished one. Measured on a 40×40×20 box, two top edges at one corner:
`r2` then `r5` is **refused** ("radius 5 does not fit arc (radius 2)"), `r5`
then `r2` **builds**, `V = 31734.3466 mm³`. `order:"auto"` (the default) gets
the second answer from either spelling of the input; `order:"input"` applies
the levels as given and is refused **by name** when they do not fit
(`reasonCode: "BLEND_GROUP_MIXED_RADIUS_VERTEX"` on the failed op's result
entry), leaving the input body untouched.

`setback` accepts `"auto"` only: the setback distance at a vertex patch is
DETERMINED by the corner geometry (`ops/vertexBlend.js`, CADSF-261), so a
number is refused by name (`BLEND_GROUP_SETBACK_FIXED`). Passing `"auto"` is
not a no-op — it selects this route, with its classification report, for a
uniform-radius selection.

A grouped blend reports `blendGroup` on its result entry: the plan, the order
and why, the per-corner classification, and the per-level steps. Every refusal
carries a `BLEND_GROUP_*` `reasonCode`, so a caller branches on the code rather
than on prose.

**Variable-radius chain** (CADSF-1043, `ops/blendVariable.js`): `variable:
{ radii: [r0, r1, …], law?: "linear" | "smooth" }` with `edges` naming an OPEN
chain of straight convex edges between planar faces builds ONE band whose
radius changes along the chain — a control radius at every chain vertex (one
more value than edges; two for a single edge), linear in arclength between
them, or a C1 cubic that is flat at each control with `law: "smooth"`. Every
section is a circular arc of the local radius tangent to both faces, so the
band is G1 against its supports for any law; the spring lines are exact
(LINEs for the linear law, cubic B-splines for the smooth one), the band is a
`B_SPLINE_SURFACE_WITH_KNOTS` whose measured miss of the analytic band is
declared as an edge tolerance, each chain end stops on an exact circle in a
planar cap perpendicular to its edge, and each interior vertex — a convex
trihedral corner — is mitered along the traced intersection of the two bands
(where the two edges make different dihedrals against the shared face, the
perpendicular band's own section in the other support plane closes the
corner, exactly as the constant-radius kernel does). Measured on a 40×30×20
box edge with `radii: [2, 6]`: 149.02 mm³ removed against the analytic
∫(1−π/4) r(s)² ds = 148.79 (0.16 %, tessellation), section radius 3 / 4 / 5 at
x = 10 / 20 / 30. Anything outside that family — a closed loop, a curved edge
or support, a chain end with no perpendicular cap, a corner with no closing
section — refuses by name (`BLEND_VARIABLE_*`) with the input untouched.

The same band is the grouped route's FALLBACK: where a `perEdge` selection's
stepped plan refuses at a mixed-radius vertex (the `BLEND_GROUP_MIXED_RADIUS_VERTEX`
that was 303 of 314 `fillet_variable` refusals on the robustness subset — a
prismatic side-face loop run whose second level cannot end on the first
level's band), `order: "auto"` offers the selection to the variable band
with the per-edge radii as controls (an end takes its edge's radius, an
interior vertex the mean of its two) and returns `plan: "variable"` with
`fallbackFrom`/`steppedRefusal` when it builds; `order: "input"` keeps the
named refusal, and a refusal that stands now carries `variableRefusal` saying
why the band declined too. Either way the result entry reports
`blendVariable` (law, controls, corners, declared deviations).

The viewer's interactive edge picking (CADSF-192's selection subsystem,
`web/src/selection.js`) resolves picked edges to real STEP `EDGE_CURVE` ids
via a face-pair → edge map built off the live model's `Topology`
(`browser-entry.js`'s `modelResolveEdgeIds`), and the "Blend all edges" panel
button fillets exactly the current edge selection through this same
`blendSelectedEdges` path when one is active — an empty (or non-edge)
selection still blends every edge, unchanged.

`shell` (CADSF-160, `ops/shell.js`) hollows a PLANAR-faced, straight-edged
solid to a wall thickness, removing the `open` faces (raw STEP ids from viewer
picks, like `delete_face`) so the part is open — the moulded/cast-part idiom.
Inner walls come from per-vertex offset-plane intersection (open faces offset
by zero, so the cavity runs out through the opening); openings become flat rim
annuli. Correct-or-refuse: it throws when the thickness exceeds half the
smallest feature width (the same scan doubles as a min-wall DFM report in
`report.walls`/`report.minWall`), when an inner wall would flip or degenerate,
when a >3-face corner cannot offset consistently (e.g. opening a face of an
octahedron), on curved solids, faces with holes, or a fully closed hollow
(BREP_WITH_VOIDS is not emitted yet).

#### Continuity: G1 / G2 / G3 cross-sections (CADSF-433)

`radius` says how big the blend is; `continuity` says what SHAPE spans it. The
two are orthogonal (the same split SMLib makes between its fillet *solver* and
its *cross-section* class):

| `continuity` | cross-section | what matches at the seam |
|---|---|---|
| `"G1"` (default) | exact circular arc — `CYLINDRICAL_SURFACE`/`CIRCLE` | tangent plane |
| `"G2"` | degree-4 Bézier, zero end curvature | tangent plane + curvature |
| `"G3"` | degree-6 Bézier, zero end curvature AND curvature rate | tangent plane + curvature + dκ/ds |

A `"G2"` blend of radius `r` has exactly the same FOOTPRINT as the `"G1"` blend
of radius `r` — the same tangency rails on the same faces — and by default the
same depth as well (`rho` defaults to the circular arc's own conic rho,
`sin(γ/2)/(1+sin(γ/2))`, 0.4142 at a 90° corner). It differs only in having no
curvature step at the seam: no light line under a zebra/reflection check.

`rho` overrides that default: **smaller rho = fuller** (nearer the chord),
larger = sharper (nearer the corner). Each level has a band it can represent at
a given corner angle; outside it the kernel refuses by name and quotes the band.

Scope today: constant radius, straight edges, planar supports, ends running out
onto planar cap faces. A selection whose edges MEET at a vertex, a rim/tangent
chain, a curved support, a run-off end or a variable-radius law is a NAMED
refusal, not a silent downgrade to G1.

```json
{ "op": "blend", "id": "soft", "of": "block", "edges": ["#26"],
  "radius": 3, "continuity": "G2" }
```

`chamfer` mirrors `ops/chamfer.js`'s `chamferConvexEdges` — a flat 45° bevel
twin of `blend` with a single symmetric setback distance (asymmetric
`distA`/`distB` chamfers are not yet exposed at the command-schema level).

`split_plane` keeps ONE half (`keep`, default `"above"` = the side the plane
normal points toward) and discards the other; both the pre-split solid and
the discarded half are retired so `save` only emits the kept piece. Wraps
`ops/surgery.js`'s `splitByPlane`.

`delete_face` removes one planar face and re-caps the opening watertight
(`ops/surgery.js`'s `deleteFaceAndCap`). Because `faceId` addresses a raw
STEP entity rather than an op id, it is only meaningful once a solid already
exists in the session (i.e. from an interactive face pick in the viewer, or
a prior `save`+re-parse) — it cannot be authored blind in a hand-written
command file the way other `of` references can.

#### Direct edit (`ops/moveFace.js`, `ops/healFace.js`, `ops/draft.js`, `ops/offsetFace.js`, `ops/replaceFace.js`) — CADSF-537, CADSF-1619

`delete_face`'s `heal` flag picks which operator closes the wound. **Default
(`heal` absent or `false`) re-CAPS**: the new face is a PLANE built on the
deleted face's own boundary loops and senses, so on a planar face it is the
face again — volume, area and face count all unchanged, and the op reports
success having moved nothing (CADSB-257 measured that on 81 of 109 deletions).
**`heal: true`** routes to `ops/healFace.js`'s `deleteFaceAndHeal`, which
extends the faces that SURROUNDED the deleted one until they close the opening
(deleting a 4 mm chamfer face off a 20x20x10 prism restores the sharp corner:
3920 → 4000 mm³ exactly), and REFUSES BY NAME when they never meet — a box's
top face has four parallel walls, so no healed shape exists and
`healface_neighbours_parallel` is the correct answer rather than a body. The
result reports `strategy` (which heal was found) and `removedFaceIds`.

`move_face` applies a **rigid motion** — translate, offset along the face's own
outward normal, and/or rotate — to one PLANAR face, regenerating every adjacent
face to follow. It is not `pushpull` with an angle: `pushpull` slides a
PRISMATIC cap and refuses anything else, while `move_face` can TILT a face and
carries neighbours that are not parallel to the motion. A rotation is applied
first (about `origin`, default the face centroid), then the translation. Every
refusal is named and raised before anything is written — a non-planar face, an
edge curve the op cannot carry, a neighbour it cannot regenerate as a plane, an
edge that would collapse, a wall that would flip. The result reports
`deltaVolume` (exact, from the moved polyhedron) alongside
`analyticDeltaVolume` (area x normal displacement, equal to it only for a pure
prismatic offset), plus the `check` tier's findings.

`offset_faces` (CADSF-1619, `ops/offsetFace.js` `offsetFaces`) is **Offset
Region**: a SET of faces moves by one signed `distance`, each along its own
outward normal, in ONE solve — so two faces of the set that share an edge carry
that edge diagonally with both (offsetting a 40x30x20 box's top and +x wall by 2
gives exactly 42x30x22), which two successive single-face offsets cannot do.
Every face tangent-connected to the set rides along (`propagate: "tangent"`, the
default): a fillet band of radius r becomes r+d and stays tangent. The faces
around the set keep their surfaces and are re-trimmed; plane, cylinder, cone,
sphere and torus faces offset exactly.

`replace_face` (CADSF-1619, `ops/replaceFace.js`) is **Replace Face**: one
face's surface is swapped for an explicit plane/cylinder (`surface`) or for
another face's surface (`withFace`, optionally shifted by `offset`), and the
boundary is RE-DERIVED against the unchanged neighbours — the side walls stay
exactly where they were and run further or shorter, unlike `move_face`, which
tilts them. Swaps: plane→plane, plane→cylinder (crown/scoop), cylinder→plane.
A plane's normal is flipped automatically to the replaced face's outward side.

`draft` tapers selected walls away from a **neutral plane** — the mould-release
feature. Geometry lying IN the neutral plane does not move, which is what keeps
the parting line put; the plane's normal is also the default `pull`. It drafts
PLANAR faces with straight (LINE) edges, and — since CADSF-1067
(`ops/draftRevolve.js`) — FACES OF REVOLUTION: a cylinder becomes a
`CONICAL_SURFACE` about the parting circle (semi-angle = the draft angle), a
cone's semi-angle changes (re-drafting ADDS angles), the far rim is re-fitted at
its new radius, the planar faces that rim carries are re-trimmed (a boss top
shrinks, a bore grows away from its parting plane) and an adjacent fillet band is
dropped and RE-SOLVED at the same radius. A parting plane not perpendicular to
the axis is refused `draft-neutral-plane-off-axis`; one call drafts planar faces
OR faces of revolution, not a mix; a body whose faces are neither is refused
whatever you select (`ops/eligibility.js`'s `draft` probe greys those bodies in
the picker for the same reason). A face that STRADDLES the
neutral plane is refused unless `parting: true` splits it into two halves
tapering opposite ways, and `|angle| >= 90°` — or any angle that collapses or
inverts a face at this geometry — is refused rather than approximated. Both
volumes are computed exactly by the divergence theorem, so `deltaVolume` is a
measured number: one 20x20x10 wall drafted 5° about z = 0 gives
4000 − ½·10²·tan5°·20 = 3912.511 mm³.

All three follow `delete_face`'s raw-STEP-id discipline for `faceId`/`faces`,
so they are only reachable once a solid exists in the session. Every refusal
carries its kernel `reasonCode` on the failed op's result entry
(`results[i].reasonCode`) as well as inside the message.

#### Split / subdivide (`ops/surgery.js`) — CADSF-267

Production-kernel split-face / split-periodic-face / split-edge parity: unlike `split_plane`, none of these three remove material or separate
the solid — each retires `of` and publishes exactly ONE new solid (same shape,
extra topology). `faceId`/`edgeId` follow the same raw-STEP-id, viewer-pick
discipline as `delete_face`'s `faceId`.

`split_face` cuts ONE face along a plane into two (or more, if the cut crosses
the face's own boundary more than once) pieces sharing a new edge — the same
analytic section-curve solver `split_plane` uses (PLANE / CYLINDRICAL_SURFACE /
CONICAL_SURFACE), restricted to one face. A boundary edge the cut also crosses
gets its two new sub-edges spliced into whichever neighbour face shares it. A
vertex or edge lying exactly ON the plane is out of scope and refused by name
(unlike `split_plane`'s own coincidence handling — see `ops/surgery.js`'s
`splitFaceByPlane` header for why the two ops draw that line differently).

`split_periodic_face` targets the genuinely periodic single-seam form a
foreign STEP file can hand this kernel — one loop, one seam `EDGE_CURVE` used
twice — which `ops/primitives.js`'s own `cylinder()` never produces (it always
builds the two-half-face split form directly). Adds a second seam at `angle`
radians from the existing one and splits each rim there, leaving the same
two-face shape `cylinder()` builds fresh. Scoped to `CYLINDRICAL_SURFACE`
today; any other periodic surface, or a face that already has two seams, is
refused by name.

`split_edge` subdivides one `LINE`/`CIRCLE` edge into two at a point or
traversal parameter, sharing a new vertex. Both sub-edges reuse the original
curve verbatim — no new geometry, just one vertex and two `EDGE_CURVE`s — so
this is a pure topology edit: volume is EXACTLY unchanged, not merely within
tessellation tolerance.

```json
{ "op": "box", "id": "block", "dx": 20, "dy": 20, "dz": 20 },
{ "op": "split_face", "id": "cut", "of": "block", "faceId": "#45", "point": [10, 0, 0], "normal": [1, 0, 0] }
```

### Pad / Pocket (`ops/pad.js`) — CADSF-357

A **pad** extrudes a closed profile that lies ON a planar face of an existing
solid, and *merges the result into that solid*. The base face genuinely changes:
the profile becomes a new loop on it, the walls and cap join the same shell, and
the document still holds exactly ONE body. There is no boolean for the user to
run afterwards and no overlapping-bodies intermediate at any point.

This is the verb to use whenever the profile sits on a face. `extrude` builds a
FREE prism, which is correct for a profile on a datum plane in empty space and
wrong on a face — that combination is what produced a prism interpenetrating its
own base solid.

| op | required fields | optional fields |
|----|------------------|------------------|
| `pad` | `of` (base id), `face` (face selector — a raw STEP face id, `{normal:[x,y,z]}` or `{surface:"cylinder"}`), `length` (non-zero number) | a profile source — either `profile` (`{sketch:"<id>"}` or `{outer}`) OR a top-level `outer`, where a loop is `[[u,v],...]` (>=3 pts) **or one `{circle}`/`{slot}`/`{rect}` primitive** (CADSF-1179), **or a `{start, segments}` line/arc loop** (CADSF-2606) |

`length` is **signed, deliberately**: positive pads along the host face's true
outward normal, negative cuts a blind **pocket** into the material. It is the one
field in this schema where a negative magnitude is meaningful rather than an
error. Zero is rejected by the validator.

A `profile` given as `{sketch:id}` is read in that sketch's own plane and lifted
to world; a bare `outer` has no sketch frame, so it is read in the **host face's**
plane — the only interpretation that can be right for a verb whose premise is
"this profile lies on this face".

**Curved footprints are exact (CADSF-1179).** The loop takes the same native
primitives `extrude` does — `{circle:{cx,cy,r}}`, `{slot:{…}}`,
`{rect:{…,cornerRadius}}` (commands/profile-primitives.js) — and a sketch whose
entities curve is resolved through the curve-preserving path. Either way the arcs
are imprinted as real `CIRCLE` edges and the walls come out as
`CYLINDRICAL_SURFACE`, so a `{circle}` boss is ONE cylinder (two half faces) with
volume exactly πr²·length, not the two-dozen planar strips an N-gon spelling
leaves behind to fragment every later boolean and blend. A pocket's arcs run the
other way about its rail, which is the only difference between the two modes. A
straight-sided footprint is byte-identical to the pre-CADSF-1179 output.

**Why it is not `extrude` + `union`.** The profile already lies on the target
face, so the imprint curves are known exactly and no surface/surface intersection
is computed — OpenCASCADE calls this a local operation with *gluing*
(`BRepFeat_MakePrism`), and documents the payoff as not merely speed but
"avoiding errors in computation of intersections of tangential faces". A pad on a
face IS the tangential-face case, precisely where a general boolean is least
robust. Concretely in this kernel: `ops/genBool.js` refuses any solid with a
curved face (`curved_tool_face`), so a pad on the flat cap of a cone or frustum
is *impossible* through the general path and exact through this one.

**Preconditions**, each refused by name rather than approximated:

| reason code | meaning |
|-------------|---------|
| `nonplanar_host` | the host face is not a `PLANE` |
| `profile_off_plane` | the profile does not lie in the host face's plane |
| `profile_not_contained` | the profile is not strictly inside the host face's boundary, crosses it, or encloses an existing loop on it — that needs the general boolean |
| `profile_self_intersects` | the profile crosses itself, so it has no interior |
| `degenerate_profile` | fewer than 3 distinct points (2 when the loop carries an arc — a circle is two half arcs), zero enclosed area, or an arc whose endpoints are not equidistant from its centre |
| `zero_length` | length is zero within tolerance |
| `through_pocket_deferred` | a pocket deep enough to break through the far side — a boolean, not a local operation |
| `up_to_face_deferred` | up-to-face termination; `BRepFeat_MakePrism` is documented as unreliable for a non-planar up-to face, so the finite-length case ships first |

The result is gated through `ops/check.checkFull`: any error-severity finding
throws, so the caller either gets a valid watertight body or the original solid
untouched.

**Scope note.** A padded profile with HOLE loops is refused: each hole would need
its own imprint ring on the host face. Pad the outer loop, then pocket the holes.

### Blade / vane (`ops/blade.js`) — CADSF-1309

A **blade** is a whole turbomachinery aerofoil built as ONE solid body: an
N-section loft with twist, a **lofted** root fillet, a platform and a dovetail
root. It takes no `of` — like `extrude`/`revolve`/`loft` it BUILDS a body.

| op | required fields | optional fields |
|----|------------------|------------------|
| `blade` | `sections` (>=2, root first, radii strictly ascending), `platform` ({`thickness`, `width`, `length`}, mm) | `fillet` ({`radius`, `steps` default 4, `kind` `"lofted"` default \| `"blend"`}), `dovetail` ({`depth`, `neckWidth`, `baseWidth`, `neckFraction` 0.25, `flankFraction` 0.5, `chamfer`}), `join` (`"imprint"` default \| `"union"`), `points` (16), `leSteps` (8), `teSteps` (4), `spanLevels` (6), `stack` (`"mid"` default \| `"centroid"` \| `"le"` \| `"te"`), `lean` / `leanDeg`, `sweep` / `sweepDeg`, `bow`, `bowAxial` (CADSF-101413, mm / deg, default 0), `tip` (`"flat"` default \| `"squealer"`), `squealerRim`, `squealerDepth` (mm; default 1/4 and 1/2 of the tip section's max thickness), `miterLimit` (2), `embed` (0; required > 0 for `join:"union"`), `footMargin` (0.2), `root` (`"dovetail"` \| `"firtree"` \| `"none"`), `firtree`, `shank`, `angelWings`, `platform.overhangFore` / `overhangAft` (CADSF-101414), `cooling` (CADSF-101415) |

**Stacking line (CADSF-101413).** `lean` (tangential, +x) and `sweep` (axial, +y) are the stacking line's offset at the tip relative to the hub (section 0's r). They can be given in mm or as `leanDeg` / `sweepDeg` (|angle| <= 45, offset = span x tan); passing both spellings of one is refused. `bow` / `bowAxial` (mm) move the mid-span point off the straight line, which makes the line a natural cubic through hub, mid-span and tip (compound lean). The line passes through the section point named by `stack`. Omit these params, or set them to zero, for a radial stack.

**Root (CADSF-101414).** `fillet.kind: "blend"` makes the root fillet a true rolling-ball blend between the platform top and the aerofoil (ops/blendPlaneSplineLoop.js, run after the join). It is tangent to both, holds one radius, and needs the smooth section, a welded blade and `join: "imprint"`. If the blend refuses, the op falls back to the lofted fillet; `report.fillet.kind` says which one ran. Omitted or `"lofted"` gives the lofted band as before. `root` picks the form below the platform: `"dovetail"` (the `dovetail` object), `"firtree"` or `"none"`. Without `root`, a `dovetail` object means dovetail and nothing means platform only. The `firtree` object takes `lobes` (default 3); `neckWidth` / `lobeWidth`, the minimum neck width and maximum lobe width of the top lobe (defaults 0.30 / 0.44 of the platform width), or `neckWidths` / `lobeWidths` per lobe; `wedgeAngle` (included taper, default 16°); `flankAngle` (the contact flank, default 40° from the tangential direction); `lowerFlankAngle` (default 45°); and `lobeRadius` / `neckRadius`. Every flank is a plane and every round a cylinder. `shank` (`height`, `topWidth`) is a tapered neck between the platform and the root. `platform.overhangFore` / `overhangAft` (mm; give both or neither) run the platform past the shank axially, and the shank is then welded under it. `angelWings` (`fore`, `aft`, `drop`, `length`, `thickness`, `lip`, `width`) adds axial seal lips on the shank's fore and aft faces. Bad values are refused as `blade_root_invalid`.

**Cooling (CADSF-101415).** `cooling` turns the blade into an internally cooled one; omit it for a solid blade (unchanged). The cavity is a serpentine of `passes` (default 3) radial passes, inset `wall` mm (default 1.0) from the skin, measured along the skin normal in 3D. The passes are separated by ribs `ribWidth` mm thick (default 1.5 x wall) and join through 180° turns `turnHeight` mm tall (default the pass width), alternating tip and hub. The passes stop where the section is thinner than 2 x `wall` + `minPassage` (default 1.0 mm). The cavity floor sits `hubGap` above the platform top (default the larger of the wall and the root fillet radius) and its ceiling `tipWall` below the tip, or below the squealer floor if there is one. Pass 1 is fed by a straight inlet (`inlet`, default true) from the root's bottom face. The inlet is a parallelogram prism, slanted if it has to be, and its walls are checked to keep `wall` of metal to the root faces. `showerhead` ({`rows` 3, `diameter` 0.5, `pitch` 5, `angle` 30, `rowSpacing`}) adds round holes from the nose into pass 1, laid back radially. `pressureSide` ({`rows` 2, `diameter` 0.6, `pitch` 4, `angle` 30}) adds rows from mid-pass out through the pressure side, laid back downstream. `teSlots` ({`width` 2.5, `height` 0.6, `pitch` 4, `exitChord` 0.88}) adds stadium slots from the last pass out through the pressure side ahead of the trailing edge. Every face is authored, not booleaned: the passage walls are sub-patches of two inset bicubic surfaces, the rib and end walls are ruled surfaces, the caps are planes, and the hole walls are cylinders and planes. Each hole's footprints on the skin and in the passage are interpolated through exact line/surface intersections. So the body stays one watertight solid. `report.cooling` lists the cavity, the inlet, every hole (mouth, landing, axis) and the timings. Bad values, a passage that does not fit the wall, a hole that would straddle two faces or run into another, the polygon path and `emit` are refused as `blade_cooling_invalid`.

**Squealer tip (CADSF-101413).** `tip: "squealer"` cuts a recess into the tip cap and leaves a rim `squealerRim` mm thick all the way round it. The recess is `squealerDepth` mm deep. The recess outline is the tip section offset inward by the rim, with the offset's swallowtails at the LE and TE cut off; those cut points are the corners. Each piece of the outline is fitted as a cubic B-spline curve. Each wall is a ruled B-spline down to a planar floor, and the floor follows the section's drift, twist and taper over the depth. The body stays one watertight solid with exact faces. Default rim = 1/4, depth = 1/2 of the tip section's max thickness. `blade_squealer_invalid` refuses a rim of 0.4 x the tip thickness or more, a depth over 1/5 of the aerofoil span, an unknown `tip`, and the polygon section path. `tip` omitted or `"flat"` is the flat cap, unchanged.

**Frame.** `x` = TANGENTIAL, `y` = AXIAL, `z` = RADIAL. The blade is authored
standing along +z at radius, which is the convention
`reference-parts/boom-sprint-core` already lofts blades in: place a row with one
`transform` (rotate the local +z onto the radial direction) and one circular
`pattern`.

**Sections.** `{ r, chord, thickness (t/c), camber (fraction of chord),
camberPos (x/c of max camber, 0.4), stagger (RADIANS from the AXIAL axis — the
turbomachinery convention, and varying it section to section IS the twist),
teThickness (t/c, 0.012), offset ([tangential, axial] lean/sweep of the stacking
point) }`. Parameters between the given sections are interpolated linearly in
radius. **Section 0's radius is the PLATFORM TOP**: the platform and dovetail
hang below it and the aerofoil stands on it.

**The root fillet is LOFTED, not blended.** On a ruled/lofted blade no edge
passes the convex-dihedral test, so `blend` with `convexity:"convex"` returns
selector-no-match, a selector that does resolve leaves one live incremental
solid per filleted edge, and whole-body `blendConvexEdges` neither refuses nor
finishes (kb: *blend on ruled-loft blade edges*, seen on CADSF-1490). So the
fillet here is GEOMETRY THE LOFT OWNS: a quarter-circle band of extra sections,
each the root aerofoil offset outward by `R - sqrt(R^2 - (R-h)^2)`, sampled at
`fillet.steps` planes. It cannot refuse, and the section trace is checked for
self-intersection (`blade_fillet_self_intersects`) before anything is built.

**The join is an IMPRINT, not a boolean.** The aerofoil's flat foot ring lies
exactly on the platform's top plane; that ring becomes a new inner bound of the
top face (sharing the SAME `EDGE_CURVE` entities) and the two shells merge into
one — watertight by construction, the local operation `ops/pad.js` documents.
`join: "union"` selects `ops/boolean.js`'s pierce union instead and then needs
`embed` > 0; it produces the same body and is far slower (it tessellates every
B-spline band twice — measured 10.7 s for a 2-section 40-point loft against a
box, and past 115 s for a 10-section blade).

**Resolution and STEP size.** The aerofoil's leading and trailing edges are
explicit ARCS (`leSteps`, `teSteps`) — physically right (a knife edge is
uncastable, see the `investment_casting` DFM pack) and numerically necessary,
because the fillet trace is a mitred polygon offset and a cusp would spike it.
Section stacks share ONE identity correspondence, so the loft has exactly
`points-per-section` columns; the body's face count is
`pointsPerSection * (sections - 1) + 1 + rootFaces`.

**Refusals** (all by `reasonCode`): `blade_sections_invalid`,
`blade_sections_not_ascending`, `blade_fillet_self_intersects`,
`blade_fillet_exceeds_platform` (carries the platform width/length it would
need), `blade_platform_invalid`, `blade_dovetail_invalid` (a dovetail must be
WIDER at the base than at the neck), `blade_embed_invalid`, `blade_join_refused`,
`blade_wall_deferred`.

**Scope.** This operator is SOLID and UNCOOLED, which is correct for compressor
blades and VSV vanes. `wall` — the design-wall parameter of a COOLED turbine
aerofoil — is refused by name (`blade_wall_deferred`) and belongs to the
COOLED-AIRFOIL epic (CADSF-1318 / CADSF-1320), which extends this operator's
section schedule rather than forking it.

#### Whole rotors: `rotor` (CADSF-101544, TPI [3])

`{ op: "blade", id, rotor: { blades, kind?, meridional, beta, thickness, splitters?, ruled?, wrapMatch?, rakeDeg?, leanDeg?, backplate?, hub?, shroud?, process? } }`
builds a whole impeller, inducer or radial-turbine wheel as ONE body (`sections` / `platform` are then not needed).
Axis +Z, flow enters at the LE plane. `meridional` is hub + shroud (z, r) polylines LE -> TE, or the parametric
`{form:"radial", r1h, r1s, r2, b2, axialLength?, rKnee?}` / `{form:"axial", rHub, rTip, length}`; `beta` is in degrees
from MERIDIONAL (`leHub`, `leShroud`, `teHub`, `teShroud`, or `le` / `te`; `te` is the backsweep); `thickness` is the
normal blade thickness (mm). The core is one index-matched loft of axial "star" sections (hub circle + blade teeth),
welded on planes to the splitter stack, the backplate (with `scallop`) and the rear boss (with balance lands), and
bored by surgery - no surface/surface boolean, so a 7-blade wheel builds in about a second. `shroud` (closed wheel,
`{thickness?, stock?}`) is built without a boolean too: the shroud profile is revolved, each flow passage (one loft)
is pierced through its two end faces by surgery, and the open exit width is welded on at the shroud TE plane - a
closed ISO 2858 pump impeller builds in about 4 s. A closed wheel with splitters is refused by name
(`rotor_shroud_union`), a shroud thinner than the process wall as `rotor_wall_below_limit`. Other refusals: `rotor_spec_invalid`, `rotor_contour_invalid`, `rotor_blades_overlap`,
`rotor_backplate_invalid`, `rotor_weld_refused`. The result report carries `report.rotor` (blade angles spec vs
measured, wrap, thickness vs the process wall, parts, timings); `ops/turbo/rotor.js` `checks()` adds the B-rep
throat and closure oracles and `fromMeanline()` sizes a spec from `ops/turbo/meanline.js`. Tests:
`tests/test_tpi-3_rotor.js`.

### Rib (`ops/rib.js`) — CADSF-264

A **rib** takes an OPEN sketch profile (a polyline, not a closed loop), plus a
thickness, and thickens it into a thin standing wall that is unioned into a
base solid — the classic rib dress-up feature (stiffener
webs, gussets, mounting-boss spines).

| op | required fields | optional fields |
|----|------------------|------------------|
| `rib` | `of` (base id), `thickness` (>0), `height` (>0), plus a profile source — either `path` ([[x,y],...], >=2 pts) OR `profile` (see below) | `side` (`"both"` default \| `"left"` \| `"right"`), `draft` (radians, default 0), `embed` (mm, default: auto ~2% of the base's bbox diagonal), `attachZ` (world Z of the base's flat attach face, default 0), `direction` (`"linear"` default; `"normal"` is accepted but **not implemented** — throws) |

`rib`'s profile source is exactly one of:

- **inline `path`** — `[[x,y],...]`, an OPEN polyline (>=2 points, do not close it).
- **`profile: { sketch: "<id>", entity: "<entityId>" }`** — pull ONE OPEN
  entity's world-mapped polyline out of a sketch built earlier in the
  document (arcs/circles are pre-discretized by `Sketch.toWorld()`). Unlike
  `extrude`'s `profile.sketch` (which consumes `profiles()`'s CLOSED loops),
  `rib` needs the raw OPEN path, so it reads `sketch.toWorld()` and rejects a
  CLOSED entity with a descriptive error. `entity` may be omitted if the
  sketch has exactly one open entity.

**Geometry model**: the rib always stands up in the WORLD +Z direction from
the horizontal plane `z = attachZ` (the same "sketch local [u,v] == world
[x,y]" convention `extrude`'s `profile.sketch` form already uses — see the
extrude section above). The open path is offset into a closed "ribbon"
footprint (mitred at interior vertices, flat square-cut ends), extruded up by
`height`, and driven down by `embed` below the attach plane so
`ops/boolean.js`'s pierce **union** — the same boss-sticking-out-of-a-face
pattern `ops/holes.js` uses for a cylindrical boss — has base material to
fuse into. `draft` applies a single linear taper to the offset over the
`[attachZ, attachZ+height]` range only (the embedded portion stays full
thickness); every side wall stays an exact `PLANE` under this taper (see
`ops/rib.js`'s header for the derivation), so no faceting/approximation is
involved. `side:"both"` splits `thickness` evenly either side of `path`;
`"left"`/`"right"` puts the full thickness on one side (which side is which
depends on `path`'s own direction of travel — CCW = "left" is the `+90°`
rotate of the current segment).

Only a SINGLE planar attach face is supported (one set of parallel pierced
planes, the same limit `subtract`/`union`/`intersect` document above) — a rib
bridging two non-coplanar walls in one call is out of scope; size
`height`/`embed` explicitly instead, the same explicit-depth convention
`extrude` already uses. Unlike `subtract`/`union`/`intersect`, `rib` is **not**
gated behind `--experimental` — the pierce pattern it drives is fully
constrained by construction (a controlled boss-style overhang, not a
free-form user tool solid), the same precedent `dicePips`'s internal
pip-drilling already sets. `rib` **retires** its `of` solid the same way
`subtract`/`union` do (the result already fuses the base's material) —
reference the op's own `id` afterward, not the source id.

### Freeform surfacing — CADSF-954 (NURBS B1)

Seven verbs over the five freeform modules that shipped with no caller at all
(`ops/netSurface.js`, `ops/fillPatch.js`, `ops/untrim.js`, `ops/curveFit.js`,
`ops/loftGuide.js`). Full reference, including the freeform-to-solid recipe:
[docs/NET_SURFACE.md](../docs/NET_SURFACE.md#reachable-verbs-cadsf-954).

Every **curve** field below takes an inline 3-D polyline `[[x,y,z], …]` (mm), an
analytic `{type:"line"|"circle"|"arc", …}` descriptor, or the **id of an earlier
`fit_curve` op**.

| op | required fields | optional fields |
|----|------------------|------------------|
| `boundary_surface` | `boundaries` (**3 or more** curves, any order/direction — 4: one Coons patch; any other count: ONE sheet of N faces, G1 across its spokes) | `degree`, `samples`, `tol`, `reparam` (`"natural"`\|`"arclength"`) |
| `flatten_surface` | `of` (a solid OR a sheet — not consumed), `face` (ONE B-spline face, natural-boundary, same selectors as `untrim`) | `plane` (where the flat `planar_sheet` is laid, default XY), `samples` (grid per direction, default 64), `strainTol` (default 0.001). Reports MEASURED `maxStrain` / `rmsStrain` / `areaChange` / `developable` |
| `mutual_trim` | `sheets` (two sheet refs, each ONE natural-bounded B-spline face — consumed), `keep` (one `{point:[x,y,z]}` or `{side:"+"\|"-"}` per sheet) | `result` (`"one"` default: one sewn sheet; `"two"`: `<id>` and `<id>#1`), `tol` (mm, default 1e-6) |
| `offset_surface` | `of` (a solid OR a sheet — not consumed), `face` (ONE B-spline face, natural-boundary, same selectors as `untrim`), `distance` (mm along the outward normal, non-zero) | `tol` (mm, default 1e-4 — the MEASURED deviation from the true offset), `samples` |
| `ruled_surface` | EITHER `curves` (**exactly 2** — straight rulings between them, EXACT NURBS, not fitted) OR `curve` + `direction` (vec3) + `distance` (mm, non-zero — rule one curve along a pull direction) | `flip` (`true`\|`false`\|`"auto"`, default auto — un-crosses the rulings), `samples`. Reports `analytic.twistDeg` / `developable` (≤0.2°) |
| `net_surface` | `uCurves` (≥2, in increasing v), `vCurves` (≥2, in increasing u) | `degree`, `samples`, `tol`, `reparam` |
| `fill_patch` | `boundaries` (≥3 sides; each a curve, or `{curve, continuity:"G0"\|"G1"\|"G2", of, face}` naming the neighbour face a G1/G2 side must meet) | `into` (a body id, or a list — sews the patch on with `ops/stitch.js`), `tangentScale`, `degree`, `samples`, `tol` |
| `untrim` | `of` (a solid OR a sheet body), `face` (ONE face — see below) | `samples` |
| `extend_face` | `of` (a solid OR a sheet body), `face` (ONE face — see below), `distance` (mm) | `direction` (`"u"`\|`"v"`), `end` (`"start"`\|`"end"`\|`"both"`), `mode` (`"distance"`\|`"percent"`), `degree`, `samples`, `tol` |
| `fit_curve` | `points` (`[[x,y,z], …]`, ≥2) | `degree`, `closed`, `tol` (present ⇒ least-squares instead of interpolation) |
| `loft_guided` | `loft`'s own sections, **plus** at least one of `guides` / `centerline` | `zA`/`zB`/`zs`, `levels` |

All of them publish a **sheet** (see the next section) except `fit_curve`, which
publishes a curve and no body, and `loft_guided`, which publishes a solid.
`fill_patch` with `into` publishes a **solid** when the sew closes and the sheet
plus the open-edge report when it does not.

`untrim` and `extend_face` take `B_SPLINE_SURFACE_WITH_KNOTS` faces only (a
loft, sweep or fillet face). A STEP `PLANE` or `CYLINDRICAL_SURFACE` carries no
extent of its own, so its "full underlying surface" is unbounded — refused by
name rather than approximated. Neither retires `of`.

**One-face selectors (CADSF-1851).** `untrim.face`, `extend_face.face` and
`delete_face.faceId` name exactly ONE face, and take — besides a raw STEP id,
`{ ref }`, `{ normal }` and `{ normal, through }` (ops/datum.js, unchanged) — a
declarative selector from the CADSF-633 grammar, evaluated against the body as it
is at that op: `{ "surface": "bspline" }` (alias of `"spline"`) for a sheet's one
B-spline face, narrowed with `largest` / `smallest` / `extreme` / `at: [x,y,z]` /
`bbox` / `createdBy` when there are several. The count is pinned to one: a second
match, or a tie on a picker, is refused `selector-ambiguous` with the candidates
listed (they also ride on the row as `candidates`); `all` and `expect > 1` are
refused `selector-bad-spec`. `untrim` / `extend_face` accept a SHEET body as
`of` (a `boundary_surface` / `net_surface` / `fill_patch` / `fit_surface`
result), the way `fill_patch`'s `into` does.

```json
{ "version": 1, "ops": [
  { "op": "fit_curve", "id": "front", "points": [[0,0,0],[12.5,0,4.5],[25,0,6],[37.5,0,4.5],[50,0,0]] },
  { "op": "boundary_surface", "id": "skin",
    "boundaries": ["front", [[50,0,0],[50,40,0]], [[50,40,0],[0,40,0]], [[0,40,0],[0,0,0]]] }
] }
```

### Surfaces from data — CADSF-958 (NURBS B5)

Four more verbs in the same family, over `NurbsCore.js`'s fit primitives,
`ops/meshToBrep.js`, `ops/meshSection.js` and `ops/meshCompare.js` (the RECON
sprint, CADSF-1408…1419 — docs/RECON_PLAYBOOK.md is the agent-side guide).

| op | required fields | optional fields |
|----|------------------|------------------|
| `fit_surface` | **exactly one** of `grid` (a rectangular `grid[i][j] = [x,y,z]` net, ≥2×2, every row the same length) or `points` (≥4 scattered `[x,y,z]`) | `degree`, `uDegree`, `vDegree`, `nu`, `nv` (control-net size for a scattered fit), `smoothing` (thin-plate weight), `samples` |
| `mesh_to_brep` | `of` (an earlier op that published a **mesh** — an `import` of `.stl`/`.obj`/`.3mf`, or of an AP242 tessellated STEP) | `tolerance`, `analyticFirst`, `segmentation` (`"fit"` \| `"dihedral"`), `featureAngle` (radians), `degree`, `nu`, `nv`, `smoothing`, `samples`, `stitchTol`, `heal`, `maxRegions`, `mixed` (route M, trimmed faces on SSI edges — CADSF-1412), `ssi`, `blends` (`"keep"` \| `"refit"` \| `"auto"` — CADSF-1414), `recipe` (route R, extrude/revolve/loft/polyhedron plan — CADSF-1415), `compare` (put ops/meshCompare.js's numbers and the 8-view screenshot signal on the report — CADSF-1409), `converge`, `target` ({normalisedHausdorff, volumeErrorPct, silhouetteIoU}), `budget` ({ms, candidates}) (the CONVERGE LOOP — CADSF-1416), `hints` ([{region, kind}] / [{region, blendRadius}] / [{axis}] / [{merge}] — loosen, never force — CADSF-1418), `preflight`, `preflightBudgetMs`, `decimateBudget`, `jobMode` (the pre-flight cost estimate: over budget, QEM-decimate to `decimateBudget` triangles, or with `jobMode:true` refuse inline as route `"job"` — CADSF-2123; over MCP, `cad_mesh_to_brep {jobMode:true}` runs as an ASYNC JOB instead: it returns a `jobId` at once and `cad_job_status` / `cad_job_result` / `cad_job_cancel` follow it — CADSF-2120), `componentVerify`, `componentFineRetry`, `componentFineRetryBudgetMs` (ms; 0 tries nothing), `cornerConcurrency`, `verify` (the component ladder's checkLocal verify and finer-ruler retry, route M's 4-plane corner rule and the single-surface verify — each defaults ON; `false` is the A/B switch — CADSF-100416, CADSF-100552, CADSF-100615) |
| `mesh_section` | `of` (a mesh import) and exactly one of `plane` ({origin, normal, xAxis?}), `axis` (with `count` or `spacing`), or `atRegions: true` with `axis` | `count` (default 5), `spacing`, `from`, `to`, `inset`, `featureAngle`, `fit` (false → raw polylines), `fitTol`, `cornerAngleDeg`, `minSpanPoints`, `points`. Publishes NO body — closed loops fitted into line / arc / B-spline spans (CADSF-1413) |
| `mesh_compare` | `of` (a mesh import), `body` (a solid — an earlier op's id or `"<opId>#<n>"`) | `views` (true \| false \| [names] — 8 canonical silhouette-IoU / depth-delta renders), `sections` (true \| false \| {axes?, count?} — section stacks with per-station 2-D IoU), `outDir`, `imageSize`, `tolerance`. Publishes NO body — pure measurement (CADSF-1417) |

`fit_surface` publishes a **sheet**. A `grid` is INTERPOLATED (P&T §9.2.5 — the
surface passes through every point, and `analytic.fitError` is exactly 0);
`points` are least-squares APPROXIMATED with a thin-plate smoothing term, and
`analytic.fitError` is the residual actually measured against the input.

`mesh_to_brep` segments the mesh, recognises each region ANALYTICALLY FIRST — a plane, cylinder, cone, sphere or
torus comes back as its own kind, never as a B-spline that merely looks like one
— and fits the rest. Every acceptance is measured: a candidate is taken only
when the worst distance from the region's own points to it is within
`tolerance` (default 1e-3 × the bounding-box diagonal).

`segmentation` (CADSF-1411) chooses how the triangles become regions.
`"fit"` (the default) grows a region while a MEASURED fit through its own
points stays within `tolerance` — planes first, then cylinders, cones, spheres
and tori, then whatever is left by the crease rule. That is what finds a
TANGENT boundary: a fillet is G1 to both of its supports, so no edge on either
side of it ever exceeds `featureAngle`, and under `"dihedral"` (CADSF-958's
crease flood fill, unchanged and still available) the plane, the fillet and the
next plane come back as ONE region that fits nothing and goes to the spline
route. `analytic.segmentation` reports `mode` and `byKind` — how many regions
the segmenter proposed as each kind — beside the per-face `kind` recognition
actually accepted. When the whole mesh
resolves to one primitive this kernel authors exactly (sphere, capped cylinder,
cone, frustum, torus, axis-aligned box) the result is a watertight **solid**
with genuinely analytic faces; otherwise the fitted patches are published as
sheets and sewn with `ops/stitch.js`, and `analytic.closed` reports honestly
whether that sew closed. The report carries the per-face `fitError` and the
whole-body `residual` on `meshToBrep`.

```json
{ "version": 1, "ops": [
  { "op": "import", "id": "scan", "path": "bracket.stl" },
  { "op": "mesh_to_brep", "id": "solid", "of": "scan", "tolerance": 0.05 },
  { "op": "blend", "id": "rounded", "of": "solid", "radius": 1 },
  { "op": "save", "path": "out/bracket.step" }
] }
```

A freeform sheet is not yet a `thicken` input: `thicken`'s v1 takes ONE
**planar**, hole-free, straight-edged `ADVANCED_FACE` and refuses a
`B_SPLINE_SURFACE_WITH_KNOTS` support by name (NURBS B2). The way to a solid
today is the sew — `fill_patch`'s `into`, or `stitch` once it accepts sheets.

### Sheet bodies (`ops/planarSheet.js`, `ops/thicken.js`) — CADSF-541 / CADSF-263

A **sheet** is an open (non-watertight) body: a surface, not a volume. It is
its own kind of result, not a broken solid — it is published as `sheetId` on
the op's report (never `solids`), and every other verb's `of`/`tool`/`bodies`
refuses it by name. The ONE thing a sheet can be given to is `thicken`, which
turns it into a watertight solid.

| op | required fields | optional fields |
|----|------------------|------------------|
| `planar_sheet` | `profile` (`[[u,v], …]`, ≥3 points, **CCW**, **not closed** — do not repeat the first point) | `plane` (`{origin?, xAxis?, yAxis?}` or the id string of an earlier `datum_plane` — the same sketch frame `extrude`/`sketch_begin` take; default world XY / +Z) |
| `thicken` | `of` (a **sheet** id — a `planar_sheet` result, or a `stitch` that did not close) | `thickness` (one-sided, mm along the sheet's own normal) OR `thickness1`/`thickness2` (ACIS `api_sheet_thicken` parity: offset along the normal / the opposite direction — either may be 0 or negative, their SUM must be > 0) |

The pair has an exact oracle: `thicken` produces a solid of volume
`area × (thickness1 + thickness2)`, and refuses rather than returning geometry
that disagrees with it.

```json
{ "version": 1, "ops": [
  { "op": "planar_sheet", "id": "plate", "profile": [[0,0],[30,0],[30,20],[0,20]] },
  { "op": "thicken", "id": "panel", "of": "plate", "thickness": 4 }
] }
```
builds one watertight body of `V = 600 × 4 = 2400 mm³`.

`planar_sheet`'s v1 scope is one outer loop, straight edges, no holes — the
exact shape `thicken` accepts (it refuses a multi-face or multi-loop sheet by
name). Four inputs are **refused, never silently corrected**: a clockwise
profile (the winding sets the sheet's normal, which is the direction
`thickness` is measured along, so a silent flip would move the solid to the
other side of the plane), a repeated point (including a mistakenly *closed*
profile whose last point repeats the first), a self-intersecting profile, and a
zero-area one.

A sheet is legitimately **not watertight**, so the solid checks are not applied
to it: what runs is the sheet-specific form of the same question (every
boundary edge used by exactly one face, no non-manifold edge, no pinched
vertex, one connected piece, and the shell *not* closed), reported on the op's
entry as `sheetCheck`.

### Shaft features (`ops/shaft.js`) — CADSF-448 (TURBO T1)

Four verbs for TURNED parts (turbine and turbo-pump shafts). Each takes a solid
of revolution about +Z by `of` — a `cylinder`, a `revolve`, or another
`shaft_*` result — **retires** it, and publishes a new body under its own `id`.

| op | required fields | optional fields |
|----|------------------|------------------|
| `shaft_groove` | `of`, `z` (groove centre), `width`, plus `depth` OR `diameter` (groove-bottom Ø) | `form` (`"rectangular"` default \| `"round"`) |
| `shaft_keyway` | `of`, `z` (slot start), `length`, plus `width`+`depth` OR `standard` | `width`, `depth`, `azimuth` (RADIANS, default π/2), `standard` (`"BS4235"`) |
| `shaft_shoulder` | `of`, `z` (shoulder face), `diameter` (turned-down Ø) | `side` (`"above"` default \| `"below"`), `relief` (`{ depth, width?, form? }`) |
| `shaft_center_drill` | `of`, `pilotDiameter`, `pilotDepth` | `end` (`"top"` default \| `"bottom"`), `countersinkDiameter` (default 2× pilot), `angle` (DEGREES, default 60), `pointAngle` (DEGREES, default 118, or `null` for a flat pilot bottom) |
| `revolve_feature` | `of`, `features` (array; each `z`, `width`, plus exactly one of `depth`/`height`/`to`/`diameter`) | per feature: `id`, `kind` (`"groove"` default \| `"land"` \| `"step"`), `on` (`"wall"` default \| `"face"`), `side` (`"outer"` default \| `"inner"` \| `"above"` \| `"below"`), `radius` (required on a face), `atRadius`, `section` (`"rectangular"` default \| `"round"` \| `"vee"` \| `"trapezoid"`), `tipWidth` |

CADSF-1312 — `revolve_feature` is the same idea on the CLOSED meridian loop
instead of an open silhouette, so it also reaches BORES, CONICAL walls and
annular FACES, and it takes a LIST of features that share one profile rebuild.
On a wall, depth is RADIAL and `z` is the feature's axial centre; on a face,
depth is AXIAL and `radius` is its radial centre (`z` names which face). On a
taper the feature rides the surface. Refusals are named:
`no_matching_surface`, `ambiguous_surface`, `feature_spans_edge`,
`feature_reaches_axis`, `section_mismatch`, `round_section_on_taper`,
`profile_self_intersects`, `partial_sweep_unsupported`.

**Every `z` is a world coordinate** along the shaft axis. `azimuth` is in
RADIANS (the kernel's angle convention); the centre drill's `angle` and
`pointAngle` are in DEGREES, matching `hole`'s countersink callout, because
they are drawing notes rather than geometric parameters.

**No booleans are involved.** `ops/boolean.js`'s engine needs its tool to
pierce PLANAR faces of the base; every shaft feature breaks out through the
cylindrical wall instead, and the engine refuses that pattern before building
anything. So these verbs edit the shaft's own (r, z) silhouette and re-spin it
into exact analytic surfaces — a rectangular groove is two planes and a
cylinder, a full-radius groove or a round relief is a torus, a centre drill is
a cone — and the keyway (the one non-axisymmetric feature) is authored
directly as B-rep faces on the section it sits in. Each op reports the volume
it removes in CLOSED FORM on `feature.removedVolume`, alongside a machinist
callout, and the rebuilt body's exact disc integral on `analytic.volume`.

**Order matters, the way it does on a machine**: turn the steps outermost-last
(a shoulder is only cut on the section that still reaches the chosen end), then
cut grooves and centre holes, then mill the slots. A section that already
carries a keyway is refused by a shoulder, and a centre hole that would break
into a slot is refused by name.

**Named refusals** (`ops/shaft.js`'s `REFUSAL_CODES`) cover the mistakes that
would otherwise produce a plausible-looking wrong body: a groove or keyway that
reaches the axis, a groove that runs off the end of the shaft or crosses a
step, a keyway wider than the shaft or too shallow for its width (floor outside
the wall at the slot edges), overlapping keyways, a shoulder that does not
reduce the diameter, a relief longer than the turned run, a countersink wider
than the end face.

**Standards**: `standard: "BS4235"` looks the key width `b` and shaft keyway
depth `t1` up from the shaft diameter (BS 4235-1:1972, the same b × h × t1
series as DIN 6885-1 over Ø6..Ø230). Retaining-ring (DIN 471) and O-ring
(ISO 3601-2) DIMENSION tables are deliberately not embedded — the groove
SHAPES are what this module standardises; supply the numbers from the ring
maker's table as `width` and `diameter`.

### Turned legs and spindles (`ops/turnedLeg.js`) — CADSF-1373 (TC-M2, epic:ten-chairs)

A **turned leg** is a SOURCE op (no `of`, one body out): a taper from
`bottomDiameter` at the foot to `topDiameter` at the shoulder, optional beads
and coves on it, an integral tenon or dowel end, stood on the floor at a splay
with its top cut to the seat underside. Every field has a default — an empty
`turned_leg` op is the Round Chair's leg (Ø35 → Ø28 over 400 mm, a Ø21 × 30
tenon, upright at the origin).

| op | required fields | optional fields |
|----|------------------|------------------|
| `turned_leg` | — | `length` (foot → seat underside, default 400), `topDiameter` (35), `bottomDiameter` (28), `foot` (`{ height, diameter? }`), `collar` (minimum straight band under the shoulder), `features` (`[{ kind: "bead"\|"cove", at, size, id? }]`), `end` (`"tenon"` default \| `"dowel"` \| `"none"` \| `{ kind?, diameter?, length? }`), `placement` (`{ point, splayX, splayY, anchor: "foot"\|"top", floorZ }`), `trimTopTo` (seat-underside height), `margin` (collar margin, 1) |

**The profile is exact.** ops/revolve.js spins it: planes, cones, cylinders and
— for each bead (convex) or cove (concave) — a TOROIDAL band whose arc's chord
is the taper segment it replaces, so a detail on a taper leans with the taper
and has no step at either end. The volume is the revolve's own shell integral.

**The splay cut is authored, not booleaned.** `splayX` / `splayY` are the
degrees the leg's TOP leans toward +X / +Y (axis `unit(tan sx, tan sy, 1)`, total
tilt ≤ 30°). The foot's LOWEST point rests on `floorZ`, and the top is cut by the
horizontal plane through the axis at station `length`. A horizontal plane
through a tilted cone is a general conic the kernel does not carry, but through
a tilted cylinder it is an ELLIPSE, which it does — so a splayed leg keeps a
straight collar of `topDiameter` under the shoulder (2·R·tan θ + margin, or
`collar` if larger) and the cut collar, the elliptical shoulder and the tenon
are written directly in ops/primitives.js's seam layout (two half-cylinder faces
on LINE seams, half-ELLIPSE edges, the tenon's footprint as the shoulder's inner
bound). Measured and recorded in tests/test_cadsf-1373.js: splitByPlane on the
revolve's seamless collar band mis-meshes the remnant (1.7 % of the volume, no
checker finding), and the tilted-tenon union declines to the general engine and
fails CADSB-503's conservation gate — hence this path. Upright (θ = 0) the
whole leg, tenon included, is one revolve and nothing is cut.

**Placement.** `anchor: "foot"` (default): `point` is the foot centre's plan
position and `point[2]` the floor. `anchor: "top"`: `point` is where the axis
meets the seat underside and `length` is DERIVED so the foot lands on `floorZ`
(default 0); `trimTopTo` with anchor "foot" derives it the other way. The result
entry carries `leg` (length and its source, taper/collar stations, each feature's
arc radius and crest, axis, foot centre, top point, `footLowestZ`,
`seatUndersideZ`, the shoulder ellipse's semi-axes) and `faces` (`top`: the
shoulder face(s); `tenon`: the pin's faces).

**Refusals** (by `reasonCode`, each with a remedy-table entry):
`turned-leg-bad-parameter`, `turned-leg-feature-out-of-range` (a bead/cove
outside the taper, or in the collar the cut needs), `turned-leg-features-overlap`,
`turned-leg-cove-reaches-axis`, `turned-leg-bead-too-large` (arc radius ≥ the leg's
radius there — a spindle torus), `turned-leg-end-too-large`,
`turned-leg-length-not-positive` (the seat underside at or below the floor, or no
taper left between foot band and collar), `turned-leg-splay-too-steep`,
`turned-leg-rim-not-seamable` (internal: the taper/collar junction is not in
the two-arc rim form).

**Not done here:** a foot cut parallel to the floor (`trimBottom`), a
non-circular (octagonal / square-to-round) section, a partial-turn spindle.

### Bent-lamination members (`ops/laminate.js`) — CADSF-1372 (TC-M1, epic:ten-chairs)

A **bent lamination** is a SOURCE op (no `of`, one body out): a rectangular
section swept along a SPINE of straight runs and true corner bends, with an
optional lamination count drawn as glue lines. Every field has a default — an
empty `laminate` op is the Stool 60 L-leg (50 × 30 section, legs 400 + 200, a
90° bend of centreline radius 60).

| op | required fields | optional fields |
|----|------------------|------------------|
| `laminate` | — | `section` (`{ width, thickness, cornerRadius? }` — thickness is the dimension IN the bend plane, default 50 × 30), `spine` (`{ points: [[x,y,z], ...], bendRadius?, fillets?, closed? }` \| `{ bspline: { ctrl, degree?, samples? } }`), `bendRadius` (default corner radius, 60), `layers` (lamination count, default 0), `up` (width direction at the spine start), `maxBandAngle` (radians per revolved band, π/2) |

**The member is analytic.** Between two bends the section is TRANSLATED — a
straight run is a prism of four PLANEs. Through a corner it is ROTATED about
the bend's own axis (the corner's plane normal through the bend centre) — a
partial revolve, and a section line revolved about an axis lying in the
section plane is a PLANE, a CYLINDRICAL_SURFACE or a CONICAL_SURFACE. An
L-leg is 12 walls + 2 caps; the volume is Pappus per leg (area × the
centroid's travel), exact; a union with an analytic seat rail meets planes
with planes (measured: the `pierce` engine, no geometry degraded). The ring /
wall authors are ops/sweep.js's own, driven by a propagated frame instead of
that module's single path plane — which is what lets the spine leave the
plane.

**The frame is rotation-minimising, and exact.** Constant along a run,
rigidly rotated about the bend axis through a bend — for a rotation about a
fixed axis ⟂ the tangent, dX/ds ∥ tangent, which is the RMF condition. So on
a planar spine the layers stay in the bend plane throughout; on a 3-D spine
(the Wishbone rail running down into its legs) the section reaches the second
bend carrying the first bend's orientation, and that bend's walls come out as
the cylinders / cones the geometry says they are (`member.segments[k]`
reports `easyWay` and `sectionTiltDeg` per bend). `up` seeds the WIDTH
direction at the start (default: the first bend's axis — an easy-way first
bend with the layers stacked radially; a straight member takes world +Z
projected off its axis, as `sweep` does).

**Closed loops** (`spine.closed: true`, the Paimio frame): every vertex is a
corner; the loop is opened at the midpoint of its closing edge, swept round,
and the final ring IS the first ring — one shell, no caps. A non-planar loop
whose frame does not return to itself is refused with the closure angle.

**Lamination lines.** `layers` L ≥ 2 splits the two WIDTH faces (b = ±w/2,
where the real member shows its glue lines) into L strips by a section node at
every layer boundary. Each strip shares ONE surface instance with its
neighbours on that side, so a glue line is an edge between two faces on the
same surface — engraved on the drawing, zero dihedral angle on the body — and
`laminations.edgeIds` lists exactly those edges (2·(L−1) lines × one edge per
leg band). Layers 0 / 1 leave the member at its minimum face count.

**The free spine.** `spine: { bspline }` has no analytic form and takes
ops/tubeSweep.js's sampled skin (B-spline walls, double-reflection RMF,
built-volume integral); `analytic.kind` is `'swept-nurbs'` there and
`'analytic'` otherwise.

**Result:** `analytic` (`volume`, `area`, `spineLength`, `faces`, `bends`,
`bands`, `planar`, `closed`), `member` (section, spine, frame at both ends,
`segments[]` — each straight run's length and volume, each bend's centre,
axis, radius, angle, inner / outer radius, easy-way flag, bands, volume),
`laminations` (`layers`, `layerThickness`, `lines`, `edgeIds`).

**Refusals** (by `reasonCode`, each with a remedy-table entry):
`laminate-bad-parameter`, `laminate-spine-too-short`,
`laminate-spine-doubles-back`, `laminate-corner-needs-bend-radius` (a turning
corner with radius 0 — a lamination is not mitred),
`laminate-bend-radii-too-large` (the setbacks eat the run; the largest radius
that fits is named), `laminate-section-folds-through-bend` (the section
reaches the bend axis; the smallest clearing radius is named),
`laminate-corner-radius-too-large`, `laminate-corner-radius-needs-planar-spine`
(a rounded section on a bend whose axis is off the width direction),
`laminate-up-parallel-to-spine`, `laminate-closed-frame-mismatch` (holonomy on
a closed 3-D loop), `laminate-free-spine-refused` (ops/tubeSweep.js's own code
in `detail.cause`).

**Not done here:** a variable section along the spine (a tapered lamination),
spring-back / bend allowance, the fan-slot pattern of the Stool 60 leg as
geometry (the layers are drawn, not sawn), a scarf joint at the ends.

### Woven seats (`ops/weave.js`) — CADSF-1374 (TC-M3, epic:ten-chairs)

A **woven seat** is a SOURCE op (no `of`): paper cord, cane or webbing woven
over a seat frame as a real strand pattern. Every field has a default — an
empty `weave` op is the Wishbone seat (a 450 × 400 frame on Ø20 rails, Danish
paper cord Ø3.5 at a 7 mm pitch, the envelope pattern, the COARSE lod).

| op | required fields | optional fields |
|----|------------------|------------------|
| `weave` | — | `frame` (`{ width, depth, railDiameter }` \| `{ rails: [4 × { from, to, section }] }`), `pattern` (`'envelope'` \| `'plain'` \| `'cane'` — refused), `strand` (`{ kind: 'cord'\|'cane'\|'webbing', diameter \| width + thickness, pitch, sides }`), `lod` (`'coarse'` default \| `'fine'`), `maxStrands` (fine-lod cap, 16), `sample` (explicit stride), `clearance` (mm at each crossing, 0.1), `coarseLines` (centrelines split into the slab's faces, 12), `wrapSegments` (8), `cornerInset` (mm, default one shoulder), `tol` |

**The frame and the grid.** Four rail AXES head to tail round the seat (rail0
A→B … rail3 D→A), which must close and be coplanar; a trapezoid is fine — the
real Wishbone seat is one. The seat is that quad under its bilinear map
`P(s,t)`, whose s- and t-lines are straight, so WARP strands (a fixed `s`,
between rails 0 and 2) and WEFT strands (a fixed `t`, between rails 3 and 1)
cross at exactly `P(s_k, t_m)`. The crossing set is the grid itself — no
segment-intersection search — which is what makes the over/under bookkeeping
and the interference measurement closed-form.

**A strand is one pass**: a half-turn around its first rail, the run across the
seat through its crossings, a half-turn around the far rail. The half-turn is a
half-circle of radius railRadius + the strand's reach about the rail axis in the
rail's own normal plane (a round rail), or the offset half-perimeter — flat,
eased corner arc, flat — for a rect one, so the strand's surface is TANGENT to
the rail's. `weave.contact.railGapMaxMm` is that gap MEASURED off the built
path, segment by segment. Both faces of the seat are woven: warp and weft in a
top and a bottom field (their stations offset by half a pitch along the rails,
or a station's two passes would meet on the outside of the rail they share).

**The weft is laid; the warp weaves.** The weft is straight and taut in its
field's mid-plane. The warp's run is the BELT over the rail circles and, at
every weft, a circle of radius ρ = 2·reach + clearance on the side the pattern
puts it: tangent between circles, arc on each, with the arcs' chords
CIRCUMSCRIBED so they ride the true circle instead of cutting inside it. Every
point of the run is therefore at least ρ from every weft centreline and at least
railRadius + reach from every rail axis — that is the whole
non-interpenetration argument, and it holds between the crossings as well as at
them. (Two families both undulating cannot be made to clear each other at any
sane pitch; measured, they meet 2.91 mm apart on the default seat.) The internal
tangent only exists when the weft spacing exceeds 2ρ, which is the pitch floor
`weave-pitch-below-strand-width` refuses under and why the default pitch is
2.2 × the strand's width.

**The corner inset.** A strand stays on its rail until it can run straight to
its first crossing, so the STATIONS are inset from the rail ends by 1.5 × the
largest wrap radius (`cornerInset`) — which is also why a real woven seat has
its corner triangles.

**The patterns.** `'envelope'` (default) is the Danish-cord seat: the quad's
two diagonals cut it into four triangles and inside each the family PARALLEL
to that rail lies on top (`|s−½| > |t−½|` puts the warp over), so a warp
strand crosses the diagonals twice and runs in TWO LAYERS along its own length
— the classic envelope figure. `'plain'` is over-under by parity.
`'cane'` — the six-way hexagonal cane — is **refused by name**.

**Levels of detail.** `'coarse'` (THE DEFAULT, so `cad_preview_body` draws it)
is ONE watertight slab — the seat quad through the weave's whole thickness —
with its top and bottom faces SPLIT along a sampled set of strand centrelines;
every split face on one side shares ONE plane instance, so each groove is an
edge of zero dihedral angle (`laminate`'s glue lines, restated). `'fine'` is
one swept solid (`ops/tubeSweep.js`) PER SAMPLED STRAND — **one body per
strand**, not one per family: strands in a family are parallel and never touch,
so a union of them would be a disconnected multi-lump body with no shared faces
to gain and every strand's identity lost. On the fine lod `<id>#0..` addresses
each strand and each body is NAMED (`seat-warp-top-12`).

**The metadata is never sampled.** A real seat is a couple of hundred strands,
so the fine lod samples (`maxStrands`, default 16, or an explicit `sample`
stride) — but `weave.counts` and `weave.length` are computed over the WHOLE
strand set on both lods, so `length.totalM` is the cord the seat really takes
whatever geometry was built.

**Result:** `analytic` (`kind` `'coarse-slab'` \| `'swept-strands'`, `bodies`,
`faces`, `volume`, `thickness`), `weave` (`pattern`, `lod`, `strand`, `counts`,
`length`, `contact` — the measured rail gap, `crossings` — the measured minimum
separation against the strand thickness, `field` — the field offset, the crossing
offset ρ, the slab thickness, the corner inset, the wrap radius and the pitch
floor, `frame`, `built`), and on the fine lod
`strands[]` (one row per body: family, field, index, name, length, volume,
stations, faces, rail gap).

**Refusals** (by `reasonCode`, each with a remedy-table entry):
`weave-bad-parameter`, `weave-pattern-unknown`,
`weave-cane-pattern-unsupported` (the six-way hexagonal cane — three families
at 60° with a three-deep order, declined rather than approximated; note
`strand.kind: 'cane'`, the flat section, IS supported),
`weave-rails-not-a-frame` (four rails that do not close, are not coplanar, or
are ordered so a rail runs along the strands it should carry),
`weave-frame-too-small`, `weave-pitch-below-strand-width`,
`weave-strand-exceeds-rail`, `weave-too-many-strands`,
`weave-strand-sweep-refused` (ops/tubeSweep.js's own code in `detail.cause`),
`weave-strands-interfere`.

**Not done here:** the six-way hexagonal cane pattern; a continuous cord (each
strand is one pass, not one 120 m polyline); tension / sag under load; the
coarse slab's grooves as cut geometry rather than face splits; the corner wrap
region, where the two families' half-turns around perpendicular rails are not
separated from one another (the crossing field is).

### Glazing — greenhouse, canopy, nose (`ops/glazing.js`) — CADSF-101824

```json
{ "op": "glazing", "id": "glass", "of": "greenhouse",
  "nodes": { "A": [700, 470, 120], "B": [330, 300, 400], "...": [0, 0, 0] },
  "members": [ { "id": "a_pillar_l", "through": ["A", "B"], "width": 50, "depth": 10 } ],
  "panes": [ { "id": "windscreen", "corners": ["A", "B", "C", "D"] } ],
  "frame": { "width": 40, "depth": 12 }, "pane": { "thickness": 5, "inset": 3, "tuck": 6 },
  "seal": { "width": 8, "height": 2 }, "hostMode": "replace" }
```

Turns a one-piece glass shape (`of`, the host) into what a real greenhouse is: FRAME MEMBERS (pillars, rails,
header, bars) swept as solids ON the host's surface, a separate thin PANE per opening — trimmed `width/2 - tuck`
inside the frame's centreline so its edge hides under the frame, its outer face `inset` below the surface, `thickness`
deep — and a black SEAL band just inside each frame edge. `nodes` are points near the host (projected onto it, with the
host's exact surface normal, CADSF-101823); a member is a smooth curve through `through` laid on the surface; a pane's
edge between two nodes that one member runs through follows that member, else the shortest on-surface chord.
`hostMode`: `"replace"` (default — the host WAS the glass and is retired), `"keep"`, `"cut"` (each opening subtracted
from the host; `glazing.host.cut` says per pane whether it held). Every part is its own body, `<id>#k`, listed in
`glazing.parts` as `{ref, role: frame|pane|seal|host, name, material}`; `glazing.panes[].supported` is false when an edge
has no member (`cad_design_review`'s unsupported-glazing rule reads it). Refusals by code: `glazing_node_unknown`,
`glazing_node_off_host`, `glazing_pane_too_small`, `glazing_member_failed`, `glazing_pane_failed`. MCP: a
`cad_build_plan` verb; `cad_help {topic: "glazing"}`.

### Sketches (`sketch/Sketch.js`) — CADSF-193 (MODELER M2.1)

A **sketch** is a plane plus 2D entities drawn in that plane, plus a
constraint SCHEMA (constraints are validated and stored but NOT solved — the
solver is a later work item). Sketches are built incrementally, then consumed
by `extrude`'s `profile: { sketch: "<id>" }` form.

| op | required fields | optional fields |
|----|------------------|------------------|
| `sketch_begin` | `plane` ({ `origin`?, `xAxis`?, `yAxis`? } as [x,y,z] arrays; defaults to the world XY plane) | — (the op's own `id` names the sketch) |
| `sketch_entity` | `sketch` (a `sketch_begin` id), `entity` ({ `type`, geometry… }) | — |
| `sketch_end` | `sketch` (a `sketch_begin` id) | — |

`entity.type` is one of `line`, `arc`, `circle`, `point`, `polyline`, with 2D
`[u,v]` geometry in the sketch plane:

- `line`     — `{ p0:[u,v], p1:[u,v] }`
- `arc`      — `{ center:[u,v], r, a0, a1 }` (radians, swept CCW `a0`→`a1`)
- `circle`   — `{ center:[u,v], r }`
- `point`    — `{ p:[u,v] }` (excluded from profiles)
- `polyline` — `{ points:[[u,v],…], closed:bool }`

Any entity may carry `"construction": true`; construction geometry is excluded
from the profiles handed to `extrude`.

**Winding is handled FOR you.** `sketch_end` (and the extrude that consumes the
sketch) run `profiles()`, which finds closed loops — self-closed circles /
closed polylines, plus open `line`/`arc`/`polyline` entities stitched together
by endpoint proximity — computes their nesting by point-in-polygon
containment, and orients them the way the extruder documents: outermost loops
CCW, the holes directly inside them CW, an island inside a hole CCW again, and
so on. You draw the loops in any winding; the sketch fixes them.

Constraints (`sketch/Sketch.js`, schema only) accept
`{ type, refs:[entityId(.point)?…], value? }` where `type` is one of
`coincident`, `horizontal`, `vertical`, `parallel`, `perpendicular`,
`tangent`, `equal`, `distance`, `angle`, `radius`; `distance`/`angle`/`radius`
carry a numeric `value`. They are validated (arity + that every ref names a
real entity) and stored for a future solver.

### Datums (`ops/datum.js`) — CADSF-196 (MODELER M2.4)

A **datum** is lightweight reference geometry — a plane FRAME, an AXIS or a
POINT. Datums are NOT STEP solids: they carry no B-rep, no volume, and are never
written to the STEP output. They live in the feature history as construction
references (a sketch plane, a mirror/section plane, a pattern axis, a
measurement anchor). Each datum op stores a `{ datum: { kind, frame|axis|point } }`
entry in the executor env under its `id`.

A datum **PLANE** frame is exactly the shape a sketch consumes —
`{ origin, xAxis, yAxis, normal }` (unit, right-handed, `normal = xAxis × yAxis`)
— so `sketch_begin`'s `plane` field may be the **id string of a datum_plane**,
and the sketch is placed on that datum. The in-plane axes are derived
deterministically from the normal, so a rebuild reproduces the identical frame.

| op | required | optional / method fields |
|----|----------|--------------------------|
| `datum_plane` | `method` (`"offset"` \| `"midplane"` \| `"three_point"`) | see below |
| `datum_axis`  | `method` (`"two_point"` \| `"face_normal"` \| `"cylinder"`) | see below |
| `datum_point` | `method` (`"vertex"` \| `"edge_midpoint"`) | see below |

A **face selector** (`face`/`faceA`/`faceB`) is one of:

- a raw STEP face id string (as `delete_face` takes) — valid only within the
  session that produced it; **dead after any rebuild**;
- an outward-normal picker `{ normal:[x,y,z] }` — the planar face whose outward
  normal best matches, furthest along it. Survives a rebuild because it names
  geometry, but it *always* picks something: if the intended face was deleted it
  silently snaps to whichever face now faces that way;
- `{ surface:"cylinder" }` — the (single) cylindrical/conical face;
- **`{ ref: "<provenance tag>" }` (CADSF-208)** — a *persistent* reference. The
  tag is a CADSF-207 provenance name (`"base:face-pz"`,
  `"cut:cut[base:face-pz]"`), normally obtained from
  `ops/refresolve.js`'s `captureRef()` at pick time, which also records a
  geometric fingerprint (pass the whole captured object as `ref` to use it).
  Resolution runs the lineage cascade in `ops/refresolve.js`: exact tag →
  descendant (the face was split) → ancestor (the split was undone) →
  fingerprint fallback. This is the only selector that reports *ambiguous* and
  *unresolvable* as distinct, explicit failures instead of guessing; both are
  raised as an op-level error carrying the ranked candidate list.
  `{ ref: …, resolve: { strict:true } }` refuses any geometry-only match, so
  nothing but a lineage answer is ever accepted.
  **Dangling-reference UX (CADSF-209, MODELER M3.2c):** a `{ ref }` resolution
  is always one of three markers, surfaced on the op's result row rather than
  left for the caller to re-derive from raw resolver fields
  (`ops/refresolve.js`'s `classify()`/`repairReport()`):
  - **CLEAN** — the lineage cascade answered outright (exact tag, an
    unambiguous split/merge, or edge-via-faces). No note, nothing to show.
  - **REPAIRED** — the reference only resolved via the geometric fallback (or
    a fingerprint tie-break elsewhere in the cascade): still a usable id, but
    flagged. The consuming op's result row carries a `refRepairs` entry
    (`{ field, marker:'REPAIRED', tag, via, reason, confidence, candidates,
    message }`) — the "reference reattached" warning, made explicit
    rather than silent.
  - **LOST** — ambiguous or unresolved: no candidate the resolver trusted.
    The op fails and is effectively SUPPRESSED (never a crash, never a guessed
    face); its result row carries `dangling`
    (`{ marker:'LOST', tag, status, reason, candidates, message }`) instead of
    just a flattened error string, and anything downstream that names the
    failed op's id degrades the same way any other failed feature does
    (CADSB-72's "skipped — depends on ops[i], which failed").
  A cut that **splits** a referenced face therefore degrades gracefully either
  way: an unambiguous split is CLEAN (reattached via lineage), a genuinely
  ambiguous one is LOST (suppressed) — never a crash, never a silent wrong
  face.

`datum_plane` methods:
- `offset`: a plane parallel to a source face/plane, `distance` (signed, along
  the source's outward normal) away. Source is either `of` + `face`, or an
  explicit `origin` + `normal`. `distance` is required.
- `midplane`: halfway between two **parallel** faces — `of` + `faceA` + `faceB`.
  Non-parallel faces have no unique midplane and are rejected.
- `three_point`: `points` (3 `[x,y,z]`); winding `p1→p2→p3` sets the normal by
  the right-hand rule, origin at `p1`, `xAxis` toward `p2`.

`datum_axis` methods:
- `two_point`: `points` (2 `[x,y,z]`); direction `p1→p2`, anchored at `p1`.
- `face_normal`: a planar face's normal through a `point` — `of` + `face` + `point`.
- `cylinder`: the axis of a cylindrical/conical face (analytic, read straight off
  the surface placement) — `of` + `face` (typically `{ surface:"cylinder" }`).

`datum_point` methods:
- `vertex`: an explicit reference `point` (`[x,y,z]`).
- `edge_midpoint`: the midpoint of an edge — either `of` + `edge` (a raw
  EDGE_CURVE id) or explicit `points` (2 `[x,y,z]` endpoints).

Deferred (documented stubs that throw rather than emit wrong geometry): a datum
plane at an **angle about an edge**, and a datum point at an arbitrary curve/
surface **intersection**.

### Wind tunnel / CFD (`cfd/index.js` — `runWindTunnel`) — CADSF-312 (CFD-I1)

Runs an incompressible MAC-grid Navier–Stokes wind-tunnel solve on a prior
solid and reports aerodynamic coefficients. The solid named by `of` is
tessellated to a surface mesh and handed to the vendored solver
(`cfd/` — vendored in from STLWindTunnel per ADR-001).

| op | required fields | optional fields |
|----|------------------|------------------|
| `wind_tunnel` | `of` (id of a prior solid) | `bodies` (**CADSF-363**: array of EXTRA body refs combined with `of` into ONE CFD geometry — no union required for DISJOINT bodies, e.g. a part next to a separate fixture; each body is tessellated and watertightness-audited independently, so the combined mesh audits watertight iff every body does. **Not** a substitute for union when bodies overlap in space — see the caveat below the table), `velocity` (m/s, SI, `0 < v ≤ 100`, default 20), `density` (kg/m³, SI, default 1.225), `iterations` (positive integer, default 60), `gridRes` (`"ultra-fast"` \| `"fast"` \| `"standard"` \| `"slow"` \| `"ultra-slow"` or an integer ≥ 8, default `"fast"`), `units` (`"mm"` \| `"cm"` \| `"m"` \| `"in"`, default `"mm"` — the modeler works in mm), `emitVtk` (bool — write a STRUCTURED_POINTS field VTK; in mode "transient" the VTK series + playable GLB, CADSF-102614), `AoA` (degrees in `[-45, 45]`, default 0 — pitches the +X nose up under the −X flow; realized as a flow-frame rotation, CADSF-313), `padding` (`{ inlet?, outlet?, top?, bottom?, left?, right? }` body-span multiples ≥ 0 — wind-tunnel domain sizing, ADR-001 §4.2), `blockageMax` (number > 0 — warn when frontal-area/tunnel-area exceeds it), `emitField` (bool — attach the sampled velocity/pressure field AND its model-space map-back), `saveStl` (path — write the exact watertight STL handed to CFD, the auditable "what CFD saw" artifact), **CADSF-316 full-setup fields:** `preset` (`"external-aero"` \| `"automotive-ground"` \| `"quick-look"` — seeds a complete config that explicit fields then override), `sideslip` (degrees in `[-45, 45]` — yaw, composed with `AoA`), `direction` (`[x,y,z]` inflow unit vector — decomposed to pitch+yaw, overrides `AoA`/`sideslip`), `wallType` (`"slip"` \| `"noSlip"` \| `"movingGround"` — tunnel-wall condition; `movingGround` moves the floor with the flow for road-car cases), `groundVelocity` (m/s — moving-ground speed, defaults to the inlet speed), `turbulence` (`"k-epsilon"` \| `"k-omega-sst"` \| `"laminar"` \| `"none"` — closure; `laminar`/`none` zero the eddy viscosity. `"k-omega-sst"` (CADSF-318) is a **real** Menter k-ω SST closure — ω-transport, F1/F2 blending (k-ω near walls → k-ε free-stream via a coarse wall-distance field), the shear-stress-limited eddy viscosity ν_t = a1·k/max(a1·ω, S·F2), and the ω cross-diffusion term, discretised in the same first-order voxel-MAC style as k-ε (Menter 1994 / SST-2003 constants, NASA TMR). **Scope honesty:** on this coarse uniform grid SST selects the blending/limiter *behaviour* for comparative trends — it is **not** certified boundary-layer physics (no wall functions / y+ fidelity). Plain `"k-omega"` (Wilcox) is still **rejected** — not implemented), `turbulenceIntensity` (0<I<1), `turbulenceLengthScale` (>0), `kinematicViscosity` (m²/s, SI), `reynolds` (advisory), `minCellsAcrossThin` (integer ≥ 4 — **global** grid-refinement knob), `refinement` (**CADSF-319 local/adaptive refinement** — `{ "zones": [ { "box": { "min":[x,y,z], "max":[x,y,z] } \| "auto": "near-body"\|"wake", "ratio": 2\|4 } ] }`; **EXACTLY ONE** zone. A two-level nested-block **one-way zonal re-solve** runs a finer uniform sub-grid (2:1 or 4:1) over the zone with its boundary values frozen from the coarse solve; force coefficients are then re-integrated on the finest grid covering the body and the fine samples are merged into the field. `box` is an SI (metres, flow-frame) AABB; `auto:"near-body"` pads the body bbox by ~½ span per axis, `auto:"wake"` extends ~3 body-spans downstream (−X). Multi-zone / octree / two-way nesting are documented follow-ups. Provenance carries `refinement` with per-zone `fineCellSize`/`effectiveCells`; `convergence.levels` reports the real residual per level), `sizing` (**CADSF-1568** — the CADSF-1564 SIZING FIELD as the other way in to that nested refinement: `{ "regions": [ { "h": <mm>, "box"|"sphere"|"cylinder": … } ] }`, stated in **MODEL** frame **millimetres**, which is how the rest of the product spells mesh intent. Up to 32 regions, **one nested block per region** (**CADSF-1823**; before it a second region was refused as `sizing-multi-region` rather than truncated to the first). Blocks whose flow-frame boxes OVERLAP are merged into the AABB of their union at the finer ratio (finest `h` wins; reported in `provenance.refinement.overlap.merges` and warned); each body triangle is integrated on the FINEST block covering its centroid, else on the coarse grid (`provenance.refinement.forceIntegration.patches`, whose shares sum to Cd/Cl/Cy); all blocks together may hold at most 800 000 fine cells, over which the solve is refused by name before it starts (`sizing-cell-budget`) — a single region keeps the CADSF-319 per-block cap instead. `provenance.refinement` stays the back-compatible summary (with several blocks: per-block fields describe the finest block, cell counts are sums) and gains `blocks` — the per-block list (region(s), shape, h, ratio, zone, cells, coarse-frozen boundary, force share). `sizing` together with `refinement` is refused as `sizing-conflicts-with-refinement` (two vocabularies for the same refinement). A `sphere`/`cylinder` region maps to its axis-aligned **bounding box** — the nested block cannot be anything else — which refines a superset of the request, never a subset, and `provenance.sizing.enclosure` says `"aabb"` when that happened. `h` is snapped to the nearer of the two sub-grids this solver has, 2:1 or 4:1, on a log scale; `provenance.refinement.sizing` reports the ratio your `h` asked for, the one granted, and whether it was clamped. Every other sizing key (`hMax`, `hMin`, `growthRatio`, gap/feature-edge counts) describes a gradated tet field and has no meaning for a Cartesian lattice: it is ACCEPTED so one `sizing` object can also be handed to `cad_tessellate_body`, and listed in `provenance.sizing.unmappedKeys` plus a `provenance.warnings` line so it is never silently dropped. Because the refined block re-integrates the coefficients, `provenance.refinement.uniform` carries the coarse-grid answer it replaced and `provenance.refinement.delta` the **drag delta between them** — the mesh sensitivity of the number, measured in the same solve), `projectionSweeps` (positive int — Gauss–Seidel pressure-solve sweeps per step), `residualTarget` (0<r<1 — early-stop on the real residual, CADSB-49), **CADSF-544 closure knobs** (the CADSB-56/55/54 `bc.solver` controls, forwarded verbatim): `eddyViscosityCap` (`"auto"` \| `"legacy"` \| a number > 0 in m²/s — the eddy-viscosity ceiling. `"auto"` (**the default since CADSF-536**) is min(1.0, 0.01·U·L); `"legacy"` is the flat 1.0 m²/s constant that pre-dates it, ~67 000× the molecular viscosity of air and over-diffusive wherever it binds. `provenance.turbulence` reports the cap, its mode and the share of cells it actually clipped), `wallPressureSampling` (`"nearest"` \| `"trilinear"` — how the per-triangle force integration reads the pressure field. `"trilinear"` (**the default since CADSF-536**) interpolates over the 8 surrounding fluid cells and removes the h-sized piecewise-constant staircase; `"nearest"` is the pre-CADSB-56 floor-to-cell probe), `skinFriction` (`false` \| `true` \| `"gradient"` \| `"wallFunction"` — the viscous wall-stress term in the force integration. **Default `false`**, and opt-in on purpose: CADSF-536 measured it worse on every validation case, because it only ever ADDS drag to an already over-predicting solver. When on, `coefficients.dragBreakdown` carries the pressure/friction split; `true` means `"wallFunction"`), `scaleInvariant` (bool — ties the k-ε length scale and the eddy-viscosity ceiling to U·L instead of the legacy absolute constants (1.0 m, 1.0 m²/s), so the same non-dimensional body answers the same at any world scale. **Default `true` since CADSF-536**, which is why an explicit `false` is a MEANINGFUL value and is carried through to the solver rather than treated as absent). Omit all four and the CADSF-536 defaults apply; state `"eddyViscosityCap": "legacy"`, `"wallPressureSampling": "nearest"`, `"scaleInvariant": false` to reproduce a pre-CADSF-536 baseline from a saved document. `placementSensitivity` (bool, **CADSB-100081**, schema 1.51.0 — `bc.solver.placementSensitivity`: with a refinement block, a second solve with every frozen block face that integrates the body moved OUT one fine cell; `provenance.refinement.placementSensitivity` carries reference/moved Cd/Cl, the delta and a `verdict`: `"insensitive"` (|ΔCd| ≤ 1 %), `"sensitive"` (≤ 5 %), `"placement-dominated"` (> 5 %: the number is the block's, not the body's) or `"not-applicable"` (no block touches the body). Roughly doubles the refined solve's cost), **RBCFD transient fields (CADSF-102610 / -102612 / -102613):** `mode` also takes `"transient"` — time-accurate overset rotating zones on the D3Q19 lattice, a hover solve (still air, 1.225 kg/m³ and 1.5e-5 m²/s; `velocity` and the fluid fields are not read); `rotatingZones` (array, at most 16 zones, each `{ bodies: [op id, …] (required: the bodies that rotate), axis: [x,y,z] (any non-zero length, normalised), centre: [x,y,z] (a point on the axis), omega (rad/s, signed right-handed about axis) \| rpm (exactly one), radius?, halfHeight? }`, lengths in `units`; defaults: R_rotor = the largest distance of any vertex of the zone's bodies from the axis, `radius` = 1.27 R_rotor, `halfHeight` = max(0.4 R_rotor, the bodies' axial reach from `centre`) — an explicit value below those is refused `rotating-zone-too-small`); exactly one of `duration` (s > 0) or `revolutions` (> 0, turns of the fastest zone); `sampleEvery` (> 0, in the unit of the run length — seconds with `duration`, revolutions with `revolutions`; `floor(run / sampleEvery)` series samples per zone, none when omitted); `dryRun` (bool — return the resolved `plan` and stop: dx, dt, per-zone Mach and cells, the cell budget, an EST wall time; no solve). `bodies`, `gridRes`, `padding`, `units`, `AoA` / `sideslip` / `direction` apply as in the steady mode; any other external-solve field is refused by name, as are the transient-only fields without `mode: "transient"`; `rotatingZones` in the steady external mode is refused `rotating-zones-need-transient`. Named geometry and budget refusals: `rotating-axis-degenerate`, `rotating-zone-too-small`, `rotating-zone-overlap`, `rotating-zone-clips-static-body`, `rotating-zone-outside-domain`, `rotating-zone-tip-mach` (tip lattice Mach > 0.3), `rotating-zone-cell-budget`, `rotating-zone-near-boundary`, `rotating-zone-too-thin-for-overset`, and `rotating-transient-needs-gpu` (a plan over the CPU reference's 3e5 cells or 3e8 lattice updates — this synchronous op runs the CPU reference only). The reply is described under **Rotating zones, transient mode** below |

**Multiple bodies, no union required (CADSF-363).** `bodies` lets `wind_tunnel`
test several solids as ONE geometry directly — the motivating case is two
bodies that were never meant to be one part (e.g. the ticket's torus sitting
next to a separate disc): `cfd/bridge.js`'s `tessellateSolids` tessellates
and audits each body independently, then concatenates the meshes with a
running vertex-index offset. This is **exact for DISJOINT bodies**: the
solver's voxelizer classifies a cell solid/fluid by ray-triangle crossing
PARITY, and a point inside exactly one shell crosses only that shell's
boundary (odd = solid), regardless of how many other shells share the mesh.
It is **NOT** a substitute for `union` when bodies overlap in space — a point
in an overlap region is crossed by *both* shells (even parity = reported
fluid, i.e. the overlap silently reads as a hole). Overlapping geometry still
needs a real `union` op first. The combined mesh audits watertight iff every
input body does; a failure names exactly which body ref(s) are not.

`wind_tunnel` is an **analysis** verb: it produces **no new solid** (like
`sketch_*`/`save`), so its `id` cannot be used as an `of` for later ops. Its
result is attached to the op report as `windTunnel: { coefficients: { Cl, Cd, Cm },
convergence, provenance, artifacts, aoaDeg, mesh }` — plus, when `emitField`
is set, `field` (raw SI flow-frame samples) and `modelSpace` (the CADSF-313
map-back: positions/extents converted to **model units in model orientation**
so overlays land on the actual geometry; velocity vectors are re-oriented but
keep SI m/s magnitudes; `domainMin/Max` is the enclosing model-space box —
exact when `AoA` is 0).

**Watertight gate (CADSF-313).** The bridge refuses to export a leaky mesh:
if the tessellated solid has open or non-manifold edges the op **fails** with
a repair hint instead of handing garbage to the solver.

**Internal flow (CADSF-100677, schema 1.50.0).** `"mode": "internal"` answers flow
THROUGH the body instead of around it (packages/cfd/src/internalFlow.js; the
contract, validation table and refusal codes are docs/CFD_INTERNAL_FLOW.md). The
body is the **fluid volume** (a duct's bore, a manifold's channels) and its faces
are named by face selector, one or a list per port: `inlets`
`[{ faces, velocity | massFlow, profile? ("uniform" | "developed"), temperature? }]`,
`outlets` `[{ faces, pressure? }]`, optional `heat` `[{ faces, power | temperature }]`;
every other face is a no-slip adiabatic wall. `cellsAcross` (the finest grid's
cells across the smallest inlet hydraulic diameter, 4-64, default 12) and
`stations` (1-200 sections along the passage) are internal-only, as are (**CADSB-100185**, schema 1.51.0)
`refinementRatio` (the three-grid GCI ratio, 1.1-2, default 1.3), `specificHeat` (J/(kg·K)) and `conductivity`
(W/(m·K)) — the fluid's cp and k for a heated passage, default air (before 1.51.0 a heated passage always used air's,
whatever `density` / `kinematicViscosity` said the fluid was); `density`,
`kinematicViscosity` (SI; default air at 20 °C), `iterations`, `residualTarget`,
`units`, `emitField` and `saveStl` apply; every other field is an external-solve
field and is refused by name in internal mode (as the internal-only fields are
without it). The result is `windTunnel: { of, mode: "internal", mesh, faces, internal }`
— `faces` the B-rep face ids each port resolved to, `internal` the solver's answer
(`pressureDrop {value, gci}`, `outlets[].split`, `massBalance`, `thermal`,
`stations`, `gci.levels`, `provenance`). A solver refusal carries its `reasonCode`
(`internal-invalid` with `detail.validationErrors`, `internal-disconnected`,
`internal-face-unresolved`, `internal-cell-budget`, `internal-mass-imbalance`,
`internal-cut-area-dropped`). Every number is solved on three grids
(refinement ratio 1.3); the external solve is unchanged.

**Rotating zones, transient mode (CADSF-102610 / -102612 / -102613).** The reply is
`windTunnel: { of, mode: "transient", mesh, plan, transient }`; with `dryRun: true` it is `{ of, mode, dryRun: true, mesh, plan }`
and nothing else. There is no steady `coefficients` block: the solve is hover (free stream 0), where Cl / Cd / Cm
(normalised by ½ρU²) are undefined — `transient.vehicle.coefficients` is `null` with the reason. `transient` carries
`backend` (`"cpu"`), `steps`, `revolutions`, `seconds`, `mlups`, `massDrift`, `interface`, `lattice`, `cellBudget`, `fluid`,
`control: null`, and, per rotating zone k (the order of `rotatingZones`):

- `zones[k]` — `{ index, thrust, torque, CT, CQ, sampleEverySteps, series, nodes }`. `thrust` (N: the fluid's force on the
  rotor along the zone axis) and `torque` (N·m: the fluid's moment on the rotor about the axis, signed along it; negative
  for a positive `omega` means the air resists the rotation) are the LAST sample's two-step mean, not an average — the
  cycle mean is in `rotors[k].averaged`. `series` holds the `plan.run.samples` entries the run's `sampleEvery` asked for
  (`[]` when omitted): `{ step, time (s), theta (rad, unwrapped), thrust, torque, CT, CQ, forceBody[3] (N), torqueBody[3]
  (N·m about the zone centre) }`, the loads in the body frame that turns with the zone.
- `rotors[k]` (CADSF-102613) — `{ zone, index, bodies: [{ref, solidId}], rpm, omega (rad/s), rotorRadius (op units),
  diameter (m), area (m²), axis, convention (the formulas, as text), completedRevolutions, averagingRevolutions, averaged,
  averagedNote, phase, convergence, series }`.
  - `averaged` is the arithmetic mean over the load samples of the last N = min(2, complete revolutions) complete
    revolutions of THAT rotor, or `null` with `averagedNote` when the run has no complete revolution (run at least one):
    `{ window: { revolutions, samples, steps, from, to, fromTime, toTime }, thrust (N), torque (N·m, fluid on rotor,
    signed), shaftTorque (N·m = −torque·sign(omega): what the drive supplies, > 0 when the air resists), power (W =
    shaftTorque·|omega|), CT, CQ, CP (propeller / UIUC convention: T / (ρ n² D⁴), Q / (ρ n² D⁵), P / (ρ n³ D⁵), n =
    |omega| / 2π, D = 2 R_rotor, Q = shaftTorque), CT_rotor, CQ_rotor, CP_rotor (helicopter convention: T / (ρ A Ω² R²),
    Q / (ρ A Ω² R³), P / (ρ A Ω³ R³), A = πR², Ω = |omega|; CP_rotor = CQ_rotor), FM (figure of merit CT_rotor^1.5 /
    (√2 CP_rotor), hover only: null with a note when the free stream is not 0 or the power is not positive), notes }`.
    The coefficients use the shaft torque, so unlike `zones[k].CQ` (signed like `torque`) they are positive for a rotor
    working against the air.
  - `phase` is every load sample of the run as parallel arrays `{ step, revolution (1-based), theta (rad, reduced to
    [0, 2π)), thrust, torque }`: the `sampleEvery` samples plus the solver's own 16 per revolution of the fastest zone, so
    the averages exist with or without `sampleEvery`; `averaged` and `convergence` can be recomputed from it.
  - `convergence` is `{ criterion: 0.01, completedRevolutions, perRevolution: [{ revolution, samples, thrust, torque }],
    lastChange: { thrust, torque } (|Xₙ − Xₙ₋₁| / |Xₙ₋₁| between the last two complete revolutions; null before two),
    converged (both below 1 % with at least two complete revolutions; otherwise false, the numbers stay), note }`.
  - `series` is `zones[k].series` (the same array).
- `vehicle` — window means over the last 2 complete revolutions of the fastest zone: `{ frame: "lab" (the flow frame),
  window, totalForce[3] (N: every rotor's body-frame force rotated to the lab frame, summed), totalMoment[3] (N·m about
  the plan origin), thrustTotal (N: the sum of the rotor thrusts along their own axes), torqueTotal[3] (N·m: Σ torque ·
  axis), torqueBalance (|torqueTotal| / Σ|torque|: about 0 for balanced CW / CCW rotors, 1 for co-rotating ones),
  staticBodies: null, coefficients: null, notes }`. Forces on non-rotating bodies are not measured (`staticBodies` is
  `null`: the background lattice is stepped without wall-force accumulation); `coefficients` stays `null` in hover and
  is where the Cl / Cd / Cm block goes once a free-stream run exists (CADSF-102795).
- `gci` is `null` with `gciNote`: no grid-convergence index is computed (it needs three resolutions on the GPU path;
  the recipe is docs/cfd/OVERSET.md, "Loads and convergence"). `averagingRevolutions` (2) and `loadSamplesPerRevolution`
  (16) echo the solver options `averagingRevolutions` and `loadSamplesPerRev` (solver options, not op fields); `loadSamples`
  is the number of load samples.

**Transient field output (CADSF-102614).** In mode `"transient"`, `emitVtk: true` writes the flow field at every plan sample
(`sampleEvery`; with none, at the last step) into `<baseDir>/<id>.transient/` (baseDir: the run's `baseDir` option, default the process
cwd): `<id>.pvd` (a ParaView collection), one legacy ASCII STRUCTURED_POINTS file per block per sample (`<id>_bg_NNN.vtk` for the
background lattice, `<id>_zoneK_NNN.vtk` for rotating zone K), `<id>_<block>.vtk.series` (a ParaView time-series list per block) and
`<id>.glb` (a playable animation of Q iso-surfaces; the same switch, there is no separate GLB field). Each .vtk holds
`velocity` (lab-frame m/s), `vorticityMag` (|curl u|, 1/s), `Q_fd` (1/s², from central differences), `Q_pi` (1/s², the strain taken from
the LBM stress moments) and `mask` (0 fluid, 1 solid, 2 hole / imposed-state (EQ) node, 3 fringe or receiver node). A zone's grid is
body-fixed: its .vtk carries `ZoneTransform` (FIELD data, 16 numbers, row-major 4×4 block → lab at that sample) and the .pvd names it;
apply a Transform filter per zone in ParaView. `emitField: true` attaches the LAST sample to the reply as `transient.field`
(`{ step, time, encoding, blocks: [{ kind, index, name, dims, origin, spacing, transform, counts, fields }] }`, each field a
`{ encoding: "base64-f32le" | "base64-u8", length, data }`). With either flag the reply gains `transient.output`
(`vtk: { pvd, series, files, samples, bytes, bytesPerSample, ... }`, `glb: { path, frames, frameNodes, triangles, bytes, duration, slowMotion,
isoQ, ... }`, `writerSeconds`, `qAgreement`, `errors`); a writer error is recorded in `output.errors` and never fails the solve. The rest of the
reply is byte-for-byte what it was without the flags. docs/cfd/OVERSET.md "Field output (CADSF-102614)" has the layout, the conventions and the numbers.

**Compressible regime (CADSF-101601, MFC [8/14], schema 1.66.0).** `"regime": "compressible"`
runs the mesh-free compressible solver (packages/cfd/src/compressible/windTunnel.js: the
MFC [2] SDF, the MFC [3] HLLC/MUSCL/SSP-RK3 block, the MFC [4] immersed boundary and the
MFC [5] viscous wall) around `of`, which may be a **body handle** (one solid: exact B-rep
SDF; with `bodies`: the combined tessellation) **or an imported mesh** (an .stl/.obj/.3mf
import's `meshId` channel). The regime's own inputs ride in ONE object, `compressible` (one
argument in the MCP tools/list line instead of twelve): `mach` (required; 0.3-25), the freestream as `altitude`
(m, geometric, U.S. Standard Atmosphere 1976, default 0) **or** `pressure` (Pa) +
`temperature` (K), `gamma` (1.05-5/3, default 1.4), `model`
(`"euler"` default, slip wall | `"laminar"` no-slip resolved wall | `"SA"` Spalart-Allmaras
with the equilibrium wall model | `"wallModel"`, **refused** `compressible-model-unsupported`:
the equilibrium wall model needs SA for the outer layer), `wallTemperature` (K, isothermal;
viscous models only; default adiabatic), `backend` (`"auto"` | `"cpu"`; `"gpu"` is refused
from a document op, `compressible-gpu-unavailable`, because the MFC [7] GPU solve is
asynchronous), `refArea` (model units²; default the frontal shadow area), `refLength` (default
the largest bounding-box extent), `momentCenter` (default the bounding-box centre) and `saveVtk`
(the volume VTK path). Top-level, as in the incompressible solve: `AoA` / `sideslip` / `direction` (the
incompressible convention: the free stream runs along -X of the flow frame with the +X nose facing it, lift +Z,
side +Y, positive AoA pitches the nose up; `flowFrame` reports the axes in model coordinates), `gridRes` (cells across the body's largest extent: `"ultra-fast"` 8,
`"fast"` 12 (default), `"standard"` 20, `"slow"` 32, `"ultra-slow"` 48, or an integer >= 8),
`iterations` (default 300), `residualTarget` (default 1e-4), `units`, `emitField`
(per-triangle Cp and a second field slice), `emitVtk` (a STRUCTURED_POINTS volume VTK of Mach, p,
rho, T, cellType and velocity at `<id>.compressible.vtk` or `compressible.saveVtk`, plus
`<name>.surface.vtk` with per-triangle Cp), `saveStl` and `bodies`. Every incompressible or internal
field is refused by name in this regime, and `compressible` without it; an unknown key inside
`compressible` is refused by name. Example: `{ "op": "wind_tunnel", "of": "nose", "regime":
"compressible", "compressible": { "mach": 3, "altitude": 20000, "model": "euler" }, "AoA": 2 }`. The result is `windTunnel: { regime: "compressible",
model, solver, freestream, flowFrame, reference, coefficients {Cd, Cl, Cy, Cm, Cn, Cr,
pressure, friction, forebodyCd}, forces (N, N m), cpMap {min, max, areaMean, maxReference,
perFace, values?}, shock {standoff, cellsAcrossStandoff, reference (Billig 1967 when the nose
is an exact sphere, or a cylinder across the flow)}, totalPressure {p02_p01, loss, reference
(normal shock)}, fieldSlices [{normal, dims, mach, p, rho, T}], wall (viscous: y+, tau, q,
Tw), convergence, provenance, validity, artifacts }`. `validity` carries `level:
"comparative"`, the grid, the caveats this run triggered (unconverged, under-resolved
standoff or nose, unresolved boundary layer) and the **measured shortfalls** of
docs/cfd/MESH_FREE_COMPRESSIBLE.md (the Mach 3 cylinder standoff +7.6 %, the 3-D sphere
curvature term, ...). Refusals carry a `reasonCode`: `compressible-mach-range`,
`compressible-altitude-range`, `compressible-freestream-conflict`, `compressible-gamma-range`,
`compressible-model-unsupported`, `compressible-gpu-unavailable`, `compressible-cell-budget`
(over 2,000,000 cells; names the largest gridRes that fits), `compressible-under-resolved`
(a feature under 2 cells across), `compressible-bad-mesh`, `compressible-field-not-read`
(`wallTemperature` with `"euler"`), `compressible-grid-invalid`, `compressible-diverged`.

### Import (`StepParser` / `IgesReader` → graft)

| op | required fields | optional fields |
|----|------------------|------------------|
| `import` | `path` (STEP `.step`/`.stp`/`.p21` or IGES `.igs`/`.iges`, absolute or relative to the doc's baseDir) | `units`, `tolerant` (IGES only) |

The **extension picks the reader** — an extension this verb does not read is
refused at validate time by name (with a pointer to `tools/import_probe.js`
for the formats that have a probe but no import), rather than handed to
`StepParser` to fail as a syntax error about a file that is not broken.

**STEP.** Parses the file with the real `StepParser` and grafts **every**
`MANIFOLD_SOLID_BREP` it contains into the shared document, so imported parts
chain into edits/booleans/`wind_tunnel` exactly like built ones. Group
semantics mirror `pattern`: the op's `id` used as an `of` resolves to the
FIRST imported solid; the report carries one `solids[]` entry per solid plus
`imported: { path, solidCount, units }`.

**IGES (CADSF-287).** `.igs`/`.iges` are read by `IgesReader.js`. IGES is a
SURFACE format carrying no shared-edge topology, so the import is **one open
sheet body** (a `SHELL_BASED_SURFACE_MODEL` of independent faces), not solids.
It is published exactly the way `planar_sheet` and a non-closing `stitch`
publish theirs — `entry.sheetId`, env `{ sheetId }` — so `thicken` and
`stitch` can consume it while every solid-taking verb (`subtract`, `fillet`,
`shell`, …) still refuses it by name. **Closing it into a solid is an explicit
`stitch` op**, never something the import does behind your back. The report
carries `imported: { path, solidCount, units, iges }`, where `iges` is the
reader's own census — face/edge/vertex/loop/hole counts, per-class surface and
curve tallies, `closedGaps`, and `skipped`. A per-face refusal fails the whole
op by default (so a half-import cannot masquerade as a clean import of a
smaller part); `"tolerant": true` downgrades it to a `skipped` entry and
imports the faces that do build. A refusal surfaces its named code on the
result as `reasonCode` (`IGES_MALFORMED`, `IGES_UNSUPPORTED_SURFACE`, …) —
see `docs/INTEROP_PLAN.md` for the full vocabulary.

```json
{ "version": 1, "ops": [
  { "op": "import", "id": "skin", "path": "legacy.igs", "tolerant": true },
  { "op": "thicken", "of": "skin", "thickness": 2 }
] }
```

**PMI / presentation (CADSB-265).** The graft above walks DOWN from each solid,
so nothing AP242 hangs off it — the shape representation, `DRAUGHTING_MODEL`,
the annotation/callout presentation graph, `GEOMETRIC_TOLERANCE`/`DATUM`/
`DIMENSIONAL_*` semantic PMI, `GEOMETRIC_ITEM_SPECIFIC_USAGE` face links and
`STYLED_ITEM` colour chains are all reachable only from entities the walk never
visits. Importing a PMI-bearing file now RECORDS it (`imported.pmiCarried:
true`) and `save` re-splices that graph into the output, renumbered into the
document's id space. Each op that consumes a body reports `pmiCarry:
{ faces, oldFaceCount, newFaceCount }` — how many of its input's faces the
rebuilt body could be matched to, so an annotation follows its face across a
fillet. A PMI-free source records nothing and writes exactly what it did
before. See `ops/presentation.js`.

```json
{ "version": 1, "ops": [
  { "op": "import", "id": "part", "path": "model.step" },
  { "op": "wind_tunnel", "of": "part", "gridRes": "ultra-fast", "iterations": 12, "emitField": true }
] }
```

**Units (CADSB-54).** Geometry is normalized to SI **metres** at a single
boundary inside `runWindTunnel` using `units` (mm→1e-3, cm→1e-2, in→0.0254,
m→1). Velocity and fluid properties are **already SI** and never scaled. The
applied scale is recorded in `provenance.unitScale`.

**Accuracy.** Coefficients are **relative/comparative estimates**, never
certified absolute values (good to ~factor-2, grid/scale dependent — see
PHYSICS_AUDIT).

**Convergence (CADSB-49 — FIXED).** `convergence.residual` is now a **real,
deterministic** metric: the final normalized L∞ change of the velocity field
between successive iterations (it decays as the flow settles). The report also
carries `residualHistory` (the full decaying trace), `divergence`
(post-projection max&nbsp;|div u|), and `converged`, which is **derived** from
the residual (≤ `residualTarget`, or a default reporting tolerance). The old
"these are hardcoded placeholders, do not gate on them" caveat is **retired** —
you may now honestly surface the residual/convergence verdict. Setting
`residualTarget` additionally **early-stops** the solve once the residual drops
below it; with no target the full `iterations` count runs (unchanged behaviour).

Example — box, then its drag coefficient:

```json
{ "version": 1, "ops": [
  { "op": "box", "id": "wing", "dx": 200, "dy": 40, "dz": 8 },
  { "op": "wind_tunnel", "of": "wing", "velocity": 25, "gridRes": "ultra-fast", "iterations": 20 }
] }
```

### Save (`StepWriter`)

| op | required fields | optional fields |
|----|------------------|------------------|
| `save` | `path` (output .step file path) | — |

`save` serializes **every** entity built so far in the document (all solids
from every op, regardless of `id`) via `StepWriter`.

### Reserved, not yet implemented

`split`, `assemble`, `joint`, `animate` are reserved op names for later work
items (Phase-6+); the validator accepts them as *known but unimplemented* and
the executor raises a clear "not yet implemented" error rather than a raw
crash if one is actually run.

## Laws (expression-driven parameters)

CADSF-266. A **law** is a scalar function written as a *string*, in one
parameter `t` (`theta` is a synonym). It is the one general mechanism for
parametrizing a feature whose value varies along something — today a
variable-radius rim fillet (`blend`'s `law` field, where `t` is the angle in
radians around the rim and the value is the fillet radius in mm), tomorrow a
swept cross-section or a field-driven op. It is `commands/laws.js`, and it is
the same expression language named parameters already use
(`commands/expr.js`, CADSF-198) — not a second one.

```jsonc
{ "op": "blend", "id": "wavy", "of": "holed", "edges": ["#61"],
  "law": "2 + 0.5*sin(3*t)" }              // r(θ), mean 2 mm, ±0.5 mm, 3 lobes
```

**Grammar.** `+ - * / ^` (with `^` the power operator, right-associative and
binding tighter than a leading unary minus, so `-2^2` is `-4`), parentheses,
and function calls:

| | |
|----|----|
| functions | `abs acos asin atan atan2 ceil cos exp floor log log10 max min pow round sign sin sqrt tan` |
| constants | `PI`, `E` |
| variables | `t` (or `theta`) — the law parameter; plus any **named parameter** of the document |

It is **not** JavaScript or Python: there is no `**`, no `Math.`, no member
access, no assignment, no statements, and no function definitions. Law text is
never `eval`'d — it is parsed by this repo's own tokenizer/parser and walked by
its own evaluator, so text from a parameters table, an AI-authored recipe or an
MCP tool call is only ever *data*. Source length is capped at 512 characters.

**Named parameters.** A law may read the document's parameters
(`"wall/2 + 0.4*cos(4*t)"`) when the executor is given a parameter table
(`run(doc, { params })`, CADSF-198). Note that unlike a numeric field, a law is
**not** pre-resolved by `commands/param-resolve.js` — a law is a *function*, not
a number, so its parameters are bound when the law is compiled. Without a table,
a law may still use `t`, `PI` and `E`; an unresolvable name is refused by name.

**What is checked, and where.** The validator parses the law (syntax + known
functions) and reports a failure positionally, e.g.
`ops[2].law — law "1 + foo(t)": unknown function "foo" (known: abs, acos, …)`.
Compilation then samples the law across its domain, so one that goes
non-finite fails with the offending `t` named rather than reaching a surface.
Meaning-dependent refusals stay with the op that knows what `t` means: a rim
law must return the same radius at `t = 0` and `t = 2π` (use whole multiples of
`t` inside `sin`/`cos`), or `ops/blendBore.js` refuses it rather than tearing
the ring open. A law-driven `blend` reports the law it used back in its result
(`law.canonical`, `law.range`, `law.periodic`).

## Errors

The validator returns human-readable, positionally-addressed messages, e.g.:

```
ops[3].radius must be > 0
ops[0].dx must be a number
ops[2].of references unknown id "widget" (did you mean "plate"?)
```

## Worked examples

### 1. A single box, saved to STEP

```json
{
  "version": 1,
  "ops": [
    { "op": "box", "id": "block", "dx": 20, "dy": 10, "dz": 5 },
    { "op": "save", "path": "out/block.step" }
  ]
}
```

### 2. A 4x circular pattern of a cylinder ("bolt circle")

```json
{
  "version": 1,
  "ops": [
    { "op": "cylinder", "id": "post", "r": 3, "h": 10, "origin": [20, 0, 0] },
    { "op": "pattern", "id": "posts", "of": "post", "type": "circular",
      "count": 4, "center": [0, 0, 0] }
  ]
}
```

### 3. A plate with corner holes (extrude with holes)

```json
{
  "version": 1,
  "ops": [
    { "op": "extrude", "id": "plate", "depth": 10,
      "outer": [[0, 0], [100, 0], [100, 60], [0, 60]],
      "holes": [
        [[8, 8], [8, 4], [12, 4], [12, 8]],
        [[92, 8], [88, 8], [88, 4], [92, 4]],
        [[8, 56], [12, 56], [12, 52], [8, 52]],
        [[92, 56], [88, 56], [88, 52], [92, 52]]
      ]
    },
    { "op": "save", "path": "out/plate.step" }
  ]
}
```

(See `commands/PROMPT.md` for the LLM-facing version of this schema with
worked examples an AI model can copy directly.)
