# Authoring guide

This is the guide to writing a Woodwerx design: a `.kdl` file that describes a piece of furniture as parameters, parts and joints, and that the compiler turns into solids, a cut list and toolpaths. It is the public edition of the authoring contract the project itself is built against, and it is generated from that contract, so it cannot drift from it.

Section numbers are the contract's own, so a gap in the numbering is a section left out here (contributor process, loudspeaker enclosures, and the plan, room and roof chapters). Those chapters, and every node, property and diagnostic, are in the [Language reference](/technology/language).

Read section 1 first. Everything else assumes you know where a part's origin is.

---

## 1. The one thing to get right first: the coordinate system

**Decision D4.** A part's local origin is the **lower-left corner of its TOP face**.

```
        z=0  ─────────────────────┐  <- top face, the reference surface
            /                    /│
   (0,0,0) ┌────────────────────┐ │
           │                    │ │      x -> width   (part-local)
           │   z is NEGATIVE    │ │      y -> depth
           │   going down       │ │      z -> thickness, downward = negative
           └────────────────────┘ ┘
        z = -thickness
```

A part with `dim x=600 y=150 z=19` occupies `x ∈ [0,600]`, `y ∈ [0,150]`, **`z ∈ [-19, 0]`**.

This matches how a CNC operator probes Z off the top of the stock, so cut depths are simply
positive numbers measured downward. **Consequences you must internalise:**

- To sit a part's top surface 90mm below another part's top, write `z="0.0 - 90.0"` or
  `z=-90.0`. Positive Z is almost always wrong.
- `0.0 - expr` is preferred over `-expr` for computed values; write it explicitly.
- A tabletop whose top face is at world Z=0 is placed at `z=0.0`; the legs below it start at
  `z="0.0 - top_th"`.

## 2. Anchors

**Decision D5.** `by` belongs to the *placement*, not the component. The world transform is:

```
M = T(pos) · R(rot) · T(−anchor)
```

Read it as: *"put THIS point on the part at THAT coordinate, having rotated about it."*
An optional `mirror` (§9; RFC-0012, proposal 0202) adds one more factor, outermost:
`M = S · T(pos) · R(rot) · T(−anchor)`. `S` is the identity unless `mirror` is written; D5
itself, and every part that does not use `mirror`, is unchanged.

Available on every part for free:

| Group | Values |
| --- | --- |
| Top corners | `top_sw` `top_se` `top_ne` `top_nw` |
| Top edge midpoints | `top_n` `top_s` `top_e` `top_w` |
| Bottom corners | `bottom_sw` `bottom_se` `bottom_ne` `bottom_nw` |
| Bottom edge midpoints | `bottom_n` `bottom_s` `bottom_e` `bottom_w` |
| Face / body centres | `top_center` `bottom_center` `center` |

`sw` = the `(0,0)` corner in part-local XY; `ne` = `(x_max, y_max)`. Default is `top_sw`.

**The anchor is named in the component's own frame, before rotation.** `top_se` always
means the `(x_max, 0, 0)` corner of *that part's* stock, whatever `rot` says. The rotation
then turns the part about that point. It does not re-pick the corner that ends up south-east
in the parent. (Ratified 2026-09-24; see ADR-0009.)

**Why this matters:** four legs of a table should be four instances of one component, each
placed at the corner it occupies, so that changing `width` moves them correctly with no
compensating arithmetic. There are two correct ways to write it.

When the leg's faces are all the same, don't rotate it. Anchor each leg by the corner that
lands on the table's corner:

```kdl
part Leg_FL instance=Leg { by top_sw; pos x=0.0   y=0.0   z=0.0 }
part Leg_FR instance=Leg { by top_se; pos x=width y=0.0   z=0.0 }
part Leg_BR instance=Leg { by top_ne; pos x=width y=depth z=0.0 }
part Leg_BL instance=Leg { by top_nw; pos x=0.0   y=depth z=0.0 }
```

When the leg has faces that must point inward (mortises, a taper on the inside faces), keep
one anchor and turn the leg about it. Each quarter-turn brings the same two faces round to
face the middle of the table:

```kdl
part Leg_FL instance=Leg { by top_sw; pos x=0.0   y=0.0   z=0.0 }
part Leg_FR instance=Leg { by top_sw; pos x=width y=0.0   z=0.0; rot z=90.0 }
part Leg_BR instance=Leg { by top_sw; pos x=width y=depth z=0.0; rot z=180.0 }
part Leg_BL instance=Leg { by top_sw; pos x=0.0   y=depth z=0.0; rot z=270.0 }
```

`examples/dining_table.kdl` does the second as a `repeat` (`rot z="i * 90.0"`). **Do not mix
the two.** `by top_se; rot z=90.0` at `x=width y=0` turns the leg about its own south-east
corner and swings it out of the table, to y ∈ [−40, 0] for a 40×60 leg. This guide printed
that line until 2026-09-24; it was checked by compiling it.

> Nodes may be separated by `;` on one line **or** written on separate lines. Both parse.
> The examples below use separate lines for readability.

## 3. Document skeleton

```kdl
project name="Thing" units=mm schema_version=1 front=neg_y {
    import Name from="lib/file.kdl" export=Name   // optional, §14
    params    { /* numbers and CEL expressions */ }
    materials { /* stock definitions */ }

    component Name material=Mat role=leg { /* reusable part template */ }
    joinery   kind name=JointName        { /* reusable joint contract */ }

    assembly Root { /* parts, joins, repeats, sub-assemblies */ }
}
```

`units` must be `mm`. All dimensions are millimetres, always as floats (`19.0`, not `19`).

