New Project

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.

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:

GroupValues
Top cornerstop_sw top_se top_ne top_nw
Top edge midpointstop_n top_s top_e top_w
Bottom cornersbottom_sw bottom_se bottom_ne bottom_nw
Bottom edge midpointsbottom_n bottom_s bottom_e bottom_w
Face / body centrestop_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:

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:

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#

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.

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".

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):

FamilyFunctionsExample
Trigonometrysin(deg) cos(deg) tan(deg) asin(x) acos(x) atan(x) atan2(y, x)x="center + radius * cos(angle_deg)"
Roundingfloor(x) ceil(x) round(x)count="floor((length - margin) / pitch) + 1.0"
Absolute valueabs(x)clearance="abs(slop)"
Extremamin(a, b) max(a, b)safe_depth="min(available, max_reach)"
Exponentiationsqrt(x) pow(base, exp)diagonal="sqrt(x*x + y*y)"
Unitsinch(x) — inches to millimetresh="inch(34.5) - toe_kick"
Loudspeaker enclosuresac_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:

LimitValueDiagnostic
Expression source4 KiBww::eval::expression_too_long
Operators and opening brackets in one expression128ww::eval::expression_too_complex
Text any expression builds, including inside it4 KiBww::eval::text_too_long
Whole-number arithmetic (3, not 3.0)±2^53ww::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:

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:

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:

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:

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#

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.

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.

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).

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:
    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:
    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):
    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#

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.

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:

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.

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.

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 }
}
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 keyrect boundsruns
z (default)z=x_min= x_max= y_min= y_max=z = 0 to z = -size.z (top face to foot)
xx=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)
yy=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:

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.

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 bended component exposes exactly the same two end FaceFrames (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. joining 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.

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.

NodeRequiredNotes
dadowidth depth offsetAcross the grain. See Placing a dado or groove below
groovewidth depth offsetWith the grain. See Placing a dado or groove below
rabbetwidth depthAdd { edges … }, or face= + edge= for any edge. Stoppable — see Bevel, and a rabbet on any edge below
chamfersizeAdd { edges … }. Stoppable — see below
roundoversizeAdd { edges … }. Stoppable — see below
mouldingsection sizeAdd { edges … }. Stoppable — see Moulding profiles below
bevelangleAdd { edges … }, or face= + edge=; depth= optional. Stoppable — see Bevel, and a rabbet on any edge below
taperstart_z end_z target_dimAdd { faces … }
sectionaxisThe 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_pathdepthA 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
holediameter depthdepth=0.0 is through. length=/axis= elongate it — see below
laminationcount thicknessGlued-up stock, for benchtops and thick legs
trimreference normal atAdd { faces … }. The one feature resolved in world or assembly space, after placement — see below
veneermaterialBonds a thin leaf (or several) to a named face — no cut on the host. See Veneer and laminate below
bandingmaterialA 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
stringingmaterial offsetA 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:

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:

// 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:

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.

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.

// 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.

// 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#

PropertyMeaning
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=#trueStop 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.

// 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:

// 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:

// 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
}
SegmentProperties
linefrom_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.
arccenter_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.
circlecenter_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:

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.

// 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
}
PropertyMeaning
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:

// 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.

// 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
PropertyMeaningDefault
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=.

// 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
PropertyMeaningDefault
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:

SectionWhat goes in itCompiler stage
1. Describe the partscomponent — stock, dimensions, and the part's own features2–3
2. Describe placementpart — by, pos, rot3
3. Describe the joinsjoin4–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:

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:

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):

// 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.

// 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:

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#

KindKey paramsLevelCut
butt—L2nothing to cut
doweldiameter count depthL2M5
pocket_holescrew_type spacing, opt. faceL2M5
biscuit / dominosize spacing; domino opt. depthL2M5
mortise_and_tenontenon_thickness tenon_length clearance, opt. tenon_width haunch centering, opt. through proud end_chamfer pin wedge keyL3M6; M13; M15; M32
half_lap / cross_lapdepth_ratio (default 0.5)L3M6
bridlecheek_thickness depthL3M6
box_jointpin_count or finger_width, clearanceL4M7
dovetail_throughslope ("1:8", "1:6") pin_count clearanceL4M7
dovetail_half_blindslope pin_count socket_depth clearanceL4M7
sliding_dovetailslope housing_depth tongue_thickness clearance, opt. stopped front_gapL4M31
round_tenondiameter length clearance, opt. taper_toL3M24
tongue_and_groovetongue_thickness depth clearance, opt. slotL5RFC-0030 / ADR-0022
edge_glue— (optional biscuit_spacing, biscuit_size)L5M5: biscuits when biscuit_spacing is given
stile_and_railgroove_width groove_depth stub_tenon_length profile, opt. profile_size profile_faceL5M6: the stub tenon (profile "square"); ADR-0026: stuck profiles with coped rail ends
miterangleL1M6
mitred_mouldingangle profile, opt. profile_size profile_faceL6#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_sleeveextension clearance, opt. depthL3RFC-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_mounthardware <Name> (opt. hole …, mortise_depth)L2M27: 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 (normals 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 marks 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):

Materialtolerance_sliptolerance_snugtolerance_press
Hard maple, walnut, white/red oak (hardwood, generally)0.25–0.30.1–0.150.0
Plywood0.30.20.0
MDF0.350.250.0
Hardboard0.30.20.0
Acrylic / solid surface0.150.050.0
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:

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):
    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):
    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):
    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.

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 RuledCuts: 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.
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:
    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.

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.

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:

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#

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:

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.

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:

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:

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 joins, 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:

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.

LimitValueWhat countsLargest in examples/
Source size256 KiBBytes in the file, comments included41 KB (welsh_dresser)
Nesting depth64{ … } 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=SomeAssembly6 (desk)
Parts2,000Parts 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 unrolled42 (welsh_dresser)
Features per part1,000Features on one part (dado, hole, …) with every repeat in its body unrolled; nested counts multiply here too13 (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#

// 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=#

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.

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:

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.

Settings

Region

A region picks sensible defaults; change any of these separately.

Units

In inches, type lengths the way you say them: 34 1/2, 3/4", 1'-2" or 12.7mm. A length that can't be read is flagged, never rounded to something else.

Paper
Date order
Clock