`front=` declares which world direction is the piece's front — one of D6's six
face-normal names, `pos_x` `neg_x` `pos_y` `neg_y` `pos_z` `neg_z`, read here as a world
direction rather than a part-local face. **Optional; defaults to `neg_y`**, the convention
every example already follows without saying so (the bedside cabinet's header: "the case
front … is y = 0", facing −y). It costs nothing to leave out and nothing changes for a
document that does not declare it. `ww-style`'s scene facts (`docs/STYLES_PLAN.md` M17) read
it to decide which faces are a "show" face — facing front, an end, or up.

`floor=` declares the world `z` of the floor plane — a bare number, in mm. **Optional; no
default.** The compiler has no floor of its own: D4 anchors a part's top face, and many designs
hang from `z = 0` with the legs going down (the desk's feet are at `z = −722`), so a built-in
`z = 0` would call them all wrong. A project that says where its floor is gets three
`ww check` invariants, each a defect (exit 1): `ww::check::below_floor` (a part reaches more
than a micron under the plane), `ww::check::floating` (nothing in the model reaches it) and,
with or without a floor, `ww::check::empty_assembly` (a placed sub-assembly with no parts
under it, so it is silently absent from the model). A fourth, `ww::check::extra_root`, is only a warning (the verdict is unchanged): a second
top-level assembly no `instance=` names is a second root at the origin — legal for a project of two
pieces, but also what an assembly you forgot to place turns into. Contact is the model's lowest point, not
each leg's: which parts *should* stand on the floor cannot be declared yet. Nothing else
reads `floor=`; the build ignores it.

## 4. `params` — numbers, expressions, and metadata

**Decision D1.** Parameters live in `params` blocks, never as properties on `project`.

```kdl
params {
    // A literal with UI metadata. This is what generates a slider.
    width 1400.0 min=1000.0 max=2000.0 step=10.0 label="Overall Width" category="Envelope"

    // A bare literal — no slider, just a constant.
    glue_clearance 0.2

    // A CEL expression — QUOTED. Evaluated after its dependencies.
    leg_len "height - top_th"
    open_w  "width - (2.0 * leg_dim)"
}
```

**The rule that decides quoting: a bare number is a literal, a quoted string is an expression.**

A declared `min=` / `max=` binds every value the param takes, an instance's override included:
`part Drawer instance=DrawerBox depth=120.0` against `depth 450.0 min=200.0` is an
`ww::eval::out_of_range` error on the override, naming the declared range (`200..=700`), not a
silent clash in the geometry. Declare the range you mean to allow; a `--set` is checked the same way.

### Unit literals: `(in)` and `(mm)`

Geometry is millimetres. To write a number in inches, annotate it: `(in)34.5` is 876.3 mm,
resolved when the file is parsed. `(mm)12.5` is the same as `12.5`. It works on any number,
including slider metadata: `w (in)36 min=(in)12 max=(in)96 step=(in)0.25`. In an expression,
use `inch(x)` (`in` is a CEL operator, so it cannot be the name): `"inch(1.5) + gap"`.

```kdl
params { cabinet_h (in)34.5   toe_kick "inch(4.5)" }
```

**Fractional inches: `(in)"1-3/4"`.** A KDL number cannot be `3/4`, so write the
inches as an *annotated string*: `(in)"34 1/2"`, `(in)"1-3/4"`, `(in)"3/4"`, `(in)"2'"`,
`(in)"1'-2\""` (a KDL string needs no escaping for `/`, `-` or spaces). It is converted to
millimetres at parse time exactly like `(in)34.5` (`(in)"34.5"` is the same bits), works on
every number entry including `min=`/`max=`/`step=`, and `(in)"1/0"`, `(in)"1 2"` and
`(in)"12.7mm"` are `ww::dsl::bad_value` carrying the parser's code (`ww::length::…`). `(mm)"…"`
is not a thing. The same text is accepted by `ww build --set width="34 1/2"`, by the MCP/API
`overrides` (`{"width": "34 1/2"}`), and typed into the viewer's inch fields; there an unmarked
decimal is inches in a field and millimetres in `--set`/`overrides`, while a fraction or a feet
mark is always inches. Grammar: `34.5`, `34 1/2`, `34-1/2`, `1-3/4"`, `3/4`, `3/4in`, `1'-2"`,
`2'`, `12.7mm`, `1.27cm`.

**A bare number is always millimetres**, whatever `project units=` says; `36in` is a KDL
syntax error; any annotation other than `(mm)`/`(in)` is `ww::dsl::unknown_unit`. `units=in`
only changes how the viewer *displays*: a param with `unit_display="in"` reads as fractional
inches (`34 1/2"`) beside its field. The viewer also has a per-browser **Inches** switch
(Parameters header) that shows every length parameter, the cut list, the inspector and the
scale bar in US fractional inches (to 1/16 for fields, 1/32 for parts) and accepts typed
fractions (`30 1/2`, `3/4"`, `1'-2"`, `12.7mm`), parsed by the compiler's own parser; the compiler, cards, drawings and DXF stay in
millimetres. The configurator's cut-list PDF and CSV follow the switch.

### Expression language

Expressions are CEL: `+ - * / ( )`, comparison, and `cond ? a : b`, plus the builtins listed below. They may reference any parameter visible in the enclosing lexical scope. They are evaluated in dependency order, so declaration order does not matter — but **cycles are a hard error**.

**Builtin functions** (angle unit is **degrees**, not radians):

| Family | Functions | Example |
| --- | --- | --- |
| Trigonometry | `sin(deg)` `cos(deg)` `tan(deg)` `asin(x)` `acos(x)` `atan(x)` `atan2(y, x)` | `x="center + radius * cos(angle_deg)"` |
| Rounding | `floor(x)` `ceil(x)` `round(x)` | `count="floor((length - margin) / pitch) + 1.0"` |
| Absolute value | `abs(x)` | `clearance="abs(slop)"` |
| Extrema | `min(a, b)` `max(a, b)` | `safe_depth="min(available, max_reach)"` |
| Exponentiation | `sqrt(x)` `pow(base, exp)` | `diagonal="sqrt(x*x + y*y)"` |
| Units | `inch(x)` — inches to millimetres | `h="inch(34.5) - toe_kick"` |
| Loudspeaker enclosures | `ac_sealed_fc(fs_hz, vas_l, vb_l)` `ac_sealed_qtc(qts, vas_l, vb_l)` `ac_port_length(vb_l, fb_hz, dv_mm, np)` `ac_helmholtz_fb(vb_l, dv_mm, lv_mm, np)` — **litres, hertz, millimetres**; the port functions take and return mm, so a result feeds a dimension directly (see §17) | `len="ac_port_length(vb, 34.0, 50.0, 1.0)"` |

All angles are in **degrees**, matching the schema's convention (`rot z=90.0`, `tilt=14.0`). This eliminates the silent-bug risk from mixed radian/degree arithmetic.

**That is the whole language** (ADR-0005, addendum). The operators are `+ - * /`, unary
`-`, `== != < <= > >=`, `&& || !` and `cond ? a : b` (`%` parses, but CEL's is
integer-only, so it is refused on dimensions); values are numbers, booleans, `null` and
`'text'`, and `+` joins text. The rest of CEL is refused with `ww::eval::not_in_language` or
`ww::eval::unknown_name`: the `map` / `filter` / `all` / `exists` / `exists_one` / `has`
macros, method calls (`x.size()`), field selection, list and map literals, indexing, `in`,
bytes, and CEL's library (`size`, `string`, `double`, `matches`, `timestamp`, …). Each
expression is also limited — a file is untrusted input, and these hold the same in the CLI,
the server and the browser:

| Limit | Value | Diagnostic |
| --- | ---: | --- |
| Expression source | 4 KiB | `ww::eval::expression_too_long` |
| Operators and opening brackets in one expression | 128 | `ww::eval::expression_too_complex` |
| Text any expression builds, including inside it | 4 KiB | `ww::eval::text_too_long` |
| Whole-number arithmetic (`3`, not `3.0`) | ±2^53 | `ww::eval::not_in_language` |

The largest expression in `examples/` is 137 bytes with 14 operators and brackets. If you
reach a limit, split the expression into named parameters.

**Whole numbers and reals do not mix inside one expression.** Every parameter, `repeat`
index and property value you can name is a real (`f64`, ADR-0008), so `n * 2.5` always
works. A *literal* written without a point is a whole number to CEL, and CEL does not
coerce: `2 * 12.5`, `3 + 0.5` and `(i < 2 ? 1 : 0) * 2.5` all fail — in `cut_path` points,
`dado` offsets, `pos` and every other attribute alike — with *every number here is a real,
so write `3.0` and not `3`*. Write `2.0 * 12.5` and `(i < 2 ? 1.0 : 0.0) * 2.5`. A whole
number on its own (`"2 * 3"`, `"i < 2 ? 1 : 0"`) is widened at the end and is fine; the
failure is only a whole number meeting a real in one operator. Whole-number division is
integer division (`7 / 2` is `3`), which is why the language does not quietly promote.

Worked example — a three-legged stool with legs splayed at 120-degree intervals:

```kdl
params {
    seat_center      "seat_size / 2.0"
    leg_circle_radius "seat_size / 2.0 - leg_inset"
}

component Seat material=Ash {
    // ... dim, grain, features ...
    mark MortiseFace_Leg0 x="seat_center + leg_circle_radius * cos(0.0)" \
                           y="seat_center + leg_circle_radius * sin(0.0)" \
                           z=0.0 normal=neg_z
    mark MortiseFace_Leg1 x="seat_center + leg_circle_radius * cos(120.0)" \
                           y="seat_center + leg_circle_radius * sin(120.0)" \
                           z=0.0 normal=neg_z
    mark MortiseFace_Leg2 x="seat_center + leg_circle_radius * cos(240.0)" \
                           y="seat_center + leg_circle_radius * sin(240.0)" \
                           z=0.0 normal=neg_z
}
```

### `params` blocks and scoping

`params` blocks may appear at three scopes, and inner shadows outer:

- `project { params { … } }` — global envelope
- `component X { params { … } }` — defaults, overridable at instantiation
- `assembly Y { params { … } }` — local working values

### Naming and DRY expectations

- `snake_case` for parameters, `PascalCase` for components, joinery names and assemblies,
  `PascalCase_WithSuffix` for part IDs (`Leg_FL`, `Apron_Back`).
- **No magic numbers in the assembly.** If a number appears twice, or is derived from another,
  it belongs in `params`. `z="0.0 - (leg_len - apron_h)"` is right; `z=-660.0` is wrong.
- Give every user-facing dimension `min`/`max`/`step`/`label`/`category`. Derived values get
  none — they are not sliders.

### String values are CEL expressions

There is no sigil and no type tag. A string in a `params` block, or in a named property that
is not a label or enum slot, **is** an expression:

```kdl
params {
    leg_len "height - top_th"        // expression
    member_h "apron_h"               // also an expression — a lone variable
}
part Top { pos x="width - 10.0" }    // expression
```

A bare argument outside a `params` block is a **name**, never an expression — `part Face`,
`edges top_perimeter`.

**If you ever need a genuine string literal**, write it as a CEL expression that evaluates to
one, using single quotes inside the KDL string. No schema change is needed:

```kdl
finish "'danish oil'"
```

`kdl-check` resolves every identifier in every expression, and ignores the contents of CEL
string literals. A typo like `"heigth - top_th"` is an error, not a silent dead reference.

### `preset` — named parameter bundles

A `preset` block names a bundle of literal-parameter overrides, sibling to `params` and
never nested inside it. The viewer shows each one as a card in the parameter panel — click
it, and every parameter it names jumps to the value given; a parameter the preset does not
mention keeps whatever it already was:

```kdl
params {
    case_w  800.0 min=420.0 max=1200.0 step=10.0 label="Width"  category="Envelope"
    case_d  420.0 min=340.0 max=600.0  step=10.0 label="Depth"  category="Envelope"
    height 1700.0 min=500.0 max=1900.0 step=10.0 label="Height" category="Envelope"
}

preset "Tallboy" {
    case_w 800.0
    case_d 420.0
    height 1700.0
}
preset "Bedside Chest" {
    case_w 500.0
    case_d 400.0
    height 620.0
}
```

- **The node's own argument is the preset's display name** (`preset "Bedside Chest" { … }`),
  the same convention `component`/`assembly` already use for their own name, not a `name=`
  property.
- **A preset body holds bare `name value` lines only** — no `min=`/`max=`/`step=`/`label=`/
  other metadata property. A preset override is a *value*, not a second slider declaration;
  that metadata belongs on the parameter's own declaration in `params`. The length-unit
  annotation (`(in)34.5`, `(in)"34 1/2"`) is accepted exactly as it is anywhere else in this
  grammar.
- **Any subset of the project's declared parameters** — a preset with one override line, or
  none at all (`preset "Default" { }`), is legal. A parameter the preset does not name keeps
  the model's own current value.
- **A preset override may target only a literal parameter**, never a derived
  (expression-valued) one — the same rule a plain `--set`/API override already follows
  (`ww::override::derived`). A preset is a bundle of overrides, not a second expression
  language.
- **Every override is validated at compile time**, against the same `min=`/`max=`/`step=`
  and type checks a `--set` already goes through: an override naming no project parameter is
  `ww::preset::unknown`; one targeting a derived parameter is `ww::preset::derived`; a value
  of the wrong type is `ww::preset::type`; outside the parameter's own declared range is
  `ww::preset::range`; off its `step=` grid is `ww::preset::step`. Each message names the
  preset — `preset \`Bedside Chest\`: \`height\` = 620.0 is outside its declared range
  350.0..=2000.0` — so a failure in `./check.sh`'s example-corpus walk points straight at the
  offending preset, not just the parameter. **This is a compile error, same severity as a
  bad `--set`:** a design with an invalid preset does not compile at all, whether or not the
  current request names that preset.
- **Two presets may not share a name** (`ww::preset::duplicate`).
- **Declaration order is display order.** The viewer shows the first three declared presets
  as cards; the rest sit behind a collapsed "More presets" expander. There is no separate
  `order=` property — authoring order is the only ordering mechanism.
- **Presets are per-design.** There is no shared, catalogue-level preset vocabulary; a
  `preset` block lives inside the one `project { … }` body that declares the parameters it
  targets, the same scope `params`/`materials`/`components` already use.

## 5. `materials`

```kdl
materials {
    material WhiteOak    type=hardwood
    material BalticBirch type=sheet nominal_th=12.7 finished_th=12.4
    material Steel       type=metal finish="'brass-plated'"

    hardware ButtHinge_75mm supplier="Horton Brasses" part_no="H-06-75AB" \
        leaf_length=75.0 leaf_width=45.0 leaf_thickness=2.5 knuckle_dia=8.0 price=6.50
}
```

`type` is `hardwood`, `softwood`, `sheet`, `metal`, or `fabric` (upholstery, below). For sheet goods give
both `nominal_th` and `finished_th` — real plywood is undersized and joinery must use the
finished figure. `metal` is solid stock with no grain — everything `sheet` already is for
grain purposes, but a genuine volume rather than a laminate.

A `type=sheet` material is timber-derived panel stock (plywood, MDF) unless it says what it
is made of with `substance=`: `acrylic`, `solid_surface`, `glass` or `laminate`.
Glass may add `tempered=#true`.

```kdl
material Panel type=sheet substance=acrylic nominal_th=6.0 finished_th=6.0
material Pane  type=sheet substance=glass tempered=#true nominal_th=6.0 finished_th=6.0
```

A non-timber sheet is a supply item like a `metal` (below). A tempered pane takes no feature
at all — it cannot be cut, drilled or ground after tempering — and any glass refuses `dado`,
`groove`, `rabbet`, `moulding`, `bevel` and `roundover`: put the recess in the frame the pane
sits in (`examples/acrylic_panel_frame.kdl`). Glass has no nest or cutting DXF in the pack.

A `type=fabric` material is **upholstery**: a seat pad, a cushion, a padded back. One solid stands
for the filling and the cover together; it is bought made up by an upholsterer, so it is a *supply* item like a
`metal`, never a board and never nested. `weave=` is the cloth the photoreal view draws (`plain`, `twill` or
`velvet`; plain when omitted; anything else is an error), `color="#RRGGBB"` is its tone, and `fill=` is a CEL string
printed on the cut sheet where a finish would be, saying honestly what is inside, since the model does not draw it.

### 5.1 `mill=` — rough-sawn to S4S milling allowance

A `hardwood`/`softwood` material's boards are not always bought already at finished size.
`mill=` names how far along the rough-to-S4S road its boards already are: `rough` (as sawn,
not flat, not square), `s2s` (surfaced two sides — faces flattened, edges still rough), `s4s`
(surfaced four sides — already the finished size) or `dimensional` (a named construction-lumber
size, already S4S by definition). **`s4s` is the default when `mill=` is left off entirely**,
which is why every material in every example written before this issue keeps its exact output:
the stock list and BOM report the purchase size equal to the finished size, with zero allowance.

```kdl
materials {
    material WhiteOak type=hardwood mill=rough   // buy rough 4/4, mill it down to size
    material Maple    type=hardwood              // mill=s4s, the default: bought at size
}
```

`mill=rough`/`s2s` adds a milling allowance (a per-mill-state table, never per-species —
milling loss is a function of how rough a board was to start, not what species it is) to the
blank's thickness (removed from **both** faces, since a board is always flattened from both
sides to true it up), width (both edges, straight-lined then ripped square) and length (snipe,
checked ends) before anything is bought or allocated against stock. The stock list prints the
resulting purchase size alongside the finished size it already printed; a `mill=s4s`/
`dimensional` row says nothing more, because there is nothing more to say — it is already at
the size printed. `mill=` on a `type=sheet` material is an error: sheet goods keep their own
`nominal_th`/`finished_th` instead (§5 above), a different, older mechanism for a different
thing (manufacturing variance in a bought, already-finished panel, not a rough-to-finished
milling process the shop performs itself).

```kdl
material Emerald type=fabric weave=velvet color="#1f4d3c" fill="'webbing, springs and horsehair'"
component SeatPad role=seat {
    stock Emerald pad_w pad_d pad_h
    roundover size=12.0 { edges top_perimeter }     // a rolled edge is what makes a slab read as a cushion
}
```

A pad takes `roundover`, `chamfer`, `cut_path`, `trim` and `hole`, and is joined with `butt` or `hardware_mount`;
`dado`, `groove`, `rabbet`, `moulding`, `bevel`, `taper`, `lamination`, `veneer`, `banding`, `stringing` and every
wood joint are refused. `roundover` eases only the blank's own straight edges (a `cut_path` outline stays sawn),
and there is no crown, piping, seam or tufting. `weave=` and `fill=` on any other `type=` are errors.

A `metal` part is a *supply* item: it is bought or made to order, so the cut
list prints it in a SUPPLY section of its own and leaves it out of timber volumes, boards
and blank sizes. Wood joinery (`mortise_and_tenon`, `dowel`, `lap`, …) on a part that is
not wood is judged against one allow-list per pair of materials: `butt` and
`hardware_mount` may touch any part; `edge_glue` is allowed wood–wood, wood–laminate,
wood–solid surface, solid surface–solid surface and acrylic–acrylic; every other kind
(`dowel`, `pocket_hole`, a mortise, …) is wood-to-wood only, and anything else is an error. A `metal` may carry
`thermal_expansion=` (per kelvin, `0.000012` for steel), used only by a shrink fit.

Any material may carry `tolerance_slip=`/`tolerance_snug=`/`tolerance_press=` (mm, issue #55)
— flat properties, like `nominal_th=`/`finished_th=` above, not a child block — and
`movement_radial_pct=`/`movement_tangential_pct=`, its cited green-to-oven-dry shrinkage.
See "Clearance resolution" in the joinery section below for how a joint's `clearance=`/`fit=`
reads these, and for the draft starting-value table.

A rolled band — a wagon wheel's iron tyre, a hoop — is `stock <metal> shape=ring
diameter=<outer> section_w=<axial width> section_h=<radial thickness>`. It is fitted with an
assembly-scope `constraint shrink from=<ring> to=<part> radial_interference=<mm>
temperature=<°C>`: the compiler checks that the built overlap really is that interference
and that the heated bore clears the part, and lists the overlap as design interference
rather than a clash. It models geometry and thermal growth, **not stress**
(`docs/agent-reference/features/non-wood-materials.md`).

`finish=` is optional on any `material`: a CEL expression evaluating to text,
printed on the cut sheet for every part built from that material. Write a genuine string
literal the ADR-0004 way — `finish="'milk paint, barn red'"` — never a bare word, which
CEL would read as an (unknown) identifier. A `part`'s own `finish=` (below) overrides its
material's.

### `hardware` — catalogue parts

A hinge, a pull, a knob, a caster, a slide, a screw, a nail, or a non-wood material with
no machinable geometry (rush, cane, webbing, bought by the hank or the roll, not milled
to a fixed size) — anything purchased as-is rather than cut from `stock`. `hardware`
takes one name, then free-form properties: `supplier=` and `part_no=` are plain text
labels, never CEL (a supplier's name is not an expression); `price=` and every other
property (a hinge's `leaf_length=`/`leaf_width=`/`leaf_thickness=`/`knuckle_dia=`, a
slide's `travel_distance=`/`slide_depth=`/`load_kg=`) are ordinary CEL numbers, so a
project param can drive one. It is never a `component` — a `component` requires exactly
one `stock` line (below), and a hinge has no board to mill.

**Reaching an assembly**, two ways:

- **Mounted**, through an ordinary `join`, when the hardware sits on two marks like a
  hinge does:
  ```kdl
  joinery hardware_mount name=DoorHingeJoint {
      hardware ButtHinge_75mm qty=2   // per join: one join per door, two hinges on it
  }
  join joint=DoorHingeJoint male=DoorStile_L@Hinge_Top female=BaseSide_Left@Hinge_Top
  ```
  On its own `hardware_mount` cuts nothing (the same as `butt`) and counts the item. Say
  what to cut and it cuts exactly that, every number the
  author's or the catalogue's:
  ```kdl
  joinery hardware_mount name=KnobMount {
      hardware Knob
      hole                                  // ⌀ screw_diameter, screw_length deep, at the mark
      hole dx=20.0 diameter=6.0 depth=0.0   // or by hand: depth 0.0 goes through
  }
  joinery hardware_mount name=HingeMount {
      hardware ButtHinge_75mm qty=2
      mortise_depth 2.5     // leaf recess, leaf_length × leaf_width (or mortise_length/_width)
  }
  join joint=KnobMount male=Door@PullPoint   // one-sided: `female=` is optional here only
  ```
  A `hole` bores the male part's face; a `mortise_depth` recesses a leaf on every end the
  join names. A `distribute [from= to= count= setback=]` row places N of them along the mark's
  axis, count and setback from the catalogue's `max_pitch`/`setback`. A mounted item whose join carries a `travel` is *checked* in motion (§8,
  ADR-0075); no force, spring or wear is modelled.
  An item with `shape=round|turned|bar|bail|custom|leaf|span` and its dimensions (`diameter`,
  `projection`, `length`, `width`; a leaf uses `leaf_*`; `span` needs only `diameter` and a
  two-ended `join`, drawn between the marks — RFC-0145), and optionally `color="#rrggbb"`,
  is also drawn in the viewer as a display proxy. A proxy is not a part: it is in no cut
  list, drawing or clash check. See `docs/agent-reference/features/hardware-mounting.md`.
- **Unmounted**, a bare fact with no join and no cut, when there is no sensible pair of
  marks (a slide screwed between two flat faces, a hank of rush, a box of screws):
  ```kdl
  hardware_use BallBearingSlide_16x300 qty=2
  hardware_use Rush_Hank qty=1 at=SeatFrame@Front
  ```
  `qty=` is an integer or a CEL expression (default 1, matching `repeat … count=`);
  `at=` is an optional `Part@Mark` reference, kept for provenance only — it is never
  checked against a real mark, since there is no geometry here to check it against.

Either way, the item reaches the **hardware schedule** (`ww pack`'s
`hardware-schedule.txt`/`.json`) as a purchased line with a summed quantity, **never**
the cut list — there is no `stock` line for it to be cut from.

## 6. `component` — reusable part templates

```kdl
component Leg role=leg {
    params { taper_to 28.0 }        // optional, overridable at instantiation

    stock Ash leg_dim leg_dim leg_len   // REQUIRED — exactly one per component
                                       // material, then x y z, then optional grain

    taper start_z="0.0 - apron_h" end_z="0.0 - leg_len" target_dim=taper_to {
        faces pos_x pos_y
    }

    mark MortiseFace_X x=leg_dim y="leg_dim / 2.0" z=-55.0 normal=pos_x
}
```

`role` is free-form metadata used for BOM grouping (`leg`, `apron`, `rail`, `stile`, `panel`,
`top`, `shelf`, `door`, `drawer_face`, …).

### Turned stock: `stock shape=round` and `section`

A lathe-turned part — a table leg, a chair spindle, a chess piece — is round, not
rectangular. `shape=` on `stock` names the exception; every plain `stock <material> x y z`
line above is `shape=rect`, the default, and is completely unchanged by this.

```kdl
component Pawn role=game_piece {
    // The square blank a lathe starts from: `diameter=` and `z=` (length down the
    // spindle axis), named properties — round stock's arity does not fit ADR-0007's
    // three-positional-argument form. No grain argument: a round blank's grain is
    // derived as lengthwise, along the axis (the spindle case); the one thing you may
    // write is `grain=across`, for a faceplate disc (below).
    stock Boxwood shape=round diameter=stock_size z=total_height

    // The turned profile: two or more `at` stations, each one `circle r=<expr>`,
    // sorted by `z` at compile time regardless of write order. `interp=linear` (the
    // default) makes the polygon from the stations themselves; `interp=spline`
    // (`samples=`, default 64) resamples a centripetal Catmull-Rom curve through them
    // first — a statement about the manufacturing route (a caliper-checked profile vs.
    // a template-checked curve), not a performance choice.
    section axis=z interp=spline samples=64 {
        at z=0.0                  { circle r="ball_diameter / 2.0" }
        at z="0.0 - total_height" { circle r="base_diameter / 2.0" }
    }

    // `top_center` / `bottom_center` / `center` (D6, already free on every part) are
    // the spindle axis, exactly — the circumscribing square's centre, by construction
    // (ADR-0018 §4). Nothing else changes: the corner anchors (`top_sw`, …) remain
    // legal but sit in material the lathe has already removed.
    mark AxisTop x="stock_size / 2.0" y="stock_size / 2.0" z=0.0 normal=pos_z
}
```

**The declared `diameter` must be at least twice the widest `circle` station** — a blank
narrower than the part it is supposed to be turned from cannot be cut, and the compiler
refuses it rather than building something impossible.

**A turned part is a stock primitive, not a cutter (D7 untouched).** `section` defines the
part's own base solid, the role `stock`'s cube plays for every other part; ordinary
features (`hole`, a joint's mortise, …) still cut it same as any other stock. Turning is a
bench operation, not a machine one, in the fabrication pack — there is no CNC setup that
spins a spindle.

**A square pommel over a turning.** A leg turned from *square* stock
keeps a short run of it untouched at the top, so a mortise-and-tenon shoulder still has a
flat face to land on — the classic Shaker or Sheraton leg. Write `stock` as ordinary
square `rect` stock (not `shape=round`), and a `section` whose stations do **not** reach
the part's own top face:

```kdl
component Leg role=leg {
    stock Ash leg_dim leg_dim leg_len   // square, unchanged: no shape=round

    section axis=z interp=linear {
        at z="0.0 - pommel_h"  { circle r="leg_dim / 2.0" }   // the shoulder
        at z="0.0 - leg_len"   { circle r=foot_r }
    }

    mark MortiseFace_X x=leg_dim y="leg_dim / 2.0" z="0.0 - pommel_h / 2.0" normal=pos_x
}
```

Above the topmost station, the stock stays the untouched square pommel; at and below it,
the part is turned round, down to the profile's own last station — which must reach the
part's own foot (`z = -leg_len`) exactly, so no square material is left floating below a
round run. The topmost station's radius may be anything up to `leg_dim / 2` — equal to it
for a flush transition (the square's corners vanish exactly on that plane) or a little
less for the small turned shoulder or cove real lathe work leaves. A tenon into the
pommel is an ordinary `mortise_and_tenon`, on the pommel's own flat face — the tenon must
go into the square part, not the round, since a mortise cut into a curved face is not
modelled.

**Faceplate turning: `grain=across`.** A bowl, platter or round table
top is turned from a disc cut from a board, the axis perpendicular to the board face, so the
grain runs *across* the axis. Write `grain=across` on the round stock; the cut list then reads
"across axis (faceplate)" instead of "lengthwise". `across` is the only value, and rect stock
still states its grain as the fifth positional argument.

**A drawn profile instead of stations.** A bowl's inside and outside are different
curves over the same heights, which no list of radii can say. Give the `section` one `profile`
child instead of `at` stations: a closed outline in **(radius, z)** — `x` is the distance from
the axis, `y` is `z` (top face 0, down negative) — written with the same `line`, `arc` and
`polyline` segments as a compound `cut_path`, chained end to start. **The closing edge from the
last point back to the first is implied.** Winding does not matter (it is normalised); a loop
that has no area or crosses itself, a point at negative radius, or one wider or taller than the
blank is refused. `interp=` and `samples=` do not apply, and a `section` is stations or a
`profile`, never both.

```kdl
component Bowl role=bowl {
    stock Maple shape=round diameter=200.0 z=70.0 grain=across
    section axis=z {
        profile {
            // Outside from the axis at the foot, out and up to the rim, across the
            // rim, then down the inside to the axis. The axis edge is implied.
            polyline {
                point x=0.0   y="0.0 - 70.0"
                point x=60.0  y="0.0 - 70.0"
                point x=100.0 y="0.0 - 20.0"
                point x=100.0 y=0.0
                point x=92.0  y=0.0
                point x=0.0   y="0.0 - 60.0"
            }
        }
    }
}
```

**A rectangular member that varies down its length.** On *rect* stock, a
`section` of `at` stations each holding one `rect x_min= x_max= y_min= y_max=` (bounds in the
part's own frame, corner-anchored) is a prismatic member whose cross-section changes, straight
between the keyframes — the tapered spoke, the stool leg narrowing to a foot. The stations
must run the whole part, `z = 0` to `z = -<length>` (repeat the last rectangle to hold a run
constant), and no rectangle may leave the blank. It builds the same solid `taper` does
(`examples/wagon_wheel.kdl`'s spoke moved from one to the other), and for a taper on two faces
it is just a corner-anchored section.

**A curved rectangular member.** Add `interp=spline` and each of the four
bounds becomes a smooth centripetal curve **through** its keyframe values, so a plank gets a
waist, a leg a belly. `samples=` (chords per interval, 1 to 1024) or `tolerance=` (largest gap
between a face and the true curve, default `0.01`) set the fineness, one or the other, and
only with `interp=spline`. A curve that doubles back along `z`, one that overshoots the blank (a
`section` only removes material: add a keyframe, or hold a run constant, to stop a bulge past
the stock), and one that pinches a side below 0.001 are refused.
`examples/trestle_table.kdl`'s `TrestleEnd` is the worked case: three keyframes (full width at
the top, narrow at the waist, full width at the foot) give a parabola-like side.

```kdl
section axis=z interp=spline {
    at z=0.0                { rect x_min=0.0 x_max=45.0 y_min=0.0  y_max=420.0 }
    at z="0.0 - waist_z"    { rect x_min=0.0 x_max=45.0 y_min=40.0 y_max="420.0 - 40.0" }
    at z="0.0 - leg_len"    { rect x_min=0.0 x_max=45.0 y_min=0.0  y_max=420.0 }
}
```

```kdl
component Spoke role=spoke {
    stock Oak 44.0 44.0 420.0
    section axis=z {
        at z=0.0           { rect x_min=0.0 x_max=44.0 y_min=0.0 y_max=44.0 }
        at z="0.0 - 40.0"  { rect x_min=0.0 x_max=44.0 y_min=0.0 y_max=44.0 }
        at z="0.0 - 420.0" { rect x_min=7.0 x_max=37.0 y_min=7.0 y_max=37.0 }
    }
}
```

The drawn card dimensions every authored station (`depth below top → ⌀` for a turning, `→
width × thickness` for a tapered member — `depth from {entry face}` instead of "depth below
top" for `axis=x`/`axis=y`, below). `kdl-check` warns when a corner or edge anchor
(`by top_sw`, …) places a round part: use `top_center`, `bottom_center` or `center`.

**`axis=x` / `axis=y` — a curved apron or shaped rail.** A `rect`
section may sweep along any of the three physical axes, not only `z`. The author always
writes physical axis names — never an abstract `u`/`v` — and which two bound names `rect`
takes, and which single key `at` takes, follow the sweep axis:

| `axis=` | `at` key | `rect` bounds | runs |
| --- | --- | --- | --- |
| `z` (default) | `z=` | `x_min= x_max= y_min= y_max=` | `z = 0` to `z = -size.z` (top face to foot) |
| `x` | `x=` | `y_min= y_max= z_min= z_max=` | `x = 0` to `x = size.x` (no sign flip: `x` is already a `[0, size.x]`-convention axis, unlike `z`) |
| `y` | `y=` | `x_min= x_max= z_min= z_max=` | `y = 0` to `y = size.y` |

Everything else about a `rect` section — straight between keyframes by default,
`interp=spline` for a curve through them, the stations must run the whole part, no rectangle
may leave the blank — is unchanged for `axis=x`/`axis=y`; only the key names differ.
`examples/curved_apron_side_table.kdl` is the worked case: an apron whose `rect` section runs
`axis=x` along the apron's own length, full height where it tenons into a leg and waisted
thinner at mid-span (`interp=spline`), the shaped-rail look a flat apron or a two-face `taper`
cannot give:

```kdl
component Apron role=apron {
    params { length 400.0 }
    stock Cherry length apron_th apron_h
    section axis=x interp=spline samples=48 {
        at x=0.0            { rect y_min=0.0 y_max=apron_th z_min="0.0 - apron_h"       z_max=0.0 }
        at x="length / 2.0" { rect y_min=0.0 y_max=apron_th z_min="0.0 - apron_waist_h" z_max=0.0 }
        at x=length         { rect y_min=0.0 y_max=apron_th z_min="0.0 - apron_h"       z_max=0.0 }
    }
}
```

The apron's own length is always its local `axis=x`, whichever side of the assembly it ends up
on — a side apron turned 90 degrees in the assembly (as `examples/dining_table.kdl`'s flat
aprons already are) still sweeps `axis=x` in its own frame, never a second `axis=y` case to
write.

**Not yet built:** a *coped* shoulder on the round part of a turning. A mark on the round part,
with `tilt=`/`heading=` on the tangent plane, does synthesise a tenon (RFC-0022's oriented
marks), but its shoulder is **flat** on that tangent plane and stands off the curved surface
(2.7 mm at 10 mm either side of the tangent on a 20 mm radius) — the cope is a boolean against
a surface of revolution, which a footprint-swept cut cannot express.

### `bend` — a curved spine along a member's own length

A `rect` section's own `axis=`/`interp=spline` curves a member's **cross-section** along its
length (a waisted apron, a tapered spoke); `bend` curves the member's own **length** instead —
a crest rail that bows in plan or elevation, a splat that follows a lumbar curve, the two
cases RFC-0040 names ("Crest rail curves in plan," "Splats curve in elevation"). It replaces
`stock`'s cube with a sweep of the unbent cross-section along a sampled spine — a chain of
straight prisms, balanced-tree unioned, built from the existing spline/arc tessellation
 with no new kernel primitive.

```kdl
component CrestRail role=rail {
    stock Ash crest_h crest_th crest_len

    bend axis=z plane=xz {
        spline {
            point z=0.0                     x=0.0
            point z="0.0 - (crest_len / 2.0)" x="0.0 - crest_arch_rise"
            point z="0.0 - crest_len"         x=0.0
        }
    }
}
```

**The feature node is `bend`, a child of a `component`'s body, same level as `section`.** It
takes:

- `axis=` — the sweep direction. Only `"z"` today; `axis=x`/`axis=y` (the `section` feature's
  own freedom) are out of scope — a bent member's own length is always its local `z`, and it
  is rotated into place at the assembly level like any other component whose own length does
  not run along world `z` (`examples/arched_crest_side_chair.kdl`'s own `rot y=90.0`).
- `plane=` — `"xz"` or `"yz"`: which of the cross-section's two in-plane axes bows. `xz` bows
  local `x` and holds local `y` fixed (the spine's own normal); `yz` is the same with `x` and
  `y` swapped. There is no third plane: the spine is always planar, not a true 3D curve (out
  of scope, per the spec).
- `tolerance=` or `samples=`, mutually exclusive — the same two knobs a `rect` section's own
  `interp=spline` takes, with the same meanings (largest face-to-curve gap, or chords per
  interval).
- Exactly one child: `arc radius=<expr>` or `spline { point z=… <x|y>=… ... }` — never both,
  never neither.

**`arc radius=`** is a closed-form circular arc, tangent to the spine's own direction at
`z=0` only — the right body for a member that meets one end square and curves away toward the
other (`crates/ww-build/tests/bend_joinery.rs`'s own worked rail). **`spline { point … }`**
takes two or more control points — the spine's own `(z, offset)` keyframes, resampled through
`ww_geom::spline::sample` exactly as a `rect` section's own curved bounds are — and is the
right body for a symmetric curve, meeting both ends the same way (three points, the two ends
at `offset=0` and the middle bowed, gives the dome/yoke shape `arched_crest_side_chair.kdl`
uses). **A spline's control points must run the whole part**, from its own top face (`z=0`)
to its foot (`z = 0 - size.z`), the same top-down convention every other local `z` coordinate
in this language uses (D4) — `kdl-check` and the compiler both refuse a spline that stops
short or overshoots.

**The too-tight-radius refusal.** A radius (or a spline's own local curvature) tighter than
the cross-section's own half-depth would self-intersect the sweep before any kernel call —
refused analytically, at parse time, rather than discovered as a non-manifold result.

**Only the two end faces are available — the swept lateral surface is not.** A `bend`ed
component exposes exactly the same two end `FaceFrame`s (and their own standard anchors —
`top_sw`, …, `top_center`, `bottom_center`, `center`) a straight member's own two end faces
would, built from the spine's own two end tangents rather than a flat box corner. A `mark`
with an end-facing `normal=` (`pos_z`/`neg_z`) resolves normally, read off the real, curved
end frame, not the unbent box's corner; a `mark` with a lateral `normal=` (`pos_x`/`neg_x`/
`pos_y`/`neg_y`) is refused by name — a bend's own swept sides have no flat face left to cut
into or reference. `join`ing against one of the standard anchors works for a `butt` joint
(which needs only a point), but `mortise_and_tenon` needs a `mark` with `normal=` (D6: a
standard anchor has no facing) — write one at the end face's own centre
(`(crest_h / 2.0, crest_th / 2.0, 0.0)`, `normal=pos_z`/`neg_z`), which is exact whenever the
curve's own control points hold `offset=0` at both ends (as above), and is bend-aware
regardless: the `normal=` only signals which end this is, and the real, possibly-tilted
tangent frame is what the tenon actually extrudes along.

**Joinery against a bend's end needs no synthesizer change.** A mortise-and-tenon (or any
other joinery kind) into a `bend`'s end face is authored exactly as it would be against a
straight member's end — `examples/arched_crest_side_chair.kdl`'s crest-rail-to-post joints are
the worked case.

**The cut-list card and grain.** A bent member's grain reports "follows spine," never an axis
word — the only physically correct rule for steam-bent or curve-sawn stock — and
its card dimensions the spine itself (the `arc`'s radius, or the `spline`'s own span and
offset keyframes), never the swept solid's bounding box; the stock line stays the straight,
unbent blank the bend is cut from.

**Not yet built:** a true 3D spine (`plane=` stays planar), a varying cross-section along a
bend (no `section`-style keyframing of the swept footprint), `axis=x`/`axis=y`, a mid-curve
feature or mark, a curved CNC toolpath (the fabrication pack still treats a bend as a bench
operation), and turning a bend (round stock and a curved spine together).

### `mark` — named joinery interfaces

**Decision D6.** A datum is a named point + facing direction that joints attach to. Declaring
them is what lets a parametric change drag the joint along instead of desynchronising it.

```kdl
mark <Name> x=<expr> y=<expr> z=<expr> normal=<face> [tilt=<expr> heading=<expr>]
```

`normal` is one of `pos_x` `neg_x` `pos_y` `neg_y` `pos_z` `neg_z` and points **out of the
material**, in the direction the mating part approaches from.

Express datum coordinates in terms of the component's own params (`x=leg_dim`,
`y="apron_th / 2.0"`), never as bare constants — that is the entire point of them.

**`tilt=`/`heading=` — an oriented reference.** A mark whose true facing is
not one of the six axis-aligned box faces — a point on a turned surface's own tangent, a
mitred end, a compound-angle approach — leans `normal=`'s named direction `tilt` degrees off
it, toward `heading` degrees counter-clockwise from that face's own `+u` (`FaceFrame`'s own
convention, ADR-0005: degrees, not a sign — the direction is `heading`'s job). Left out, both
default to `0.0` and the mark is exactly the named face, byte for byte — every `mark` written
before M25 is unchanged. A mark whose point sits off its nominal box face but still faces
squarely along a real axis (a recessed or turned surface parallel to the box face, just set
back from it) needs no `tilt=`/`heading=` at all: the validator centres its own frame on the
mark's point rather than the box's corner, so only marks with a genuinely different facing
need the two extra properties. `examples/wagon_wheel.kdl`'s `Hub` (`normal=pos_x tilt=<the
spoke's own azimuth> heading=0.0`, one mark per spoke — `tilt` here is the azimuth itself, not
a lean off vertical) and `Felloe` (`Dowel_Start`/`Dowel_End`, leaning off `pos_z`/`neg_z` by
the mitre's own half-angle) are the two worked examples; `SpokeSocket` on the same component
is the set-back-face case, needing neither property.

The viewer's joint dialog writes marks this way too: on the stock face picked,
the middle as `"<dim> / 2.0"`, anything else as a distance from the nearer end, named
`<Noun>_<Face>` (`Dowel_PosY`, `Tenon_Top`). Rename or re-express them as you would your own.

## 7. Features (single-body material removal)

These live inside a `component` or an inline `part`. All of them lower to the `RuledCut`
primitive (D7) — two exceptions: `section` (M23/ADR-0018, §6 above) defines the part's own
base solid, a turned profile's `revolve`, the role `stock`'s cube plays for every other
part, rather than cutting material away from an already-built stock; `veneer` (M29,
RFC-0123) cuts nothing off the part that declares it at all — it generates a thin, bonded
part instead, the same "generated part" mechanism a joint's own `pin`/`wedge` modifier uses
 — see *Veneer and laminate* below. `box_joint`, `dovetail`, `mortise` and
`tenon` are **not** features, however they look: they are interlocking-joint kinds and belong
to §8's `joinery` + `join` vocabulary only — the linter refuses them here.

| Node | Required | Notes |
| --- | --- | --- |
| `dado` | `width` `depth` `offset` | Across the grain. See *Placing a dado or groove* below |
| `groove` | `width` `depth` `offset` | With the grain. See *Placing a dado or groove* below |
| `rabbet` | `width` `depth` | Add `{ edges … }`, or `face=` + `edge=` for any edge. Stoppable — see *Bevel, and a rabbet on any edge* below |
| `chamfer` | `size` | Add `{ edges … }`. Stoppable — see below |
| `roundover` | `size` | Add `{ edges … }`. Stoppable — see below |
| `moulding` | `section` `size` | Add `{ edges … }`. Stoppable — see *Moulding profiles* below |
| `bevel` | `angle` | Add `{ edges … }`, or `face=` + `edge=`; `depth=` optional. Stoppable — see *Bevel, and a rabbet on any edge* below |
| `taper` | `start_z` `end_z` `target_dim` | Add `{ faces … }` |
| `section` | `axis` | The part's own base solid, not a cut: `circle` stations (a turning), a drawn `profile` (faceplate work) or `rect` stations (a tapered member). See *Turned stock* in §6 |
| `cut_path` | `depth` | A closed `line`/`arc`/`polyline`/`spline`/`circle` outline, or several chained into a compound path; `tilt=` `heading=` `kerf=` `keep=` (`outside`, default, or `inside`), `depth=0.0` is through. See *`cut_path`* below |
| `hole` | `diameter` `depth` | `depth=0.0` is through. `length=`/`axis=` elongate it — see below |
| `lamination` | `count` `thickness` | Glued-up stock, for benchtops and thick legs |
| `trim` | `reference` `normal` `at` | Add `{ faces … }`. The one feature resolved in **world** or assembly space, after placement — see below |
| `veneer` | `material` | Bonds a thin leaf (or several) to a named face — no cut on the host. See *Veneer and laminate* below |
| `banding` | `material` | A strip let into a groove round a face's edges, flush, mitred at the corners; generates the filler part. `lay=surface`, `pattern=herringbone`, `angle=90` (cross-banding). See *Banding and stringing* below |
| `stringing` | `material` `offset` | A fine inlaid line: `banding` with a 1.5mm width and 2mm depth by default, and the offset required. See *Banding and stringing* below |

Enum lists are **child nodes taking one or more arguments** — KDL v2 has no array literals:

```kdl
chamfer size=3.0 {
    edges top_perimeter
}
taper start_z=-90.0 end_z=-700.0 target_dim=28.0 {
    faces pos_x pos_y
}
```

Edge selectors: `top_perimeter` `bottom_perimeter` `front_perimeter` `all`, or named edges
`top_n` `top_s` `top_e` `top_w` and their `bottom_` equivalents, the four vertical arrises
`upright_ne` `upright_nw` `upright_se` `upright_sw`, and the four edges of the front face
`front_top` `front_bottom` `front_e` `front_w` (previously reachable only through `all` or
`front_perimeter`).

**Edge profiles follow the blank, not a sawn outline — except `top_perimeter`/
`bottom_perimeter` on `chamfer`/`roundover`.** `rabbet`,
`bevel`, `moulding`, and a perimeter selector naming individual compass edges, still ease
the stock's own box edges only: the edges a `cut_path` makes (a curved or profiled outline)
are not eased by them, a perimeter selector (`front_perimeter`, `all`, ...) on such a part
shapes its straight blank edges and the build reports "shaped only along the blank's own
edges" as a skip (`ww check`). Naming an edge (`top_n`) is taken as asking for that straight
edge: it is shaped wherever the saw left it, and not noted. If the saw has taken the named
edge away altogether (the outline never touches it, or a through cut removes it), the
profile would be cut into air, so it is skipped as "sawn away" (and `chamfer`, `roundover`,
`moulding`, `bevel` and `rabbet` all say so). A profile along a curved run is RFC-0126's
addendum.

`chamfer`'s and `roundover`'s own `top_perimeter`/`bottom_perimeter`, though, sweep the
*real* outline a through, `keep="inside"` `cut_path` leaves on that face — arcs and
spline-flattened runs included — rather than the box's four compass edges, whenever such a
`cut_path` exists on it: a triangular or round plan (`three_legged_stool.kdl`'s own Seat) is
eased all the way round, not left sharp-edged. `stopped=` has no meaning there (no single
straight edge's length to measure a run against) and is refused.

### Stopped chamfers and roundovers

`chamfer` and `roundover` take ADR-0011's `stopped=` `start=` `run_length=` along the
selected edge's own run, measured the same way as a dado or groove's: from the low end of
the run axis, `start` defaulting to 10mm in and `run_length` defaulting to "out through the
far end" when omitted. Add `stop=square` (the default) or `stop=ramp` for how the profile
ends where it does not reach a real stock face:

```kdl
// Stops 60mm above the bottom rail's shoulder and 60mm below the top rail's, easing out
// instead of a squared step; the back pair of arrises stays untouched.
chamfer size=4.0 stopped=#true start=60.0 run_length=600.0 stop=ramp {
    edges upright_se upright_sw
}
```

A chamfer or roundover on a vertical arris is refused, with the reason, if the same run
overlaps a `taper` on either of the arris's two faces: the edge is no longer parallel to a
stock axis there.

`chamfer` (only) also takes `stop=lambs_tongue` beside `square` and `ramp`: the same
triangular run-out `ramp` uses, but the taper follows a concave curve instead of a
straight line — tangent to the full chamfer at the body end (no kink) and coming to a
sharp, pointed terminus at the stop, rather than easing in at a constant slope:

```kdl
chamfer size=4.0 stopped=#true start=60.0 run_length=600.0 stop=lambs_tongue {
    edges upright_se upright_sw
}
```

A stopped `roundover` has no single tangent line for a lamb's-tongue to blend into (it is
already several untilted layers, not one tilted cut) and `stop=lambs_tongue` there is
refused, named, rather than silently falling back to something else.

### Bevel, and a rabbet on any edge

`bevel` is a flat slope across an edge at `angle=` degrees from a face; a `chamfer` is the
45° case with equal legs. Name the edge either with `{ edges … }` (the top and bottom faces'
edges) or with `face=` and `edge=` — `face` is the face the angle is measured on and cut
into, `edge` an adjacent face (neither `face` nor its opposite). `face=`/`edge=` is the only
way to reach a vertical arris: `upright_*` in `{ edges }` is refused for `bevel` and
`rabbet`, because there neither face is the one the angle or width is measured on.

```kdl
bevel angle=35.0 { edges top_s }                  // across the whole thickness
bevel face=pos_z edge=neg_y angle=30.0 depth=8.0  // shallower: an 8mm-deep, 13.9mm-wide facet
```

`depth=` defaults to the whole thickness across `face`; the facet is `depth / tan(angle)`
wide on `face`. `angle` is in `[1, 90)`; outside is an error, and a facet wider than the
face is a skip that says so. `stopped=` `start=` `run_length=` `stop=square|ramp|lambs_tongue`
work as on `chamfer` above.

`rabbet` takes the same `face=` + `edge=`: `width` is measured on `face` from `edge`, `depth`
goes into `face`. It also takes `stopped=` `start=` `run_length=` (ADR-0011's), always
square-ended — `stop=ramp` and `stop=lambs_tongue` are refused. A stopped rabbet without
`start=` begins 10mm in; write `start=0.0` to reach the end.

**Stopping at a height** (a column, a post, a pilaster): there is no "stop at extent"
keyword — `start` and `run_length` are measured on the edge's own run axis from its **low
end**, so `start=0.0 run_length=<height>` stops the rabbet `<height>` up from that end, and
`start=<length - height>` with no `run_length` runs the last `<height>` out through the far
end. The same holds for every stopped cut in this section.

```kdl
// The back rabbet on a standing side: the vertical edge where pos_x meets pos_y.
rabbet face=pos_x edge=pos_y width=18.0 depth=6.0
// A corner notch reaching the end of the edge, 40mm long.
rabbet face=pos_z edge=pos_y width=8.0 depth=6.0 stopped=#true start=0.0 run_length=40.0
```

### Moulding profiles

`moulding` cuts a named classical profile into an edge — `section=` one of `ovolo` (a
convex quarter-round, the plainest classical section), `cove` (its concave mirror — a
scoop at the corner), `ogee` (a genuine S-curve: concave then convex, meeting smoothly at
the midpoint), or `thumbnail`/`half_round`/`cockbead` (the same convex generator as
`ovolo`, differing only in name and the proportion a style calls for). `size=` is the
profile's overall projection, measured exactly as `chamfer`/`roundover`'s own `size=` is
(both `p` and `q` from the arris); `edges` is the same selector vocabulary as every other
profile in this section.

```kdl
// A plinth or cornice board's own top edge.
moulding section=ogee size=4.0 {
    edges top_perimeter
}
```

Every section is built as a **stepped** polygon — 16 straight chords approximate each 90°
quarter-circle, rather than one exact arc — chosen finely enough that the deviation from
the true analytic curve is well under a tenth of a millimetre for any moulding size this
library or its styles calls for (`crates/ww-build/src/features.rs`'s own
`MOULDING_PROFILE_STEPS` works the bound out in full, and an exit test checks it against
the built solid rather than merely asserting it). This keeps the feature entirely on D7's
existing `RuledCut::region` primitive, with no new kernel geometry and a bounded mesh
cost per edge.

**A moulding may be stopped**: `stopped=` `start=` `run_length=` and
`stop=square|ramp` work as on `roundover` (`lambs_tongue` is refused). A stopped run is cut
as a 32-layer staircase along the section's own curve: it never removes more than the true
section and falls under 7% short of its volume.

**`half_round` and `cockbead` are documented as *applied* mouldings**, never a cut on the
part they decorate: cutting a board's own edge can never leave material standing proud of
the surrounding surface, so an applied bead — a cockbead round a drawer opening, a
half-round strip on a meeting stile — is authored as its **own `component`**, a narrow
strip whose stock *is* the bead's own section, with the profile cut on the edge(s) that
face out, mounted with an ordinary `join` (a cockbead typically sits in a rabbet cut in
the surface it protects — `rabbet face= edge=` reaches the edges that needs). It reaches the
cut list the same way every other authored part does: as its own row, keyed on its own
mesh, material and stock line, never merged with the part it is glued to.

**How big the strip must be.** An edge profile (`chamfer`, `roundover`, `moulding`, `rabbet`)
is cut into the corner of two faces and must stop *strictly* short of the opposite face of
each; a cut that would take the whole of either dimension is a skip whose message names the
short one ("would remove the whole section"). So a half-round strip's `size` is **not** its
thickness: the stock thickness must be **greater than** `size`, and its width greater than
`size` too. With both long edges moulded, a width of `2 × size` is the true semicircle (the
two quarter-rounds meet at mid-width); a wider strip keeps a flat between them. A strip 6mm
thick takes `size` up to just under 6, never 6.

A `section=` word outside these six is refused with the reason: every other named
moulding a plausible request might reach for would need a boolean or a re-entrant tool
approach a straight-sided `RuledCut` cannot make (PLAN §1's undercutting exclusion,
docs/rfc/0126).

### Placing a dado or groove

| Property | Meaning |
| --- | --- |
| `face=` | The face it is cut into: `pos_x` `neg_x` `pos_y` `neg_y` `pos_z` `neg_z`. Default: the broad face (`+` side of the thinnest dimension). **Required** on a square section. |
| `offset=` | Where across its width: distance of the cut's **low edge** from the low end of that axis — `0` for x and y, the **bottom face** for z. Always positive, always "up from the bottom" / "in from the front or left". |
| `stopped=#true` | Stop the cut instead of running it through both ends. |
| `start=` | Stopped only: where it begins along its run, measured the same way. Default 10mm in. |
| `run_length=` | Stopped only, with `start=`: how far it runs. Default: out through the far end. |

Which way it runs is not a property. It comes from the grain: a groove runs with the grain and
a dado across it, on whichever face you named. A face the grain runs into is end grain, and a
cut there is skipped.

`dado`/`groove` also accept `id=` and `label=` (both strings). They parse, but nothing in the
compiler reads them back yet — no diagnostic, no cut list, no drawing resolves against them.
They are bookkeeping for the author only, not a cross-reference mechanism.

```kdl
// Shelf housing across a side lying flat (x is the side's height): 400 up from the bottom.
dado width=19.0 depth=8.0 offset=400.0

// Panel groove in the inner EDGE of a 20mm-thick frame rail, centred for a 6mm panel.
groove face=pos_y width=6.0 depth=10.0 offset="(20.0 - 6.0) / 2.0"

// Stopped: 50 in from the left end, 900 long.
groove width=10.0 depth=6.0 offset=40.0 stopped=#true start=50.0 run_length=900.0
```

`offset_bottom` and `offset_x` no longer exist; the build reports them by name.

### Elongated holes

`hole` takes two optional properties for a slot rather than a circle: `length=` (mm, the
straight run between the two semicircular caps) and `axis=` (`x` or `y` only — the part-local
axis the slot runs along, which must lie in the plane of the face `normal=` names). Omit
`length=`, or write `0.0`, for the true round hole every existing `hole` already is; `axis=`
does nothing when `length` is `0.0`, so a `repeat`'s index-conditional ternary can give both
on every iteration and only the outer ones actually elongate:

```kdl
// A row of four holes where only the outer two are slots: an index-conditional ternary.
repeat i count=4 {
    hole id="Peg_{i}" diameter=10.0 depth=0.0 x="50.0 + (i * 100.0)" y=40.0 z=0.0 \
        length="(i == 0.0 || i == 3.0) ? 16.0 : 0.0" axis=x
}
```

`hole` also accepts `id=` and `label=` (both strings). They parse, but nothing in the
compiler reads them back yet — no diagnostic, no cut list, no drawing resolves against
them. They are bookkeeping for the author only, not a cross-reference mechanism (issue
#551, the same gap `dado`/`groove` had — issue #549).

### `cut_path`: `line`, `arc`, `polyline`, `spline`, `circle`

`cut_path` cuts a closed outline into `face=` (same default as `dado`/`groove`: the broad
face), to `depth=` (`0.0` through), optionally leaning (`tilt=` `heading=`, D7) and carrying
`kerf=` (inert on a closed path; kept for M11's toolpath compensation). **`keep="outside"`
(default): the closed region is removed**, exactly like every other feature in this
section — a through `cut_path` is a *pierced* cut-out, wood taken from the middle of an
otherwise whole board, not the whole board trimmed down to the loop's shape (docs/adr/0016
§1). **`keep="inside"`: the opposite** — everything *outside* the region is removed, so the
path becomes the part's own new footprint through its full thickness (ADR-0016 §1's own
named follow-up for RFC-0021's `plan_outline` reading). A round tabletop cut to its outline,
or a wagon-wheel felloe's curved outer edge, are `keep="inside"`; a mortise or a bootjack
notch stay the default.

The outline is one segment, or — a compound path — several `line`/
`arc`/`polyline`/`spline` children chained in document order, each one's end meeting the next
one's start. A `path=<Name>` reference names one segment (Level 2) or a whole compound path
(`path <Name> { … }`, ADR-0032). Inline, or named once and referenced:

```kdl
// Inline, a semicircular cut-out.
cut_path depth=0.0 {
    arc center_x=100.0 center_y=75.0 radius=50.0 start_angle=0.0 end_angle=180.0
}

// A rectangular pierced window, as a polyline (the closing edge, last point back to
// first, is implied — do not repeat the first point).
cut_path depth=0.0 {
    polyline {
        point x=60.0  y=50.0
        point x=140.0 y=50.0
        point x=140.0 y=100.0
        point x=60.0  y=100.0
    }
}

// Named once, referenced by `path=` — the same by-name idiom `join` uses for `joint=`.
path Window arc center_x=100.0 center_y=75.0 radius=50.0 start_angle=0.0 end_angle=180.0
cut_path depth=0.0 path=Window

// `keep="inside"`: the arc becomes the part's own footprint — cut from an over-length
// rectangular blank, a curved felloe rather than a pierced window.
cut_path depth=0.0 keep="inside" {
    arc center_x=100.0 center_y=75.0 radius=50.0 start_angle=0.0 end_angle=180.0
}

// A compound path: `arc`, `line`, `arc`, chained end to start — an annular wedge, curved on
// both its inner and outer edge, flat on its two mitred ends (a wagon-wheel felloe's actual
// shape, `examples/wagon_wheel.kdl`'s `Felloe`). Only one mitre is written: the last
// segment's end does not need to meet the first segment's start explicitly — that closing
// edge is implied, exactly as a lone arc's own chord already is (do not write a fourth,
// redundant closing segment back to the start; it duplicates that corner and is very likely
// rejected as degenerate).
cut_path depth=0.0 keep="inside" {
    arc center_x=100.0 center_y=75.0 radius=60.0 start_angle=0.0  end_angle=90.0
    line from_x=100.0 from_y=135.0 to_x=100.0 to_y=115.0
    arc center_x=100.0 center_y=75.0 radius=40.0 start_angle=90.0 end_angle=0.0
}
```

| Segment | Properties |
| --- | --- |
| `line` | `from_x=` `from_y=` `to_x=` `to_y=` — a straight run. Never closes by itself (two points bound no area); accepted for completeness, per the proposal, and reported as a skip if used alone. |
| `arc` | `center_x=` `center_y=` `radius=` `start_angle=` `end_angle=` (degrees, the same convention as `sin(deg)`/`cos(deg)`, ADR-0005) `segments=` (optional, not yet read). A 360° sweep closes as a circle; anything less closes along the chord back to its start, so the region is the circular segment the arc and that chord bound. |
| `polyline` | `{ point x= y= ; … }`, at least two points. The edge from the last point back to the first is implied. |
| `spline` | `{ point x= y= ; … }`, a smooth centripetal Catmull-Rom curve **through** the points (at least two; three if `closed=#true`). `closed=#true` is a whole loop (a blob, a vase profile): it cannot chain with other segments. Otherwise it closes along its chord like a lone `arc`, or chains end to start in a compound path. `tolerance=` (default `0.01`) is the largest gap in length units between the cut edge and the true curve; `samples=` instead fixes the chords per span (whole number, at most 1024). The curve is sampled to chords once, deterministically. Consecutive points must differ. |
| `circle` | `center_x=` `center_y=` `radius=`: a whole round, Level 3's derived path. It is a whole loop and cannot chain; an exact two-arc outline, no sampling. |

`path <Name> <kind> <properties…>` declares a segment once, by name, for `cut_path`'s
`path=` to reference — the same kinds, written the same way, just with a leading name.
`path <Name> { <segment> … }` declares a compound path, the same chained children an inline
`cut_path` takes; `cut_path path=<Name>` then cuts it. A named path is built where it is
referenced, so it reads the same parameters as the `cut_path`.

A curved cloud-lift or serpentine edge, one named compound path:

```kdl
path Cloud {
    line from_x=30.0 from_y=-10.0 to_x=30.0 to_y=0.0
    spline { point x=30.0 y=0.0; point x=75.0 y=34.0; point x=115.0 y=34.0; point x=160.0 y=0.0 }
    line from_x=160.0 from_y=0.0 to_x=160.0 to_y=-10.0
}
cut_path depth=0.0 face=neg_y path=Cloud
```

`cut_path` also accepts `id=` and `label=` (both strings). They parse, but nothing in the
compiler reads them back yet — no diagnostic, no cut list, no drawing resolves against
them. They are bookkeeping for the author only, not a cross-reference mechanism (issue
#551, the same gap `dado`/`groove` had — issue #549).

**What is deliberately not here:** a true curve in the kernel (ADR-0016 §2: it has none, so a
`spline` and an `arc` are sampled to a polygon at the `cut_path`, the one place a curve
becomes chords); G02/G03 toolpath arcs; a `hole` or `mortise` placed along a path; and a
curved or bent *part* (RFC-0040 `bend`, a later wave that can take the sampled spline as its
spine, ADR-0032).

### Trimming a raked part to a world-space plane

Every other feature in this section is written, and cut, in the part's own local frame
(D4) — before the part is even placed. `trim` is the one exception: it names a plane
fixed in **world** space and is resolved only after `pos`/`rot`/`by` have placed the part,
which is what lets it level a raked leg's foot to the floor regardless of how the leg is
rotated into position.

```kdl
// A raked leg's foot, levelled to the floor at world z = 0. The cut is written from a
// side face, not the end: see "Picking `faces`" below.
trim reference=world normal=pos_z at=0.0 {
    faces pos_x
}
```

| Property | Meaning |
| --- | --- |
| `reference=` | `world` or `assembly` (required). `world` is the document's own origin. `assembly` is the frame of the part's nearest enclosing `instance=`-placed assembly — identity (same as `world`) when the part sits directly in the root or under only inline `assembly { }` groupings. A named datum plane on an assembly remains unbuilt (ADR-0024, no example needs more than one reference plane per assembly). |
| `normal=` | A direction in `reference=`'s own frame, in D6's six words (`pos_x` … `neg_z`) — **not** a face of this part. It names which side of the plane is **kept**: the same sense a real surface's own normal points (a floor's points up, away from what standing past it would be). |
| `at=` | Where the plane sits along that direction's own axis, in `reference=`'s own frame: `reference=world normal=pos_z at=0.0` is "world z = 0"; `reference=assembly normal=pos_z at=0.0` is "the nearest enclosing assembly's own z = 0". A CEL expression, evaluated in the part's own scope like any other feature property — for a raked leg this is almost always a derived parameter (`at="0.0 - leg_drop"`), not a literal, because the floor is rarely at the reference frame's `z = 0`. |
| `faces` (child) | Which of the part's **own** stock faces (D6 face names, part-local) the cut is written from. Pick one whose normal stays close to perpendicular to the plane — see below; a face too oblique (`joinery::plane::MAX_LEAN`, 60°) is refused, not silently skipped. |
| `within_normal=` / `within_at=` | Optional, and only together: a second plane, read in the same `reference=` frame as `normal=`/`at=`, that bounds the cut to the wedge where both planes' removed sides meet — a birdsmouth's seat cut, bounded by its heel plane, rather than the whole unbounded half-space of the seat plane alone. Lowers to `joinery::plane::beyond_within` instead of `beyond`; refused, not silently skipped, if the cut's own lean runs across this plane rather than along it (the same "too oblique" contract `faces` keeps). |

A birdsmouth seat, bounded so it stops at the heel rather than running the length of the
board:

```kdl
// The seat cut (normal=pos_x, into the rafter's depth) only applies on the near side of
// the heel plane (within_normal=/within_at=) -- the tail beyond it keeps full depth.
trim reference=world normal=pos_x at=seat_depth
     within_normal=neg_z within_at=heel_z {
    faces pos_y
}
```

**Giving one of `within_normal=`/`within_at=` without the other is a `BuildError`**, not a
default — there is no sane default position for a bound whose direction was given but not
where it sits.

`trim` still lowers to one ordinary `RuledCut` per named face — the same leaning cut
`joinery::plane` already gives a mitre or a scribed shoulder — so the novelty is
entirely in *when* the plane can be resolved, not in the cut itself.

**Picking `faces`: this is not the face the trim visually removes.** For a plane close to
perpendicular to the part's own length (a leg's floor, a post's cap), the face to name is
one of the *sides*, not the end (`neg_z`) the trim conceptually shortens — a face parallel
to the plane needs to lean nearly 90° to cut along it, which `plane::beyond` refuses past
60°. `examples/three_legged_stool.kdl`'s `Leg` needs `faces pos_x`, not `faces neg_z`
(which the RFC's own first sketch of this feature assumed, and which is refused outright,
verified rather than assumed — `crates/ww-build/tests/trim.rs`): the leg's rake is always
about its own local X axis, so `pos_x`'s normal never tilts out of the plane the floor's
normal is perpendicular to, whatever azimuth the leg is then turned to. Read the comment
on that file's `trim` node before copying this pattern to another raked part with a
different rotation order.

**A feature that never reaches the plane is a skip, not a silent no-op**, the same
contract every other feature in this section keeps: a `trim` whose named face never
crosses the plane says so in the build report, rather than quietly leaving the part
untrimmed.

**Interaction with the wasteful-stock check.** Padding a part's stock purely so
`trim` has "something to cut" shows up as real waste (`ww::lower::wasteful_stock`) if it
leaves the part's bounding box short of the declared stock on that axis — which it will,
on the axis `trim` shortens, unless the declared length is chosen so *some* point of the
part still reaches the declared end. `three_legged_stool.kdl`'s `leg_len` comment works
this out for a square, uniformly-raked foot; the general rule is to size the stock so the
**shallowest** corner (the one `trim` removes the least of) lands exactly on the plane,
not the deepest one.

### Veneer and laminate

`veneer` bonds one or more thin leaves to a named face of the part that declares it — the
host's own dimensions are untouched (`veneer` cuts nothing; it is not in the table above by
accident). A `laminate` — a sheet good glued on for a surface, not a solid species — is not
a second node: give `material=` a `type=sheet` material and it is the same feature.

```kdl
// Biedermeier book match (docs/styles/biedermeier.md): two leaves, opened like a book so
// the figure mirrors about the centre seam. Hand-sawn thickness, the style's own "3-5mm".
veneer face=pos_z material=Walnut thickness=3.0 grain_angle=0.0 match=book leaves=2
```

| Property | Meaning | Default |
| --- | --- | --- |
| `face=` | D6 face name the leaves bond to. | `pos_z` |
| `material=` | The veneer's own species (or a `type=sheet` material, for a laminate) — a bare word, like `face=`, never a CEL expression. | required |
| `thickness=` | Finished leaf thickness, mm. Real veneer runs roughly 0.6mm (paper-backed) to 3mm (hand-sawn) — pick a realistic one. | 0.6 |
| `grain_angle=` | Degrees, the figure's angle off the host's own length axis. Recorded on the cut list; a book-matched leaf's mirrored half reads at the opposite angle automatically. | 0.0 |
| `leaves=` | How many leaves divide the face, side by side, edge to edge (no gap — real leaves are taped and glued seam to seam). | 1, or 2 once `match=` is given |
| `match=` | `book` (alternate leaves mirror about each seam — ADR-0019's `mirror`, exact, not merely "similar"), `slip` (leaves repeat, unmirrored) or `sunburst` (RFC-0141: `leaves` wedges radiating from a point — below). | none (a single leaf), or `slip` once `leaves` is more than 1 |

`match=sunburst` lays the leaves as a fan instead of side by side: `leaves=` wedges (default
8) radiate from `center_u=`/`center_v=` (face coordinates, default the face's centre) between
`start_angle=` and `end_angle=` (degrees from the face's `+u`, default a full 0 to 360), each
clipped to the face, so the leaves tile it with no gap. Each leaf records its mid-ray as its
`grain_angle`. A leaf is a convex wedge, so each must span under 180 degrees: two leaves
round a full circle is a named skip. Geometry only — paterae, bellflowers and marquetry are
out of scope.

A leaf follows the part's **finished outline**, not its blank rectangle: a `cut_path
keep="inside"` outline or a pierced `cut_path` cut-out on a face parallel to the veneered one,
and a `trim` or scribed tenon shoulder square to it, cut the leaves exactly as they cut the host
(the seams of a book or slip match are still straight, so a pentagon panel's leaves are the two
halves of the pentagon). Not followed, and said so rather than guessed at: a leaning (`tilt=`)
`cut_path` on a parallel face, an outline written on a face square to the veneered one, and
blind cuts; there the leaf keeps the blank rectangle.
A sunburst leaf's blank is cut to the finished leaf's own bounding box, so the stock list
and `wasteful_stock` see the leaf that is made, not the wedge before the outline clipped it.

Each leaf becomes its own generated part (RFC-0090 §5's machinery, `ww_expand::{
PlacedPart::generated, Provenance, GeneratedKind::Veneer }`), never written back to the
source (D10) and never named by a `join` — it is bonded, not joined, so it is expected to
*touch* the host and never reported as a clash or a gap. The cut list shows each leaf's own
species, thickness, grain angle and match style as its own row (book match's mirrored half
is a distinct row: a different world handedness, the same way a mirrored part already gets
its own row, ADR-0019), and shows the host's own row **twice over**: its declared
(substrate) `L × W × T` — "the host part keeps its own dimensions" — is unchanged, and a
separate finished-thickness figure (substrate plus every leaf bonded to it) is printed
alongside it. The viewer needs no special case: a generated part already draws as an
ordinary mesh, and since each leaf is its own separate solid, the edge it shares with its
neighbour draws like any other mesh edge — that shared edge is the seam.

### Banding and stringing

`banding` and `stringing` let a strip of contrasting wood flush into a groove a fixed
`offset=` in from the edges of a face, mitred at the corners. One node does two things from
one set of numbers: it **cuts the groove** in the host and **generates the filler part** that
fills it exactly, so the two cannot disagree. `stringing` is `banding` with a thin default
section (1.5mm wide, 2mm deep) and a required `offset=`.

```kdl
// A 12mm walnut band, 3mm deep, 8mm in from every edge of the top, with a fine holly line
// inside it — and a second line, by `repeat`, at a different offset.
banding face=pos_z material=Walnut width=12.0 depth=3.0 offset=8.0
stringing face=pos_z material=Holly offset=24.0
```

| Property | Meaning | Default |
| --- | --- | --- |
| `face=` | D6 face name the groove is cut in. | `pos_z` |
| `material=` | The filler's species. A bare word, never an expression. | required |
| `width=` | The strip's width across, mm. | 12.0 banding, 1.5 stringing |
| `depth=` | Groove depth = filler thickness, mm. Must leave the part some thickness under it. | 3.0 banding, 2.0 stringing |
| `offset=` | Distance from the face's edge to the strip's near side, mm. `0` opens the groove through the side (a flush edge band). | required for stringing, 0 banding |
| `lay=` | `inlay` (let into a groove) or `surface` (bonded proud, no groove; `thickness=` instead of `depth=`, default 0.6). | `inlay` |
| `pattern=` | `plain` or `herringbone` (each strip split lengthwise into two halves at `+angle` and `-angle`). | `plain` |
| `angle=` | Degrees, the strip's grain off its length. `90` is cross-banding. | 0, or 45 for herringbone |
| `edges` child | `{ edges pos_x pos_y }`, neighbouring face names: banding on those edges only. Strips mitre only where two selected edges meet; the rest end square. | all four edges |

The groove and its filler share one section; the filler sits flush with the face with no
gap, so it touches the host and is never reported as a clash. The groove cutters overlap at
the corners and the filler is the mitred trapezoid; the volumes agree. A strip is an ordinary
generated part (`GeneratedKind::{Banding, Stringing}`) and shows in the cut list as strip
stock: species, grain angle in its name, `L` the outer edge's length (what the mitre is cut
from). `repeat` works (`repeat i count=3 { stringing offset="10.0 + i * 6.0" … }`). A bad
property, an impossible offset or a `depth=` that reaches through is a named skip, never
silent. The figure of a herringbone is a grain angle, not geometry.

## 7b. Naming and reading order

**Verb-like nodes are imperative.** `join`, `dado`, `chamfer`, `taper`, `repeat` — never
`joined`, `chamfered`, `repeated`. These are the words a setup sheet already uses, and because
most feature names are nouns doing duty as verbs (`dado`, `rabbet`, `mortise`, `groove`), the
imperative form is identical to the noun. One word for the thing and the instruction.

**Imperative mood does not mean imperative semantics.** This matters and is easy to misread:

- **Features on a part are order-independent.** The compiler unions every cutter and performs a
  single difference (D12), so `chamfer` before or after `dado` gives identical geometry. Do not
  write a file that depends on feature order — nothing will honour it.
- **`join` is a mutual contract, not an action done to one body.** It cuts the male feature on
  one part and the female cavity on the other at the same time (D8). `male=` and `female=` say
  which side gets which; the order you write the two properties in is irrelevant.

SQL is the precedent — `SELECT` is imperative mood in a declarative language.

**Write a project in three sections, in this order:**

| Section | What goes in it | Compiler stage |
| --- | --- | --- |
| 1. Describe the parts | `component` — stock, dimensions, **and the part's own features** | 2–3 |
| 2. Describe placement | `part` — `by`, `pos`, `rot` | 3 |
| 3. Describe the joins | `join` | 4–5 |

This is document order, evaluation order, and error-attribution order all at once. Two things
it does not make obvious:

- **Features belong to section 1**, not section 2. A dado is part of a board's intrinsic shape;
  where the board sits is a separate question.
- **Section 1 is intrinsic shape, not final geometry.** A join mutates *both* bodies, so a
  leg's mortise will never appear in that leg's own `component` block. The final shape of any
  jointed part exists only after section 3 resolves. Do not go looking for it earlier.

**Placement is one node, not a sequence.** `by`, `pos` and `rot` are properties of the
single `part` node, composing as `M = T(pos) · R(rot) · T(−anchor)` (D5). There is deliberately
no standalone `rotate` or `move` directive — see ADR-0003 for why that was rejected.

## 8. `joinery` + `join` — two-body joints

**Decision D8. Joints are declared by the author, never inferred.** You name the joint type
and the two marks it mates; the compiler cuts the male feature on one part and the female
feature on the other, applying clearance to the female side only. Every interlocking joint —
including `box_joint`, `dovetail`, `mortise` and `tenon` — is declared here, never as a §7
feature: a feature has no male/female to synthesise against and no clearance side.

Declare the contract once:

```kdl
joinery mortise_and_tenon name=StandardApronJoint {
    tenon_thickness 8.0
    tenon_length   25.0
    clearance      glue_clearance
    haunch length=12.0 depth=10.0
    centering axis=z
}
```

Apply it as many times as you like:

```kdl
join joint=StandardApronJoint male=Apron_Back@TenonShoulder_Start female=Leg_BL@MortiseFace_Y
```

**One exception, and it used to be unavoidable.** `{` and `}` are structural in KDL, so a
reference whose part id contains a `{name}`-style `repeat` interpolation **cannot** be bare
and must stay quoted (ADR-0002, which tested this against the real parser and accepted it as
a deliberate wart rather than quoting every reference for uniformity):

```kdl
// literal id — bare
join joint=TopEdgeGlue male=TopBoard_0@EdgeGlue_N female=TopBoard_1@EdgeGlue_S

// interpolated id, {}-form — must be quoted
join joint=TopEdgeGlue male="TopBoard_{j}@EdgeGlue_N" female="TopBoard_{next_j}@EdgeGlue_S"
```

**`<name>` interpolates exactly the same way and is bare-parseable** — use it when
you want to skip the quoting: `TopBoard_<j>` and `TopBoard_{j}` splice `j`'s value
identically; pick whichever reads better, never mix the two delimiters within one
placeholder.

```kdl
// interpolated id, <>-form — no quotes needed
join joint=TopEdgeGlue male=TopBoard_<j>@EdgeGlue_N female=TopBoard_<next_j>@EdgeGlue_S
```

All three forms are accepted. If you forget the quotes on a `{}`-interpolated reference the
file will not parse at all, which is at least a loud failure.

The **mark** half interpolates the same way, so a `repeat` of joins can name the mark each
iteration generated or picked, not only the part: `join joint=J male=Peg_<i>@Tip
female=Board@Slot_<i>` (or the quoted `{}` spelling). The delimiters hold a name in scope (a
`repeat` variable or a param), never an expression; the result must be a whole number or
`[A-Za-z0-9_]` text, as for a part id.

`male` and `female` are **`Part@Mark`** references, written **unquoted**. Read `@`
as "at" — the `TenonShoulder_Start` datum *at* `Apron_Back`. The male part receives the
protruding feature (tenon, pin, tongue); the female receives the cavity.

### Parts that move: `travel`

A `join` may carry one `travel` child, which says the **male** end moves — a drawer on its
slides, a door or lid on its hinges, a vise chop on its screw — and by how much:

```kdl
join joint=DrawerSlide male=DrawerSide_L@Slide female=Runner_L@SlideEdgeL {
    travel axis=-y body=assembly                  // max = the slide's `travel_distance`
}
join joint=LidHinge male=Rail_Back@HingeA female=Back@HingeB {
    travel kind=angle axis=-x body=assembly pivot=HingePin   // max = the hinge's `max_angle`
}
```

`kind=slide` (default) moves along `axis` in mm; `kind=angle` turns about it, in degrees,
right-handed, through the male end's mark or the male part's `pivot=` mark. `axis` is `x`, `y`
or `z`, signed to reverse, in the frame of the assembly the `join` is written in. `min`
(default 0), `max` (default the catalogue item's `travel_distance` / `max_angle`), `rest`
(default `min`) and `step` (default 10 mm / 5°) are CEL numbers. `body=part` (default) moves the
male part; `body=assembly` moves every part under its innermost enclosing assembly.

**D5 is unchanged: a `travel` moves nothing in the document.** The parts are drawn at `rest`
with their one static transform, the cut list and every joint are untouched, and the travel
is only a check pose. `ww check` carries the moving body through `[min, max]` in equal steps
(both ends always sampled) and intersects it with every other part, reporting
`ww::check::travel_foul` with both part ids and the travel value at which they first meet.
Hardware (a screw, garter, slide, hinge) is a display proxy and is never swept. A slide item
with `mount_gap=` is also checked against the real gap it fills
(`ww::check::slide_clearance`). Two joins declaring the same travel on one body (two hinges on
a door) are swept once. See `travel` and
`docs/adr/0075-travel-primitive.md`.

### Available joinery kinds

| Kind | Key params | Level | Cut |
| --- | --- | --- | --- |
| `butt` | — | L2 | nothing to cut |
| `dowel` | `diameter` `count` `depth` | L2 | M5 |
| `pocket_hole` | `screw_type` `spacing`, opt. `face` | L2 | M5 |
| `biscuit` / `domino` | `size` `spacing`; `domino` opt. `depth` | L2 | M5 |
| `mortise_and_tenon` | `tenon_thickness` `tenon_length` `clearance`, opt. `tenon_width` `haunch` `centering`, opt. `through` `proud` `end_chamfer` `pin` `wedge` `key` | L3 | M6; M13; M15; M32 |
| `half_lap` / `cross_lap` | `depth_ratio` (default `0.5`) | L3 | M6 |
| `bridle` | `cheek_thickness` `depth` | L3 | M6 |
| `box_joint` | `pin_count` or `finger_width`, `clearance` | L4 | M7 |
| `dovetail_through` | `slope` (`"1:8"`, `"1:6"`) `pin_count` `clearance` | L4 | M7 |
| `dovetail_half_blind` | `slope` `pin_count` `socket_depth` `clearance` | L4 | M7 |
| `sliding_dovetail` | `slope` `housing_depth` `tongue_thickness` `clearance`, opt. `stopped` `front_gap` | L4 | M31 |
| `round_tenon` | `diameter` `length` `clearance`, opt. `taper_to` | L3 | M24 |
| `tongue_and_groove` | `tongue_thickness` `depth` `clearance`, opt. `slot` | L5 | RFC-0030 / ADR-0022 |
| `edge_glue` | — (optional `biscuit_spacing`, `biscuit_size`) | L5 | M5: biscuits when `biscuit_spacing` is given |
| `stile_and_rail` | `groove_width` `groove_depth` `stub_tenon_length` `profile`, opt. `profile_size` `profile_face` | L5 | M6: the stub tenon (`profile "square"`); ADR-0026: stuck profiles with coped rail ends |
| `miter` | `angle` | L1 | M6 |
| `mitred_moulding` | `angle` `profile`, opt. `profile_size` `profile_face` | L6 | #460: a mitred corner that also carries a stuck profile (`miter`'s own bisector cut, `stile_and_rail`'s own sticking, applied to both symmetric ends) |
| `sliding_sleeve` | `extension` `clearance`, opt. `depth` | L3 | RFC-0146: a bore in the sleeve and a declared parked pose of the inner member — a pose, not a `travel` (ADR-0075 does not apply) |
| `hardware_mount` | `hardware <Name>` (opt. `hole …`, `mortise_depth`) | L2 | M27: cuts nothing unless it has a `hole` or `mortise_depth` row; `female=` optional |

A kind not yet cut is declared, resolved and drawn in the viewer, and `ww joins`, `ww check`
and the viewer's diagnostics say it is not yet synthesised.

Every kind above that takes `clearance` also takes `fit=slip|snug|press` instead (issue
#55): see "Clearance resolution" above for what each gives and the chain that resolves one
when a declaration gives neither.

### What the compiler cuts, and what it checks

A joint is **laid out from its male mark**, on the stock face that mark sits on and faces.
The female mark names the face it mates with: the two marks must face each other (`normal`s
opposite in the world) on faces in contact, and the male mark must lie where the faces
overlap. Where the female mark sits along its face is not used. A standard anchor
(`top_sw`) has no facing, so a joint that cuts anything needs `mark`s at both ends; a
`butt` does not.

Fasteners run in a row along the **long axis of the faces' overlap**, centred on the male
mark, each in an equal share of the longest span centred on the mark that stays on the
overlap:

- **`dowel`** — `count` of them, floored to a whole number with a warning.
  `depth` is how deep the hole goes into **each** part. Make a count follow a board with an
  expression: `count "floor(1.0 + (shelf_depth / dowel_spacing))"`. The two faces may be
  **turned about their shared normal** (a shelf set at an angle to the board it sits on, both
  faces still parallel): the row runs along the long axis of the part of the male's face that
  lies over the female's, and is refused only if it cannot stay on both.
- **`pocket_hole`, `biscuit`, `domino`, `edge_glue`** — one per whole `spacing` of that
  span, at least one, so the count follows the board as a slider moves.
- **`pocket_hole`** — a 15° pocket and pilot in the male, drilled into one of the two faces
  across the joint: `face` names it (a male part-local face, written as a CEL string:
  `face "'neg_y'"`), else the one no other part covers, else the underside of a board lying
  flat and the `+` face of one standing. The screw leaves the joint face at the male mark and
  the female gets the matching pilot, as far as the screw reaches. `screw_type` is the
  screw's length in inches and its thread: `1-1/4_coarse`, `2_fine`. The pockets are bench
  work (a jig), and the fabrication pack says so.
- **`biscuit`** — `size` `0`, `10` or `20`. **`domino`** — `size` is the tenon's thickness,
  `4` `5` `6` `8` or `10` (the Festool DF 500's), `depth` into each part, by default half
  the tenon's length.
- **Loose inserts.** `dowel`, `biscuit`, `domino` and an `edge_glue` with
  `biscuit_spacing` also **emit the loose piece**, one generated part per hole, `role=insert`,
  id `<male>@<mark>/Dowel_<i>` (`/Biscuit_<i>`, `/Domino_<i>`), in the male's material. A dowel
  is a round ⌀`diameter` × `2 × depth` revolve; a biscuit is the table's #0/#10/#20 wafer; a
  domino is the table's tenon, and `2 × depth` shorter than its length is the error
  `insert-fit`. Inserts are **bought, not cut**: they are in the scene, drawings,
  exploded views and clash check and in the hardware schedule (`Dowel 8 x 24`, `Biscuit #20`,
  quantity summed), but in no cut list, stock list or toolpath. Do not also model the piece
  as a `stock` part: it would be counted twice. An insert overlapping its own joint's two parts
  is design interference, not a clash; against any third part it is a clash. A hole that
  lands in wood another joint **adds** to the same part (a dowel bored into the end of a board
  whose through-dovetail tails pass through it) is refused as `insert-meets-joint`, naming the
  joint and the part, rather than building and surfacing later as a clash: move the row, or
  join the end some other way.

**`clearance` goes on the female side only**: its hole, slot or mortise is the male one grown
by `clearance / 2` on every face — `clearance` wider (a ⌀10 dowel in a ⌀10.2 hole at 0.2) and
`clearance / 2` deeper.

**Clearance resolution.** A `clearance=` number on the joinery declaration still
wins outright, unchanged — including the common `clearance glue_clearance` pattern every
example already writes. When a declaration gives **neither** `clearance=` nor `fit=`, or
gives `fit=` alone, the chain below resolves a number instead of the old flat `0.0`:

1. **`fit=slip|snug|press`**, when written, names which of the **female** material's own
   `tolerance_slip=`/`tolerance_snug=`/`tolerance_press=` properties (§5) to use. Leaving
   both `clearance=` and `fit=` out resolves as if `fit=snug` had been written — the class
   nearest the `glue_clearance 0.15–0.2` convention most examples already use. Giving both
   `clearance=` and `fit=` on the same declaration is an error: a number and a class cannot
   both be asked for.
2. If the female material declares no `tolerance_*` for that class (every example, until a
   material opts in), the project's own `glue_clearance` param is used if declared.
3. If neither resolves anything, the declaration behaves as `clearance 0.2` — matching the
   joint dialog's own seed value, and replacing the old silent `0.0` a declaration with no
   `clearance` at all used to get.

`fit`/`slip`/`snug`/`press` are woodworking-trade categories, not three different textbook
clearances for every material pair: a material's `tolerance_*` is itself a shop-practice
choice, to be corrected from real experience, not a derived physical constant. **Draft starting
values** (mm, not independently sourced — correct them before relying on them):

| Material | `tolerance_slip` | `tolerance_snug` | `tolerance_press` |
| --- | --- | --- | --- |
| Hard maple, walnut, white/red oak (hardwood, generally) | 0.25–0.3 | 0.1–0.15 | 0.0 |
| Plywood | 0.3 | 0.2 | 0.0 |
| MDF | 0.35 | 0.25 | 0.0 |
| Hardboard | 0.3 | 0.2 | 0.0 |
| Acrylic / solid surface | 0.15 | 0.05 | 0.0 |

```kdl
material HardMaple type=hardwood \
    tolerance_slip=0.25 tolerance_snug=0.1 tolerance_press=0.0 \
    movement_radial_pct=4.8 movement_tangential_pct=9.9
```

**Moisture-movement across grain.** `movement_radial_pct=`/`movement_tangential_pct=`
(percent linear, green-to-oven-dry) store a material's cited shrinkage — sourced to
`docs/species/*.md` where one exists (ultimately Wood Database) — for reference and for CEL,
never auto-applied to any geometry: no slot, dado or housing moves on its own because a
material declares these. For every material the resolved scene declares them on, they are
exposed as two flat CEL variables, `<MaterialName>_movement_radial_pct` and
`<MaterialName>_movement_tangential_pct`, readable from any joinery-body expression (a
breadboard end's `slot length=`, RFC-0030) — an author who wants a slot sized from a
material's own shrinkage writes the expression:

```kdl
slot diameter=8.0 length="peg_travel + panel_width * HardMaple_movement_tangential_pct / 100.0"
```

**What is refused** (the joint is not cut; `ww check` fails): marks that do not face each
other, faces not in contact (an inset door's reveal), faces that do not overlap or are too
short for the fastener, a fastener wider than the faces share, one that does not fit its
stock (a pocket in a board too short behind the joint for its 15° run), fasteners closer than
their own size, a hole deeper than the board, holes from opposite faces that meet, and a
parameter missing or out of its range. **What is warned**: less wood left behind a blind cut
than `max(3 mm, 15 %)` of the board, and a fractional `count`. **What is not yet placed**: a
mark on no stock face and a coped shoulder to a curved surface; an oblique
shoulder and a compound mitre are placed.

### Tenons, laps, bridles and mitres

**`mortise_and_tenon`.** The male mark sits on the **shoulder** — the end of the male's
stock line — and the tenon is **added** outside it, `tenon_length` long: the stock line stays
shoulder to shoulder (that is what `ww clash` and `ww joins` measure) and the stock list buys
the blank longer. `tenon_thickness` runs across the shoulder's narrower side, centred on the
mark; along the wider side the tenon is `tenon_width` wide, centred on the mark, or — without
`tenon_width` — leaves **equal shoulders all round**. The female mark names the face the
tenon goes into; the mortise is the tenon grown by `clearance / 2` on every face.
**The thickness axis is the smaller of the shoulder's two extents, always** (an exactly square
shoulder takes its first axis). It is not chosen by `tenon_width` or `haunch`, so a rail
stood on edge gets a tenon thin across its thickness and wide up its height, as expected.
When the two extents are within 2 % of each other but not equal, and a `tenon_width` or `haunch` is given, the
compiler warns `tenon-axis` and says which side it took (a 0.5 mm difference in the stock
line would turn the tenon 90°); an exactly square shoulder is not warned; a `tenon_thickness` that does not
fit the narrower side is an error that names both extents and this rule. To get the other
orientation, make the stock differ by more, not the joint.
- **On a part shaped by `cut_path keep="inside"`** the tenon is cut from the shoulder face as
  the outline leaves it, not from the stock box: a stepped or short end face carries the tenon
  on whatever of it stands, and the tenon (and a stub tenon on a `stile_and_rail`) is clipped
  to that. A tenon with no end face left to stand on is refused (`material`), naming the
  outline.
- **Zero `glue_clearance`** is allowed: the mortise is then exactly the tenon, and a round
  tenon's or wedge's socket is floored at twice the tessellation sagitta so the faceted
  polygon still clears it.
- **Blind or through:** a tenon that reaches the far face is a through tenon; its mortise
  goes through, and what would stand proud is cut flush by default. `through #true`
 asserts it — and, with `tenon_length` left out, computes the length
  that reaches the far face exactly — so a resize that shortens the female cannot
  silently turn a through tenon blind.
- **`proud` and `end_chamfer`:** `proud` moves the flush-trim plane out by that
  much instead of cutting the tenon flush — needs a through tenon. `end_chamfer` cuts
  the proud end's four arrises at 45° — needs `proud` greater than zero, and less than
  both `proud` and a quarter of `tenon_thickness`.
- **`pin` (M13, the drawbore's mechanism, RFC-0090):**
  ```kdl
  pin shape=round size=12.0 draw=1.0 count=2 spacing=30.0
  ```
  A round peg or a square, optionally tapered komisen, driven through the female and the
  tenon **across the tenon's thickness** (normal to its cheeks), through both, the tenon's
  own hole displaced `draw` toward the shoulder so driving one pin through both pulls the
  shoulder tight. `shape` is `round` (default) or `square`; `size` is the diameter (round)
  or the side at the big end (square, required); `taper` (square only) is 1-in-`taper` on
  the end-side face along the pin's own drive; `at` is how far from the female's mortise
  face the pin's axis sits (default: half the female's section when through, else half
  `tenon_length`); `count`/`spacing` spread pins across the tenon's width, centred on the
  mark; `enter` names the male-local cheek face the pin is driven from (default: the
  cheek's positive face); `ends_proud`, `material` and `label` place and name a **generated
  part** the joint makes on its own: the peg or komisen itself, id
  `<male id>@<male mark>/Pin_<k>`, appears in `Scene::parts` beside every declared part —
  in the cut list, the stock list, on a label, and in the viewer — with no `part` or
  `mark` written for it, and no `join` can name it (it has none). `material` defaults to
  the male's own; `label` to `"Peg"` (round) or `"Pin"` (square) and becomes its cut-list
  title. `ww clash` allows it to overlap its own joint's two parts, up to the draw's own
  design interference, without calling that a clash. A pin needs both parts turned by a
  multiple of 90° to one another, so its axis lands on a single straight line through both
  (`pin-frame`, unsupported otherwise).
- **`wedge` (M15, wari-kusabi, RFC-0090 §6):**
  ```kdl
  wedge count=2 kerf=1.5 depth=40.0 flare=1.5 flare_depth=20.0 material=Kashi label="Kusabi"
  ```
  Needs a through tenon (`through #true`). `count` saw kerfs, `kerf` wide, `depth` deep
  from the tenon's own end, through its whole thickness, at `k / (count + 1)` of its
  width — their planes normal to the tenon's own width, so the wedges spread it along the
  **female's own grain** (`wedge-grain`, checked); a blind, flared mouth on the female's
  **exit face** (the far face of a through tenon), each of the mortise's two
  width-bounding walls leaning out by `flare` over the last `flare_depth` of it
  (`wedge-flare`: `flare` at most a quarter of `flare_depth`, and the flare must not run
  out of the female's own face). `depth` must reach at least `flare_depth` and at most
  three quarters of the tenon's length in the female (`wedge-kerf`, which also refuses
  kerfs closer than `3 × kerf` to each other or to the tenon's own edges). `material` and
  `label` place and name a **generated part** per kerf, the same mechanism
  as `pin`'s: id `<male id>@<male mark>/Wedge_<k>`, `kerf` thick at its own tip and
  `kerf + 2 × flare / count` at its head, as thick as the tenon and as long as `depth`,
  seated with its head flush with the tenon's own end. `material` defaults to the male's
  own; `label` defaults to `"Wedge"`. `ww clash` allows a wedge to overlap its own kerf up
  to its own declared design interference — the extra width it grows toward its head
  against the kerf's own constant width — without calling that a clash. A `pin` and a
  `wedge` on the same declaration must not cross (`pin-wedge-clash`). Needs the same
  90°-turned, square-shouldered frame `pin` does (`wedge-frame`, unsupported otherwise).
- **`key` (M32, the keyed/tusk tenon, RFC-0125):**
  ```kdl
  key size=16.0 thickness=8.0 taper=10.0 draw=1.5 ends_proud=4.0 material=Wenge label="Key"
  ```
  Needs a through tenon standing well proud (`through #true`, `proud` large enough —
  `key-relish`). A plain, **untapered** mortise, `size` (along the tenon's length) ×
  `thickness` (across it), cut straight through the tenon's own **width** — the tall
  dimension of a rail standing on edge, so the key is driven top to bottom through the
  proud tip, as a real tusk key is — entirely in the male; no cut ever reaches the female.
  Its inner wall sits `draw` mm nearer the shoulder than flush with the female's own exit
  face (the same directed offset a drawbored pin's tenon-side hole uses, RFC-0090 §3): the
  tenon's own width must exceed `size` (`key-mortise`) for a tapered key to have room to
  seat rather than bind at once. `size`, `thickness` and `taper` (1-in-`taper`, along the
  key's own length) are required; `draw` defaults to `1.5` mm ("about 1–2 mm" is the shop
  range, RFC-0125 §3); `ends_proud` (default `0.0`) stands the key proud of the tenon's own
  width on both ends. `material` and `label` place and name a **generated part**
: id `<male id>@<male mark>/Key_0`, `size × thickness × (tenon width + 2 ×
  ends_proud)`, tapered on one face from `size` at its head down toward its tip, its
  *other* face placed flush, undrawn, on the female's own exit face — "bearing on the
  female's outer face". `material` defaults to the male's own; `label` defaults to
  `"Key"`. `ww clash` allows the key to overlap the tenon by the design interference the
  draw declares — the sliver `draw` wide at the head, tapering to nothing along the key's
  own length — without calling that a clash. Needs the same square, axis-aligned frame
  `pin` and `wedge` do (`key-frame`, unsupported otherwise).
- **`haunch length= depth=`**: the `length` of the tenon's width toward the nearer end of
  the mortised part stands only `depth` off the shoulder, and the mortise steps with it.
- **`centering axis=`** names a male axis the mark must be centred on (it is checked).
- **An oblique shoulder:** the tenon runs along the male's shoulder normal as placed, and
  may meet the female face up to 45° off square — a raked leg, a dished spoke, a hayrake or
  splayed stretcher tenoned into the legs at its slope (`docs/styles/models/
  hayrake-stretcher.kdl`, RFC-0142). The mortise then **leans** with the tenon, and the
  shoulder is **scribed** to the face, all round the tenon. A mark on a mitred end or a
  turned surface needs `tilt=`/`heading=`, and on a turned surface its shoulder is flat on
  the tangent plane, not coped.
- **Square corners:** the mortise is modelled square-cornered, as the tenon is. The radius
  a router leaves in its corners is a fabrication question (docs/platform/04 §2 check 5).
- **Refused:** a tenon as thick as its stock ("a 10 mm tenon on 8 mm stock"), a tenon or
  `tenon_width` off its shoulder, a mortise that runs off the female face, a blind mortise
  the clearance breaks through, a haunch as wide or as long as the tenon or with no nearer
  end, a shoulder more than 45° off square or off the face, and **two tenons that meet
  inside one part** (`tenons-meet`: two aprons into one leg at one height — stop each short
  of the other, or write `tip mitre=#true`, below).
- **Mitred tips (`tip mitre=#true`, RFC-0041, ADR-0026):** one line in the joinery body. Where
  two tenons from adjacent faces meet inside one leg, each is cut back to the plane
  bisecting the two (their axes' closest approach, the normal their difference), so they meet
  face to face with no overlap and no gap — the chair-maker's long tenons, and no
  `tenons-meet`. **Both** joints must say it (the rule still applies to a pair where only one
  does); the tenons must meet at an angle (parallel axes have no bisector — `tip-mitre`), and
  the bisector may not cut the tenon's own rail (`tip-mitre`: use shorter tenons). Only
  `mitre` is a key. The mortises are left as cut: each tenon is cut from its own mortise and
  the two mortises together hold both mitred tips.

**`round_tenon`:** a bored round socket and a cylindrical tenon, for a
turned member — closer in spirit to `dowel` than to `mortise_and_tenon`'s rectangular-cheek
assumption (ADR-0018's own "not yet built" line). Laid out from the shoulder exactly like
`mortise_and_tenon`: the male mark sits on the end of the male's stock line, the tenon
stands `length` off it, and the female mark names the broad face the socket is bored into.
```kdl
joinery round_tenon name=LegIntoSeat {
    diameter 16.0
    length 30.0
    taper_to 12.0      // optional: the tenon's diameter at its own tip, less than
                        // `diameter` — a plain cylinder without it
    clearance 0.3
}
```
- **Clearance is on the socket only.** The socket is bored to `diameter + clearance`; the
  tenon is cut at its nominal `diameter`, its widest point, whether or not it tapers — a
  straight bore is never reamed to follow a taper. `taper_to` therefore leaves the tenon
  loose near its tip in a straight socket by design: it is what lets the tenon start easily
  and drive home snug, closed the rest of the way (if at all) by `wedge` on the
  tenon's own end rather than a matching reamed taper.
- **Oblique shoulders lean the socket, not the tenon.** A raked or splayed post-and-rung
  joint is built: the tenon stays a cylinder along the male's axis and the socket is bored
  leaning to meet it, so its footprint on the female face is an ellipse. Only a `through`
  round tenon (the wedged one) must be square-shouldered (`round-tenon-frame`, unsupported
  otherwise): a through bore is cut straight.
- **Refused:** the shoulder off square by any amount, `taper_to` not less than `diameter`,
  and a tenon or socket that would run past its own face's edge.

**`stile_and_rail`** (`profile "square"`): the rail's stub tenon, `groove_width` thick and
`stub_tenon_length` long, from the edge nearer the stile's end to the rail's own panel
groove — cut back `groove_depth` there, so the panel runs on past it — into the stile's
groove.

**`stile_and_rail` with a stuck profile** (`profile "ogee"`, ADR-0026, RFC-0126): `profile`
is `square` (the default — flat) or one of the six moulding sections (`ovolo` `cove` `ogee`
`thumbnail` `half_round` `cockbead`); a moulding also needs `profile_size` (mm, across both
faces of the arris, as `moulding size=`) and `profile_face "'neg_y'"` — the way the frame's
show face looks, **a world direction** (`pos_x` … `neg_z`; a CEL string, as `face "'neg_y'"`
is), because a door's two stiles are one component placed twice, one turned half round, and
a part-local face would stick opposite sides. The joint then cuts three things, all
straight-sided `RuledCut`s: the **stile is stuck** (the same section `moulding` cuts, along
its whole inside edge), the **rail is stuck** the same way along its panel-side edge(s) —
both for a middle rail — and the **rail's end is coped**: the stile's void stands out of
the rail's end grain, across its width, so the rail fills the stile's sticking, and the
rail's own sticking runs through that tongue. The cope is the stile's void exactly, with no
`clearance` (clearance is the groove's). Refused (`cope-profile`, errors): a sticking deeper
than the wood between the show face and the groove, or than half the rail's width; a
`profile_face` that is not a broad face of the rail (its narrow edge) or that no side face of
a member looks; a shoulder off square; oriented marks. `profile_size`/
`profile_face` with `profile "square"` is a warning, not an error.

**`half_lap` / `cross_lap`.** The two members are laid out **through one another** at full
size; the marks sit **on the cheek**, facing each other (the male's facing the member lying
over it). Each part loses what of the shared box lies on its mark's side, down to the cheek;
`depth_ratio` is the male's share of its thickness and must agree with where its mark is.
`clearance` widens the female's notch, never deepens it, so the faces stay flush. A
`half_lap` is at the end of at least one member; crossing in the middle of both is a
`cross_lap`. The members may cross **skewed** — the two X-braces of a hayrake stretcher —
by at least 15° (shallower is an error): each is then notched to the parallelogram the
other's width casts on the cheek, their marks still on the cheek and facing. A
skewed `bridle` is unsupported (its fork and tongue are laid out square).

**`bridle`** (`cheek_thickness`, `depth`): the female is the fork, open at its end; the male
keeps its middle as the tongue. Same thickness face for face, marks on the mid-plane, the
slot reaching across the male.

**`miter`** (`angle`): the marks sit on the two boards' **ends**, which run past each other to
the corner's outer edge; both are cut back to the plane bisecting the corner. `angle` is
each end's cut off square (45 for a square corner) and is checked against the placement. A
compound mitre — boards that lean as well as turn, so the corner's edge runs along neither
board's axes — is cut on the same bisecting plane, provided the two boards are mirror images
across it (same section, leaning alike); unlike boards are an error, since the cut faces
would not meet.

**`mitred_moulding`** (`angle` `profile`, opt. `profile_size` `profile_face`, #460):
`miter`'s own corner — the marks sit on the two boards' ends the same way, and `angle` means
the same thing — with both members also stuck the whole way to the corner with the same
moulding (`profile`/`profile_size`/`profile_face` exactly as `stile_and_rail`'s own: a
world direction, the six sections, mm across the arris). The sticking is cut first, then
the mitre, so the mitre's own cut runs through the moulding rather than a plain box.
`profile "square"` degenerates to plain `miter` geometry, byte for byte. Which of a
member's two side faces (other than `profile_face`'s own show face) carries the moulding
— its **inside** edge — is not a parameter: it is derived from the corner itself, the
width-axis face farther from the mitre's own outer edge, so the author does not name it
directly, only places the components so it lands where intended (as they already do for
`profile_face`). Refused (`cope-profile`, generalised from `stile_and_rail`'s own, to
either end): a sticking deeper than half the member's own width; a `profile_face` no side
face of a member looks (its own length-axis end is always excluded). Refused (`corner`,
`mitres`'s own compound-mirror check, chord by chord when a profile is stuck instead of
16 box corners): a compound mitre (a member leaning as well as turning) whose stuck
sections are not mirror images — which, in practice, a stuck profile on a genuinely
compound corner cannot avoid, since no side face of a leaning member lines up with any
world direction for `profile_face` to resolve at all; `profile "square"` reaches the same
compound check `miter` always has, unaffected.

### Box joints and dovetails

A corner joint is two boards meeting **end to face**. The male — the finger or tail board —
is written **shoulder to shoulder**, like a tenoned rail: its end stops at the female's inner
face, and its mark sits on that end, facing the female mark on the face it bears on. The
compiler adds the fingers or tails to the male's end (they reach the female's outer face, or
`socket_depth` into it) and cuts the sockets from the female; the stock list adds the tails
to the male's blank. So:

- the male mark goes on the **end** of its board, never a broad face; the female mark on a
  **broad face**, across the board's thickness; and the male's whole end must bear on it;
- write the female **full length**, running to the male's outer face — for a dovetail it must
  end there (the tails slide in along the tail board's thickness, so a socket closed on both
  sides can never be assembled). An overlay front that runs past the side is **lipped**:
  rabbet its back face back to the side, at least as deep as the sockets
  (`bedside_cabinet.kdl` does it with `rabbet face=pos_y edge=neg_x`, RFC-0051/0143), and
  the compiler sees the rabbet;
- a groove through the male's end stops at the shoulder; run it on into the tails
  (`stopped=#true start="0.0 - depth"`) where something sits in it (`blanket_chest.kdl`'s
  floor), and stop the female's short of its ends.

```kdl
joinery dovetail_half_blind name=DrawerHalfBlind {
    slope "1:8"                 // an enum, written as a word — never a parameter's name
    pin_count dovetail_pin_count
    socket_depth "min(drawer_side_th, drawer_face_th - drawer_face_th / 4.0)"
    clearance glue_clearance
}
join joint=DrawerHalfBlind male=SideLeft@Tails_Start female=Front@Sockets_L
```

- **`box_joint`** — `pin_count` is the male's fingers: `2 × pin_count − 1` equal positions,
  both outer ones the male's. Or give `finger_width`, the narrowest finger you want, and the
  count follows the board: the most whole fingers at least that wide, made odd
  (`finger_width "min(box_th, drawer_h / 3.0)"` in `desk.kdl`). One of the two, not both.
- **`dovetail_through`**, **`dovetail_half_blind`** — `slope "1:N"` (1:8 hardwood, 1:6
  softwood; outside 1:4–1:12 is a warning). `pin_count` counts the pins **with the half-pin at
  each edge**, so there is one tail fewer; pin centres are equally spaced and pins and tails
  are equally wide halfway along a tail. **Or**, in place of `pin_count`, a graduated
  schedule — the same evenly-spaced centres, each tail's own root
  width given explicitly instead of shared:
  ```kdl
  joinery dovetail_through name=CarcassDovetail {
      slope "1:8"
      repeat i count=5 {
          pin index=i width="pin_width_min + (pin_width_max - pin_width_min) * (i / 4.0)"
      }
  }
  ```
  one `pin` per tail (`pin_count − 1` rows) — give `pin_count` or a schedule, not both. A
  through dovetail's tails are as long as the pin board is thick; a half-blind's are
  `socket_depth`, and the rest of the pin board is the lap.
- **`avoid start= width= margin= [on=male]`**: keeps a tail root clear
  of a feature cut elsewhere on the male board — a divider's dado, say. `start=`/`width=`
  are CEL expressions giving the exclusion interval `[start, start + width]`; `margin=`
  (default 0) expands it symmetrically. Unlike a `dado`/`groove`'s own `offset=`/`start=`,
  these are read as offsets from **the joint's own low end** — the same zero a `pin index=`
  row's position already is — not the avoided feature's own frame; reuse a feature's
  *run*-axis property (a `dado`'s `start=`, not its width-axis `offset=`) to land in the
  same axis the dovetail's own layout uses. At most one `avoid` per joint; `on=male` is the
  only side built (`on=female`, a zone on the pin board instead, is refused as
  `unsupported`). A tail whose root overlaps the zone but whose own centre does not is
  fixable — narrow that tail's `pin` row. A tail whose fixed centre sits *inside* the zone
  is not fixable by any schedule at that `pin_count` — `avoid` validates a layout, it does
  not write one.
- **Clearance** grows each socket by `clearance / 2` along the joint and on its closed side,
  and a half-blind socket's floor `clearance / 2` deeper.

**Refused** (the joint is not cut): a slope that is not a ratio, fewer than two pins, a
box joint without (or with both) `pin_count` and `finger_width`; the male mark on a broad
face, the female mark on an end or edge, the male's end not wholly on the face; fewer than
three fingers, or a finger, tail root or pin narrower than 3 mm (or than twice a flank's
run); a half-blind socket as deep as the pin board is thick; dovetail sockets closed on both
sides. **Warned**: a lap thinner than `max(3 mm, 15 %)` of the pin board, a fractional
`pin_count`, a slope outside 1:4–1:12.

**How they are made** (the fabrication pack): fingers and tails are the male's outline, cut
with it; box-joint sockets are through notches, from the face; a through dovetail's socket is
one straight cut from the pin board's end (class N); a half-blind socket leans from the
inside face (class A, a dovetail bit).

**Choose the joint a woodworker would actually use.** End-grain pocket screws into hardwood,
box joints on a corner that needs a mitre, or a mortise-and-tenon into 6mm ply are all wrong.
The validator rejects what cannot be made; it cannot tell a poor choice from a good one.

### Sliding dovetail housing

A shelf or divider's end, captured across the whole run of a housing rather than standing
in a plain square dado: the housing's walls lean outward as they go deeper (wider at the
floor than at the mouth), and the male's end is shouldered down to a matching tapered
tongue. Unlike `box_joint`/`dovetail_*`, this is a **T**, not a corner — the same shape
`mortise_and_tenon` binds (male mark on the end, female mark on the broad face it meets,
square: `docs/KDL_AUTHORING.md`'s oblique-shoulder allowance for a tenon does **not** apply
here, since an oblique housing is a different joint entirely) — but the housing runs the
male's **whole** depth rather than a bounded pocket sized to the tenon, and can be **stopped**
short of one edge.

```kdl
joinery sliding_dovetail name=ShelfHousing {
    slope "1:6"              // shallower than a corner dovetail's — a long run only
                              // needs to resist lifting, not driven home
    housing_depth 7.0        // how deep into the female, and how far the tongue stands
    tongue_thickness 15.0    // the tongue's width at its root, centred on the male mark
    clearance glue_clearance
    stopped #true            // leaves `front_gap` uncut at the end nearer the project's
    front_gap 12.0           // front (`front=`, defaulting to `neg_y`), so it never shows
}
join joint=ShelfHousing male=ShelfLeft_0@RightDowel female=CentreSupp@Shelf_0_Left
```

- The male mark's own position along the run (the male end face's longer extent — a
  shelf's own depth) is not read: the housing always captures the male's whole depth there.
  Only the mark's position **across** the flare axis (the male's own thickness) matters,
  centring `tongue_thickness`.
- **Stopped** which end depends on which one is nearer the project's front, read off the
  world rather than a fixed local convention — a mirrored (turned) twin's own end face
  reads its axes the other way round, and a fixed `u = 0`/`u = extent` rule
  would get it backwards.
- **Clearance** widens the housing only across the flare (the fit that matters) and in
  depth, never along the run.

**Refused**: the shoulder off square by any amount (a raked shoulder is a different
joint); the tongue wider than the male measures across its end there; a stopped run left
narrower than 10mm once `front_gap` is taken off; `stopped` without `front_gap`.
**Warned**: `front_gap` given without `stopped` (it is not read).

**How it is made** (the fabrication pack): a dovetail bit, leaning (class A) — the same
technique a corner dovetail's socket is cut with.

### Tongue and groove, and the breadboard end's slot

A square tongue on the male's end or edge, the groove it fits across the female's face: the
same **T** as the housing, with no lean (it resists shear across the seam, not being lifted
out). The tongue is the male's own wood, never a separate part.

```kdl
joinery tongue_and_groove name=CapJoint {
    tongue_thickness 10.0    // across the male's thickness, centred on the male mark
    depth            25.0    // how far the tongue stands out; the groove's depth too
    clearance        0.1     // optional: the groove is wider, and deeper by half
    slot diameter=8.0 length=16.0    // optional: a peg slot through the tongue
}
join joint=CapJoint male=TopBoard_0@Tongue_Left female=BreadboardEnd_Left@GrooveFace_0
```

- The tongue spans the male's **whole** run (the end face's longer extent); only the mark's
  position across the shorter one matters, centring `tongue_thickness`.
- `slot diameter= length= [at=]` bores an elongated hole through the tongue's thickness,
  `length` overall along the run (`diameter` plus the travel either side), centred on the
  male mark, `at` out of the shoulder (default `depth / 2`). Put it on the boards that move;
  a glued board takes a second `joinery` without it.

**Refused**: a tongue as thick as its board or off it; a groove that does not fit across the
female; a shoulder off square or not bearing; a `slot` shorter than its diameter, or leaving
the tongue. **Warned**: under `max(3 mm, 15 %)` of wood beside the groove (`remaining-wall`);
a tongue running past the end of the face it goes into (`tongue-overhang`).

### Grain contact and the joint-strength rating

Every joint between two solid-timber parts is classified by how each side's **grain**
meets the joint, not just which stock face the cut is on: long-grain/long-grain,
long-grain/end-grain, end-grain/end-grain, or a cross-grain panel (two long-grain faces
whose grain axes are not parallel). This runs for every joint kind with no per-kind
opt-in — a `hardware_mount`, a sheet-goods joint, or a mark with no stock face is simply
not classified (no finding either way).

**Refused**: an unassisted end-grain glue joint — long-grain-to-end-grain or
end-to-end — with no mechanical joint present (`butt`, `edge_glue`, `biscuit` alone): glue
on bare end grain is one of the weakest joints in woodworking. **Warned**: a long-grain or
cross-grain glue-up wide enough that a typical seasonal humidity swing would move it past a
shop-practice limit (`grain-movement`). Every other joint carries a banded `A`/`B`/`C`
strength rating (never a number) — a shop-practice estimate, not an engineering
certification — shown alongside `ww check`'s diagnostics.

Accept the risk knowingly, per joint:

```kdl
joinery mortise_and_tenon name=DecorativeRail {
    grain_check off reason="deliberately butting end grain here; this rail is decorative, \
                             not load-bearing"
}
```

`reason=` is required. `grain_check off` silences the error and the rating's visibility for
that one joint, but not the movement-check warning — a different physical concern.

The rating bands, the movement check's constants, and (for now) the shrinkage figures it
reads are placeholder or hand-copied numbers, not an engineering reference — see
`docs/adr/0082-grain-contact-and-joint-strength-rating.md` for exactly which.

## 9. `assembly`, `part`, `repeat`

```kdl
assembly Base {
    // Instance of a component
    part Leg_FL instance=Leg {
        by top_sw
        pos x=0.0 y=0.0 z=0.0
        rot z=90.0
    }

    // One-off part defined inline: `stock` + `material` instead of `instance=`
    part Top material=WhiteOak role=top {
        stock <material> width depth top_th
        by top_sw
        pos x=0.0 y=0.0 z=0.0
    }

    // Override a component param at instantiation
    part Rail instance=Apron length=open_w member_h=25.0 {
        by top_sw
        pos x=leg_dim y=10.0 z=-100.0
    }

    // `finish=` (RFC-0122): overrides this one foot's material default for the cut sheet
    part Foot instance=TurnedFoot finish="'ebonized'" {
        by top_sw
        pos x=0.0 y=0.0 z=0.0
    }

    // The left side, mirrored (RFC-0012, proposal 0202): same `by`/`pos`/`rot` as its
    // left-hand twin, reflected across the plane `x = width / 2.0` — the case's own
    // centre plane, in the frame `pos` is written in.
    part CaseRight instance=CasePanel {
        by top_sw
        pos x=0.0 y=0.0 z=0.0
        mirror x="width / 2.0"
    }

    repeat i count=3 {
        part Shelf instance=ShelfBoard id="Shelf_{i}" {
            by top_sw
            pos x=0.0 y=0.0 z="0.0 - (i * shelf_pitch)"
        }
    }
}
```

- A `part` has **either** `instance=` **or** an inline `stock` — never both.
- `pos` and `rot` take `x=` `y=` `z=` properties. Omitted axes are `0.0`. Rotations are
  degrees, applied about the anchor.
- `mirror` takes **exactly one** of `x=` `y=` `z=`: the value is
  a plane `<axis> = <value>`, in the frame `pos` is written in (the enclosing assembly's),
  that the whole placement — `by`, `pos` and `rot`, exactly as written — is reflected
  across, applied last. Two sides that are mirror images of each other are one component,
  written once for the left hand; the right hand is the same `by`/`pos`/`rot` plus
  `mirror`, not a `rot` plus overridden offsets. The solid stays watertight with positive
  volume (the kernel re-winds a negative-determinant transform, ADR-0008's postscript);
  marks and their `normal=` reflect with the part, so a `join` naming one still resolves;
  and the cut list gives the pair two rows, `(left hand)` / `(right hand)`, never one row
  at quantity 2. A mirror leaves the part's own grain axis and any asymmetric joinery
  (a half-blind dovetail's tails, say) exactly as declared — reflected geometrically like
  everything else, not specially handled, and not automatically re-cut for the other hand's
  bench: the RFC's own worked table shows why a rotation alone could not do this.
- `repeat <var> count=<n>` binds `<var>` to `0 .. n-1`, as a real (never a CEL int — ordinary
  float arithmetic over the index, `i / 2.0`, always works). Use `id="Name_{var}"` to keep
  part IDs unique — **required**, or the IDs collide.
- **`pos`, `rot` and `mirror` are three nodes: one per line, or `;`-separated.** KDL ends a
  node at a newline or a `;`, not at the next word, so `pos x=10.0 y=0.0 mirror x="w / 2.0"`
  is *one* `pos` carrying the stray argument `mirror` and a second `x=`. That is refused
  (`ww::expand::stray_argument`, `ww::expand::duplicate_axis`) rather than silently moving the
  part. Write `pos x=10.0 y=0.0; mirror x="w / 2.0"` or put `mirror` on the next line.
- Assemblies nest. Nested transforms compose. `mirror` is legal on an assembly instance
  too — reflecting the whole sub-assembly, RFC-0012's handedness composes (a mirrored part
  inside a mirrored assembly is not handed).
- `finish=` is optional on any `part`, instantiated or inline: overrides its
  material's own default for the cut sheet. Not valid on `part X instance=SomeAssembly`
  — an assembly instance is not one material.

### `repeat` inside a `component` or a `joinery` body

The same `repeat <var> count=<n> { … }`, legal in two more places, lowered differently by
what its body already means there:

```kdl
component Side {
    stock WhiteOak side_th depth height
    repeat i count=shelf_count {
        dado width=shelf_th depth=dado_depth stopped=#true \
            offset="height - (i * shelf_pitch) - shelf_th"
    }
}

joinery dovetail_through name=CarcassDovetail {
    slope "1:8"
    repeat i count=4 {
        pin index=i width="pin_width_min + (pin_width_max - pin_width_min) * (i / 3.0)"
    }
}
```

- **Inside a `component`**, the body holds features (`dado`, `hole`, `chamfer`, …), cut
  directly onto the component's own template — every `mark` or `id=`-bearing feature must
  interpolate the loop variable (either delimiter form, `{i}` or `<i>` — ADR-0086), exactly
  as a `part id=` must (`kdl-check` enforces both). A `mark` inside the `repeat` generates
  one mark per iteration (`mark Slot_<i> x=… normal=…`, or the quoted `mark "Slot_{i}"`
  spelling), and a `join` may name it by interpolating the same way
  (`female=Board@Slot_<i>`, bare, or `female="Board@Slot_{i}"`, quoted).
- **Inside a `joinery`**, the body holds a schedule record — `pin` for a graduated dovetail
, `peg` for a drawbore — one row per iteration, consumed by the kind's
  synthesiser in place of a flat count. Nothing here is independently `join`-able; a schedule
  row is a parameter, not a part or a mark.

### `lattice` — a data-driven grid, resolved structurally, not through `repeat`

`std:drawer_dividers`' eggcrate lattice (two runs of slotted slats crossing with
`cross_lap` joints, an N×M grid with some internal walls optionally merged into bigger
compartments) cannot be written as plain `repeat`/`join`, for a reason worth stating rather
than discovering mid-build: an arbitrary-length list of which walls to omit has no way to
gate a `repeat` body's own iteration, because CEL has no list-membership test (ADR-0005
deliberately starts its context from `cel::Context::empty()`, dropping `contains`/`in`/
`size`). So `lattice { … }` is a different kind of node: an assembly-scope generator,
resolved **structurally**, before any CEL expression in the document is evaluated at all
(Stage 1, alongside `collection`'s own resolution pass) — its own properties and its `omit`
children must therefore be **literal numbers**, not expressions.

```kdl
import HBar, VBar, BorderSide, BorderEnd from="std:drawer_dividers"

assembly Organizer export=Organizer {
    lattice rows=4.0 cols=5.0 bar_th=6.0 wall_height=50.0 fit_clearance=0.75 \
        border_th=9.0 inner_width=400.0 inner_length=300.0 {
        omit x=2.0 y=1.0
        omit from_x=1.0 from_y=1.0 to_x=3.0 to_y=4.0
    }
}
```

- `rows=`/`cols=` are the grid's **compartment** count (not wall count): `rows=4` is 4
  compartments, 3 internal cross-walls.
- `fit_clearance=` is a per-side clearance gap, subtracted from `inner_width`/`inner_length`
  before anything else is computed — `0.0` for an exact fit.
- `omit`, in either form, is the entire mechanism for a layout that reads as non-uniform:
  merging two or more adjacent base cells by removing the wall between them. Compartment
  sizes are always a whole multiple of the base-grid pitch — there is no independent
  per-cell dimension.
  - **Single-coordinate**, `omit x= y=`: removes the *vertical* internal wall at interior
    column-line `x` (`1..cols-1`), within row-band `y` (`1..rows`) — merging the base cells
    on either side of that wall, in that one row. This form is **vertical-wall-only**, a
    deliberate convention (not stated either way in the spec this feature came from): to
    merge cells within one column instead (a vertical merge, across rows), use the region
    form with a span one column wide.
  - **Region**, `omit from_x= from_y= to_x= to_y=` (grid-line coordinates, `0..cols` /
    `0..rows`): removes every internal wall of **either** orientation strictly inside the
    span — every vertical wall-line strictly between `from_x`/`to_x`, in every row-band
    fully inside `from_y`/`to_y`; every horizontal wall-line strictly between `from_y`/
    `to_y`, in every column-band fully inside `from_x`/`to_x`. Sugar over the same
    single-coordinate mechanism, not a second one — it just saves writing one `omit` per
    wall in a large merged region.
- `lattice` generates the border frame (four butt-jointed pieces) and every internal bar as
  ordinary `part`/`join` nodes — nothing here is inspectable before expansion, but
  `ww params`/`ww check` see exactly what a hand-written grid of this shape would produce.
  **Known v1 limitation**, recorded rather than hidden: a bar still carries a notch mark at
  every interior point of its own run regardless of whether the perpendicular member is
  present there, so a lone omission on one orientation with no matching omission on the
  other leaves an unused notch mark on the continuing member (harmless — an unmounted mark
  is ordinary, `crates/ww-dsl/std/foundation.kdl`'s own `Anchor_{a}` marks are the same
  shape) rather than being independently skipped too.

See `docs/superpowers/plans/2026-10-29-drawer-dividers-698-plan.md` for the full reasoning
(including why a per-instance `omit` cannot be threaded into a shared `std:` assembly's own
`repeat` the way an ordinary CEL parameter override can) and
`crates/ww-dsl/src/lattice.rs` for the implementation.

### `instance=` may name a component **or** a sub-assembly

A `part` instantiates either a `component` or an `assembly`. Instantiating an assembly is how
a piece that comes apart in real life is modelled as two things that stack:

```kdl
assembly Base { … }
assembly Rack { … }

assembly WelshDresser {
    part BaseUnit instance=Base { by top_sw; pos x=0.0 y=0.0 z=0.0 }
    part RackUnit instance=Rack { by top_sw; pos x=0.0 y=0.0 z="0.0 - base_height" }
}
```

An instance of an assembly may override that assembly's own `params` with child nodes
(`width 600.0`, `h_low "base_h / 2.0"`) — the same way a component instance overrides a
component's. The value is evaluated where the `part` is written; the key must be a param
of the *instanced* assembly (`ww::eval::unknown_override` otherwise). Each distinct set of
overrides compiles once and is reused. An assembly's params see its own file's project
scope only (D1), never the enclosing assembly's. See §14.

`kdl-check` errors if `instance=` names neither. It also **warns when a component is declared
and never instantiated** — a real bug that has occurred: a file described a centre support in
its comments, declared the component with marks, and never placed it, so the built object
lacked the part its own documentation promised.

### `volume` — an enclosed volume, and `ww metrics`

`ww metrics <file> [--json] [--set …]` reports each part's volume, mass (where the material's
species or density is known), surface area, and the contact area between touching parts. It
reads the built solids and changes nothing. A top-level `volume` node adds a **named enclosed
volume**, for the litres inside a cabinet, chest, drawer or compartment:

```kdl
volume Interior {
    bounded_by End_L End_R Front Back Bottom Lid   // the parts whose union closes the region
    subtract Shelf                                   // parts standing in the air (optional)
}
```

`bounded_by` names parts by id or by any unique trailing run of it (`Lid`, `Chest/Lid`); at
least one is required. `subtract` parts displace their volume. `volume` takes no properties and
no other children. The result is `volumes.<Name>.net_l` in `metrics.json`, and:

- if the bounding parts do not close, the volume is a **leak**: `status: "leak"`, no number. A
  gap under 2 micrometres is sealed and flagged with an error bound; a wider one is a leak that
  says how wide (`leak_closes_at_mm`). A volume is never guessed.
- closed chambers under 1 mL or 1% of the bounding box (dowel sockets) are not the enclosure;
- faces lining the volume count as **internal** area, the rest as external.

Mass is air-dry (about 12% moisture content). A material gets a density from `density_kg_m3=`,
else `species="slug"`, else an exact match of its name against `docs/species/`; otherwise its
mass is `null` with a reason, never a default. See `docs/adr/0036-model-metrics.md` for the
method, accuracy bounds and JSON schema.

**Finish quantity.** A part's `finish=` (else its material's) is matched to an entry of
`docs/finishes` by name (`finish="'danish oil'"`); where that entry records a maker's coverage
and coat count, `finish.groups.<text>.litres` is `{low, high}`: free area (contact excluded,
end grain counted x1 to x2) times coats over coverage. A finish the guide has no sourced figure
for (shellac, paint, milk paint, an invented name) gets `status` `no-coverage`/`no-entry` and no
litres: nothing is defaulted, and `area_without_litres_m2` says how much surface the total
leaves out. `--coats N` and `--end-grain-max X` override the coat count and the (assumed) end
grain multiplier.

**Bill of materials.** `bom` in `metrics.json` (and the end of the text report) lists what to
buy: stock per material and thickness (the cut list's blanks, `nominal` and with a `--waste`
factor, default 0.15, board feet for timber); purchased items split into `fastener` (a hardware
entry with a `length`: quantity, length, SKU), `insert` (dowels, biscuits and dominos the
joinery generates) and `hardware`; and consumables. Glue is the **area** of the glue lines (the
planar contact of parts a `join` ties together), not a quantity: no spread rate is recorded for
any adhesive. Finish is the litres above. Items with no `price=` are counted as unpriced, never
priced at zero. `ww pack` writes the same data as `metrics.json`.

### Contact completeness: `no_join`

`ww metrics` already computes exact face-to-face contact between built solids (`contacts` in
`metrics.json`, from real geometry, not bounding boxes). It now cross-checks every contact
against the document's declared `join`s, in both directions:

- a `join` with no matching planar contact was already reported (the BOM's `glue
  pairs_without_contact`, an under-count of glue area — a round tenon in a round mortise, a
  scarf, where contact is real but not planar);
- a **real contact with no `join` at all** — a forgotten joint, most likely — is now a warning,
  `ww::analysis::unaccounted_contact`, naming the pair and the contact area.

A contact that is real and deliberately un-jointed (two parts that rest against each other by
design, with no glue or mechanical joint between them) is excused with `no_join`, a top-level
node parallel to `volume`:

```kdl
no_join a=Shelf b=Case reason="rests on cleats, lifts out, no joint by design"
```

`a=` and `b=` name the two parts the same way `volume`'s `bounded_by` does — an exact id, or any
unique trailing run of one — resolved against the built scene, so `no_join a=Leg_0 b=Floor` and
a `repeat`-generated `Leg_{i}` both work. `reason=` is required and must not be empty: the
escape hatch names *why*, it does not silence by merely existing. A `no_join` naming a part that
does not resolve is reported (not silently ignored), and the contact it was meant to excuse
stays flagged.

This is read-only, like the rest of `ww-metrics`: it decides no joint type and changes no solid
(D8 stands — the check only asks whether the author said *something* about a touching pair,
never what that pair *should* be).

## 10. House rules

1. **Every number that means something gets a name.** A reader should be able to change one
   parameter and get a correctly proportioned piece of furniture.
2. **Comment the joinery reasoning, not the syntax.** `// short grain here, so screws not a
   tenon` is useful; `// set the position` is not.
3. **Real proportions.** Seat height 430–460mm, table height 720–760mm, desk 730–750mm,
   worktop 900mm, chair seat depth ~400mm. Look them up rather than guessing.
4. **Grain direction is structural.** It is the fifth, optional argument of `stock` —
   `stock Oak 23.0 44.0 232.0 crosswise` — and defaults to `lengthwise`, along the member's
   longest dimension. State it explicitly on parts where it decides the joint. `lengthwise`
   and `crosswise` are read from the board's *current* proportions, so a parametric panel
   whose depth and height can cross over would flip its grain (and turn every groove on it)
   partway along a slider. Pin it instead with `along_x`, `along_y` or `along_z`: the grain
   runs along that part-local axis (D4) at every parameter value —
   `stock Oak 15.0 278.0 312.0 along_y`. It pins one axis for the whole board, not a
   grain per face (RFC-0113 is still open). Sheet goods
   are cross-laminated and have no solid-timber grain direction, so naming **any** grain word
   (`lengthwise`, `crosswise`, `along_x`, `along_y`, `along_z`) on a `type=sheet` material is an
   error, not a nicety: write `stock Birch_Ply 18.0 500.0 700.0` with no
   fifth argument. The *face-veneer* direction of a ply sheet (which way the show-face figure
   runs) is not modelled; it is RFC-0072's open question 5.
5. **Sheet goods get `finished_th`**, and joinery must reference it, not the nominal.
6. **Solid wood panels must be allowed to move.** A wide panel captured in a groove needs an
   expansion gap; say so in the parameters (`panel_gap`) and in a comment.
7. Header comment on every file: what the piece is, its overall size, and which joints it
   exercises.

## 13. Limits

The compiler runs files it did not write — on the server and in a visitor's browser — so
one project file may ask it for only so much. Each limit is a counter inside the compiler,
not a timeout, so a file over one fails with the same diagnostic, at the same line, in the
CLI, on the server and in the browser. Every file in `examples/` is at least 6× inside
every limit; `crates/ww-build/tests/budgets.rs` measures it.

| Limit | Value | What counts | Largest in `examples/` |
| --- | ---: | --- | ---: |
| Source size | 256 KiB | Bytes in the file, comments included | 41 KB (`welsh_dresser`) |
| Nesting depth | 64 | `{ … }` blocks, with `project` as level 1; and assemblies, from a root down through every nested `assembly` block and every sub-assembly placed with `part X instance=SomeAssembly` | 6 (`desk`) |
| Parts | 2,000 | Parts the project expands to, with every `repeat` unrolled and every sub-assembly instance counted as the parts it places. Nested `repeat` counts multiply: `repeat` 64 inside `repeat` 64 is 4,096, refused before the inner loop is unrolled | 42 (`welsh_dresser`) |
| Features per part | 1,000 | Features on one part (`dado`, `hole`, …) with every `repeat` in its body unrolled; nested counts multiply here too | 13 (`workbench`) |

| Imports | 32 | Distinct files pulled in by one compile, `std:` libraries included | 2 (`imports/two_banks`) |
| Import depth | 8 | `import` chain length: file imports file imports file | 2 (`imports/two_banks`) |
| Specialisations | 256 | Distinct (assembly, override set) pairs: each `instance=` of an assembly with a different set of overrides | 2 (`imports/two_banks`) |

One `repeat` is also capped at 4,096 iterations by itself; the parts limit is the one that
bounds loops inside loops. There is no limit yet on triangles or on the size of the
compiled scene.

### Known model limits

Not budgets: places where the model is narrower than the woodworking, so a correct-looking
result can still be wrong. Each is a refusal or a blind spot you can design around.

- **A pin needs both parts axis-aligned.** `pin` (RFC-0090, drawbore and through-pin) takes
  the direction the pin runs — the male's `enter` cheek normal — and carries it into the
  female's own part-local frame (`joinery/pin.rs`, `canonical_axis`). It must land on one of
  the female's ±x/±y/±z axes to within 1e-6; any other direction reports an `unsupported`
  `pin-frame` diagnostic ("a pin needs both parts axis-aligned") and produces no pin. So a
  male turned a quarter-turn about an axis is fine, while a raked, splayed or compound-angle
  member (RFC-0022, `rot` by anything but a multiple of 90°) is refused, as is a female
  that is itself rotated off the male's axes. `wedge` has the same constraint
  (`joinery/wedge.rs`, `joinery/round_wedge.rs`).
- **The exposure check cannot see a part buried inside another.** `ww style`'s exposure
  (`crates/ww-style/src/exposure.rs`) hides a face only where a join's contact lands on it
  or a coplanar neighbour's box covers it. A peg or dowel stub sunk inside its leg touches
  no face that way, so it counts as *show*, and style rules that read show faces (finish,
  `absent … on=show`) see it as visible.
- **A face is one unit.** The same module marks a whole face hidden if any join lands on
  any part of it, and counts a tapered face at its stock plane. Both err toward "hidden",
  never toward "show".

## 14. `import` — using another file's assemblies

```kdl
// cabinet.kdl
project name="Cabinet" units=mm schema_version=1 {
    import DrawerBank from="lib/drawer_bank.kdl" export=DrawerBank
    import DrawerBox  from="std:drawer"          export=DrawerBox

    assembly Cabinet {
        part Narrow instance=DrawerBank { by top_sw; pos x=0.0 y=0.0 z=0.0 }
        part Wide   instance=DrawerBank { width 800.0; by top_sw; pos x=500.0 y=0.0 z=0.0 }
    }
}

// lib/drawer_bank.kdl — offers one thing
project name="Bank" units=mm schema_version=1 {
    assembly DrawerBank export=DrawerBank { params { width 400.0 } /* … */ }
}
```

- `import Alias from="…" export=Name` brings the file's exported `assembly` (or `component`)
  in as `Alias` (or `Alias.Export` when its name differs from the alias). `export=` on the
  import is a comma-separated list (`export="A,B"`); omitted, it takes everything the file
  offers. Only what a file marks `export=` is visible; everything else stays private to it.
- `from=` is relative to the *importing file's* directory. `std:drawer` is a built-in
  library (`DrawerBox`: box-jointed Baltic-birch drawer box; params `width` `depth` `height`,
  outside size, origin at the top face's south-west corner). No file access is needed for
  `std:`, so it works in the browser and on a server.
- A file resolves in its **own** scope (D1): an imported drawer sees the drawer file's
  params, never the importer's. Override what you need at the `part … instance=` line (§9).
- Imported top-level assemblies are templates, never scene roots: they appear in the scene
  only where a `part … instance=` places them.
- Materials, hardware and joinery are one global catalogue by name, but an imported entry is
  evaluated in **its own file's** scope: a library's `hardware … price="base * 2.0"` reads the
  library's `base`, never the importer's. An identical duplicate is fine; a same-name entry
  with the same text that reads a param (so means something different in each file), or that
  differs, is `ww::dsl::import_conflict`.
- Imported parts appear with mangled names in the cut list and part ids
  (`DrawerBox.DrawerBoxEnd~Wide.Bottom.Front`, `DrawerStack/Wide/Bottom/Front`).
- Cycles are `import_cycle`; over the depth or count limit is `import_too_deep` / `too_many_imports`
  (§13); an unknown export name is `unknown_export`.
- **Hosts.** The CLI reads files inside the entry file's directory only (no absolute paths,
  no `..`, `.kdl` only). The browser/JSON request supplies a `files` map of `key → text`;
  a server compiling untrusted source supplies none.
- **Diagnostics** inside an imported file name that file and count from its own line 1:
  `lib/bad.kdl:7:15` in the CLI, and `{"file": "lib/bad.kdl", "line": 7}` in the JSON
  diagnostics (`file` is absent for the entry file).
- Known limits: computed values cannot be exported (only assemblies and components; ADR-0020
  addendum C says why);
  `ww` author edits (`join`, marks, joinery) refuse targets inside an imported file.


## 15. Reference parts, `label=`, material `color=`

```kdl
materials { material Appliance type=sheet nominal_th=(in)1 color="#B0B4B8" }
assembly Kitchen {
    part Fridge role=reference label="Refrigerator" {
        stock Appliance (in)36 (in)30 (in)68
        by top_sw
        pos x=(in)42 y=0.0 z=0.0
    }
}
```

- `role=reference` marks a part that exists only to show context — an appliance, a wall, a
  neighbouring unit. It is **in the scene and in the clash check** (a cabinet overlapping the
  fridge is reported) and **in no list**: not the cut list, stock list, nest, labels, DXF,
  edge schedule, cost, hardware schedule or waste. Nothing is cut from it.
- `label="…"` on a `part` is the display name shown in the viewer and scene JSON; the part id
  is unchanged.
- `color="#RRGGBB"` on a `material` tints its parts in the scene (viewer and JSON only); on a `type=fabric` material it is the
  cloth's tone in the photoreal view.

## 16. Units, `collection`, `choice` and slots

**Unit literals.** `(in)34.5` is 876.3 mm, converted at parse time; `(mm)N` is the identity;
any other annotation (`(ft)3`) is `ww::dsl::unknown_unit`. Inside a CEL string use
`inch(0.75)`. A bare number is always millimetres; `project units=in` only changes the
viewer's readout.

**A collection is a look, defined once.** It holds standards as `params`, word-valued settings
as `choice`, and `slots` that bind role names to materials, hardware, components or assemblies.

```kdl
project name="Kitchen" units=mm schema_version=1 collection=ShakerFaceFrame {
    collection UsBase {
        params {
            base_h (in)34.5
            reveal (in)0.125
        }
        choice construction=face_frame { face_frame frameless }
        slots {
            carcass material  CarcassPly
            door    component Door_Shaker requires="dw dh pull_x pull_y"
        }
    }
    collection SlabFrameless extends=UsBase {
        params { base_h (in)36 }
        choice construction=frameless
        slots {
            door Door_Slab
        }
    }
}
```

- `project … collection=Name` selects the collection in force. There is no default.
- `extends=Base` merges the chain root-first. A derived collection may only override keys the
  base declares (`ww::dsl::collection_unknown_key`); a cycle is `collection_cycle`; an unknown
  name is `unknown_collection`.
- **Params** appear to every unit as `coll_<name>`; `base_h` is read as `coll_base_h`.
- **`choice n=word { words }`** declares the allowed words; it becomes one 0/1 param per word,
  `coll_n_is_<word>`. Branch on it with `repeat _ count=coll_construction_is_face_frame { … }`.
  Anything else is `ww::dsl::bad_choice`.
- **Slots** are declared once in the base as `name kind Target [requires="p1 p2"]` where kind is
  `material`, `hardware`, `component` or `assembly`; a derived collection rebinds with
  `name Target`. `requires=` lists the params a bound component must declare: the check runs
  for every collection's slots (in force or not) and a violation is
  `ww::dsl::slot_contract`, reported at the slot line.
- **Using a slot.** Write `@name` anywhere a material, hardware, component or assembly name
  goes: `stock @carcass …`, `part Door instance=@door {…}`, `hardware_use @pull qty=1`, joinery
  and `finish`. `@x` with no collection in force, or naming no slot, is `ww::dsl::unknown_slot`.
- Example: `examples/kitchen_collections.kdl` — a three-unit kitchen; the second collection is
  ten lines.
- Limits: collections are local to the file (not importable before K3); `coll_*` params are
  not visible inside imported components; `choice` is project-level only.


## 17. Linked parameters — `solves=` / `inverse=`

A derived parameter can be a slider. A customer asks "how tall?"; the model is driven by "how
many drawers". Mark the derived height with the literal parameter it **solves** and an
**inverse** that turns a requested height into that parameter's input:

```kdl
params {
    drawer_count 4.0 min=2.0 max=5.0 step=1.0 label="Drawer Count" category="Envelope"
    pommel_h "(drawer_count * opening_pitch) + rail_h"
    leg_len "bank_floor_h + pommel_h"
    overall_h "leg_len + top_th" min=650.0 max=2000.0 step=5.0 \
        label="Overall Height" category="Envelope" \
        solves=drawer_count \
        inverse="floor((v - top_th - bank_floor_h - rail_h) / opening_pitch + 0.5)"
}
```

(`examples/filing_cabinet_vertical.kdl`.)

- **`inverse=`** is full CEL, evaluated with the current params in scope plus the reserved
  name `v` — the requested value. `v` is reserved: no parameter may be called `v`. You write
  the tie rule: `floor(x + 0.5)` rounds ties **up** (the builtin `round` is ties-to-even).
- **Several inverses**: `inverse=` pairs with `solves=`; further ones are spelled
  `inverse_for_<param>=` (the target in the key; one per target). The first declared is the
  default; `ww build f.kdl --set total_h=900 --for total_h=other_param` (or
  `--set total_h@other_param=900`) picks another. Each result is snapped to *its own* target's
  `step`/`min`/`max` — a continuous target (e.g. drawer height) is clamped but not rounded to
  an integer unless its own `step` says so.
- **What a request does.** A `--set`/configure/MCP request naming `overall_h` is solved for its
  target, the result is clamped then snapped to the *target's own* `min`/`max`/`step`, and the
  compile builds the target's value. The height you get is what that count makes, reported as
  `requested → resolved` on stderr (and in `scene.resolution` / `ww params --json`).
  Re-requesting the resolved height gives the same count (idempotent).
- **Conflicts are refused.** Requesting a linked param and its target together, or two linked
  params that solve the same target, is `ww::override::conflict`, naming both. There is no
  last-writer-wins.
- **No cycles.** The target must be a *literal* project param (never derived), the derived param
  must depend on it, and the inverse must not read the derived param it sets. A solves cycle
  or a derived target is a Stage 2 error (`ww::eval::link`).
- **`ww check` audits the inverse**: it samples a few requests and warns (`ww::link::inverse`)
  when the chosen input, put back through the forward expression, lands further from the
  request than a neighbouring grid value — a sign the inverse and the forward expression
  disagree.
- **Identity is the inputs**, never the derived param: share links, the compile cache key and
  MCP records carry `drawer_count`, not `overall_h`, so two requests that snap to the same
  count are the same configuration.
- `ww params --json` lists each linked param's `solves` and its reachable `ticks` (the values
  it can actually take; omitted when the target's grid has more than 16 points or is open).
- Not provided in v1: a "best integer" search helper. Write the closed-form inverse.
