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"orz=-90.0. Positive Z is almost always wrong. 0.0 - expris preferred over-exprfor 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 atz="0.0 - top_th".
2. Anchors#
Decision D5. by belongs to the placement, not the component. The world transform is:
M = T(pos) · R(rot) · T(−anchor)
Read it as: "put THIS point on the part at THAT coordinate, having rotated about it."
An optional mirror (§9; RFC-0012, proposal 0202) adds one more factor, outermost:
M = S · T(pos) · R(rot) · T(−anchor). S is the identity unless mirror is written; D5
itself, and every part that does not use mirror, is unchanged.
Available on every part for free:
| Group | Values |
|---|---|
| Top corners | top_sw top_se top_ne top_nw |
| Top edge midpoints | top_n top_s top_e top_w |
| Bottom corners | bottom_sw bottom_se bottom_ne bottom_nw |
| Bottom edge midpoints | bottom_n bottom_s bottom_e bottom_w |
| Face / body centres | top_center bottom_center center |
sw = the (0,0) corner in part-local XY; ne = (x_max, y_max). Default is top_sw.
The anchor is named in the component's own frame, before rotation. top_se always
means the (x_max, 0, 0) corner of that part's stock, whatever rot says. The rotation
then turns the part about that point. It does not re-pick the corner that ends up south-east
in the parent. (Ratified 2026-09-24; see ADR-0009.)
Why this matters: four legs of a table should be four instances of one component, each
placed at the corner it occupies, so that changing width moves them correctly with no
compensating arithmetic. There are two correct ways to write it.
When the leg's faces are all the same, don't rotate it. Anchor each leg by the corner that lands on the table's corner:
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):
| Family | Functions | Example |
|---|---|---|
| Trigonometry | sin(deg) cos(deg) tan(deg) asin(x) acos(x) atan(x) atan2(y, x) | x="center + radius * cos(angle_deg)" |
| Rounding | floor(x) ceil(x) round(x) | count="floor((length - margin) / pitch) + 1.0" |
| Absolute value | abs(x) | clearance="abs(slop)" |
| Extrema | min(a, b) max(a, b) | safe_depth="min(available, max_reach)" |
| Exponentiation | sqrt(x) pow(base, exp) | diagonal="sqrt(x*x + y*y)" |
| Units | inch(x) — inches to millimetres | h="inch(34.5) - toe_kick" |
| Loudspeaker enclosures | ac_sealed_fc(fs_hz, vas_l, vb_l) ac_sealed_qtc(qts, vas_l, vb_l) ac_port_length(vb_l, fb_hz, dv_mm, np) ac_helmholtz_fb(vb_l, dv_mm, lv_mm, np) — litres, hertz, millimetres; the port functions take and return mm, so a result feeds a dimension directly (see §17) | len="ac_port_length(vb, 34.0, 50.0, 1.0)" |
All angles are in degrees, matching the schema's convention (rot z=90.0, tilt=14.0). This eliminates the silent-bug risk from mixed radian/degree arithmetic.
That is the whole language (ADR-0005, addendum). The operators are + - * /, unary
-, == != < <= > >=, && || ! and cond ? a : b (% parses, but CEL's is
integer-only, so it is refused on dimensions); values are numbers, booleans, null and
'text', and + joins text. The rest of CEL is refused with ww::eval::not_in_language or
ww::eval::unknown_name: the map / filter / all / exists / exists_one / has
macros, method calls (x.size()), field selection, list and map literals, indexing, in,
bytes, and CEL's library (size, string, double, matches, timestamp, …). Each
expression is also limited — a file is untrusted input, and these hold the same in the CLI,
the server and the browser:
| Limit | Value | Diagnostic |
|---|---|---|
| Expression source | 4 KiB | ww::eval::expression_too_long |
| Operators and opening brackets in one expression | 128 | ww::eval::expression_too_complex |
| Text any expression builds, including inside it | 4 KiB | ww::eval::text_too_long |
Whole-number arithmetic (3, not 3.0) | ±2^53 | ww::eval::not_in_language |
The largest expression in examples/ is 137 bytes with 14 operators and brackets. If you
reach a limit, split the expression into named parameters.
Whole numbers and reals do not mix inside one expression. Every parameter, repeat
index and property value you can name is a real (f64, ADR-0008), so n * 2.5 always
works. A literal written without a point is a whole number to CEL, and CEL does not
coerce: 2 * 12.5, 3 + 0.5 and (i < 2 ? 1 : 0) * 2.5 all fail — in cut_path points,
dado offsets, pos and every other attribute alike — with every number here is a real,
so write 3.0 and not 3. Write 2.0 * 12.5 and (i < 2 ? 1.0 : 0.0) * 2.5. A whole
number on its own ("2 * 3", "i < 2 ? 1 : 0") is widened at the end and is fine; the
failure is only a whole number meeting a real in one operator. Whole-number division is
integer division (7 / 2 is 3), which is why the language does not quietly promote.
Worked example — a three-legged stool with legs splayed at 120-degree intervals:
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 envelopecomponent X { params { … } }— defaults, overridable at instantiationassembly Y { params { … } }— local working values
Naming and DRY expectations#
snake_casefor parameters,PascalCasefor components, joinery names and assemblies,PascalCase_WithSuffixfor 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.0is 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 conventioncomponent/assemblyalready use for their own name, not aname=property. - A preset body holds bare
name valuelines only — nomin=/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 inparams. 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--setalready goes through: an override naming no project parameter isww::preset::unknown; one targeting a derived parameter isww::preset::derived; a value of the wrong type isww::preset::type; outside the parameter's own declared range isww::preset::range; off itsstep=grid isww::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
presetblock lives inside the oneproject { … }body that declares the parameters it targets, the same scopeparams/materials/componentsalready 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:On its ownjoinery 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_Tophardware_mountcuts nothing (the same asbutt) and counts the item. Say what to cut and it cuts exactly that, every number the author's or the catalogue's:Ajoinery 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 onlyholebores the male part's face; amortise_depthrecesses a leaf on every end the join names. Adistribute [from= to= count= setback=]row places N of them along the mark's axis, count and setback from the catalogue'smax_pitch/setback. A mounted item whose join carries atravelis checked in motion (§8, ADR-0075); no force, spring or wear is modelled. An item withshape=round|turned|bar|bail|custom|leaf|spanand its dimensions (diameter,projection,length,width; a leaf usesleaf_*;spanneeds onlydiameterand a two-endedjoin, drawn between the marks — RFC-0145), and optionallycolor="#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. Seedocs/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@Frontqty=is an integer or a CEL expression (default 1, matchingrepeat … count=);at=is an optionalPart@Markreference, 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 key | rect bounds | runs |
|---|---|---|---|
z (default) | z= | x_min= x_max= y_min= y_max= | z = 0 to z = -size.z (top face to foot) |
x | x= | y_min= y_max= z_min= z_max= | x = 0 to x = size.x (no sign flip: x is already a [0, size.x]-convention axis, unlike z) |
y | y= | x_min= x_max= z_min= z_max= | y = 0 to y = size.y |
Everything else about a rect section — straight between keyframes by default,
interp=spline for a curve through them, the stations must run the whole part, no rectangle
may leave the blank — is unchanged for axis=x/axis=y; only the key names differ.
examples/curved_apron_side_table.kdl is the worked case: an apron whose rect section runs
axis=x along the apron's own length, full height where it tenons into a leg and waisted
thinner at mid-span (interp=spline), the shaped-rail look a flat apron or a two-face taper
cannot give:
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(thesectionfeature's own freedom) are out of scope — a bent member's own length is always its localz, and it is rotated into place at the assembly level like any other component whose own length does not run along worldz(examples/arched_crest_side_chair.kdl's ownrot y=90.0).plane=—"xz"or"yz": which of the cross-section's two in-plane axes bows.xzbows localxand holds localyfixed (the spine's own normal);yzis the same withxandyswapped. There is no third plane: the spine is always planar, not a true 3D curve (out of scope, per the spec).tolerance=orsamples=, mutually exclusive — the same two knobs arectsection's owninterp=splinetakes, with the same meanings (largest face-to-curve gap, or chords per interval).- Exactly one child:
arc radius=<expr>orspline { 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.
| Node | Required | Notes |
|---|---|---|
dado | width depth offset | Across the grain. See Placing a dado or groove below |
groove | width depth offset | With the grain. See Placing a dado or groove below |
rabbet | width depth | Add { edges … }, or face= + edge= for any edge. Stoppable — see Bevel, and a rabbet on any edge below |
chamfer | size | Add { edges … }. Stoppable — see below |
roundover | size | Add { edges … }. Stoppable — see below |
moulding | section size | Add { edges … }. Stoppable — see Moulding profiles below |
bevel | angle | Add { edges … }, or face= + edge=; depth= optional. Stoppable — see Bevel, and a rabbet on any edge below |
taper | start_z end_z target_dim | Add { faces … } |
section | axis | The part's own base solid, not a cut: circle stations (a turning), a drawn profile (faceplate work) or rect stations (a tapered member). See Turned stock in §6 |
cut_path | depth | A closed line/arc/polyline/spline/circle outline, or several chained into a compound path; tilt= heading= kerf= keep= (outside, default, or inside), depth=0.0 is through. See cut_path below |
hole | diameter depth | depth=0.0 is through. length=/axis= elongate it — see below |
lamination | count thickness | Glued-up stock, for benchtops and thick legs |
trim | reference normal at | Add { faces … }. The one feature resolved in world or assembly space, after placement — see below |
veneer | material | Bonds a thin leaf (or several) to a named face — no cut on the host. See Veneer and laminate below |
banding | material | A strip let into a groove round a face's edges, flush, mitred at the corners; generates the filler part. lay=surface, pattern=herringbone, angle=90 (cross-banding). See Banding and stringing below |
stringing | material offset | A fine inlaid line: banding with a 1.5mm width and 2mm depth by default, and the offset required. See Banding and stringing below |
Enum lists are child nodes taking one or more arguments — KDL v2 has no array literals:
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#
| Property | Meaning |
|---|---|
face= | The face it is cut into: pos_x neg_x pos_y neg_y pos_z neg_z. Default: the broad face (+ side of the thinnest dimension). Required on a square section. |
offset= | Where across its width: distance of the cut's low edge from the low end of that axis — 0 for x and y, the bottom face for z. Always positive, always "up from the bottom" / "in from the front or left". |
stopped=#true | Stop the cut instead of running it through both ends. |
start= | Stopped only: where it begins along its run, measured the same way. Default 10mm in. |
run_length= | Stopped only, with start=: how far it runs. Default: out through the far end. |
Which way it runs is not a property. It comes from the grain: a groove runs with the grain and a dado across it, on whichever face you named. A face the grain runs into is end grain, and a cut there is skipped.
dado/groove also accept id= and label= (both strings). They parse, but nothing in the
compiler reads them back yet — no diagnostic, no cut list, no drawing resolves against them.
They are bookkeeping for the author only, not a cross-reference mechanism.
// 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
}
| Segment | Properties |
|---|---|
line | from_x= from_y= to_x= to_y= — a straight run. Never closes by itself (two points bound no area); accepted for completeness, per the proposal, and reported as a skip if used alone. |
arc | center_x= center_y= radius= start_angle= end_angle= (degrees, the same convention as sin(deg)/cos(deg), ADR-0005) segments= (optional, not yet read). A 360° sweep closes as a circle; anything less closes along the chord back to its start, so the region is the circular segment the arc and that chord bound. |
polyline | { point x= y= ; … }, at least two points. The edge from the last point back to the first is implied. |
spline | { point x= y= ; … }, a smooth centripetal Catmull-Rom curve through the points (at least two; three if closed=#true). closed=#true is a whole loop (a blob, a vase profile): it cannot chain with other segments. Otherwise it closes along its chord like a lone arc, or chains end to start in a compound path. tolerance= (default 0.01) is the largest gap in length units between the cut edge and the true curve; samples= instead fixes the chords per span (whole number, at most 1024). The curve is sampled to chords once, deterministically. Consecutive points must differ. |
circle | center_x= center_y= radius=: a whole round, Level 3's derived path. It is a whole loop and cannot chain; an exact two-arc outline, no sampling. |
path <Name> <kind> <properties…> declares a segment once, by name, for cut_path's
path= to reference — the same kinds, written the same way, just with a leading name.
path <Name> { <segment> … } declares a compound path, the same chained children an inline
cut_path takes; cut_path path=<Name> then cuts it. A named path is built where it is
referenced, so it reads the same parameters as the cut_path.
A curved cloud-lift or serpentine edge, one named compound path:
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
}
| Property | Meaning |
|---|---|
reference= | world or assembly (required). world is the document's own origin. assembly is the frame of the part's nearest enclosing instance=-placed assembly — identity (same as world) when the part sits directly in the root or under only inline assembly { } groupings. A named datum plane on an assembly remains unbuilt (ADR-0024, no example needs more than one reference plane per assembly). |
normal= | A direction in reference='s own frame, in D6's six words (pos_x … neg_z) — not a face of this part. It names which side of the plane is kept: the same sense a real surface's own normal points (a floor's points up, away from what standing past it would be). |
at= | Where the plane sits along that direction's own axis, in reference='s own frame: reference=world normal=pos_z at=0.0 is "world z = 0"; reference=assembly normal=pos_z at=0.0 is "the nearest enclosing assembly's own z = 0". A CEL expression, evaluated in the part's own scope like any other feature property — for a raked leg this is almost always a derived parameter (at="0.0 - leg_drop"), not a literal, because the floor is rarely at the reference frame's z = 0. |
faces (child) | Which of the part's own stock faces (D6 face names, part-local) the cut is written from. Pick one whose normal stays close to perpendicular to the plane — see below; a face too oblique (joinery::plane::MAX_LEAN, 60°) is refused, not silently skipped. |
within_normal= / within_at= | Optional, and only together: a second plane, read in the same reference= frame as normal=/at=, that bounds the cut to the wedge where both planes' removed sides meet — a birdsmouth's seat cut, bounded by its heel plane, rather than the whole unbounded half-space of the seat plane alone. Lowers to joinery::plane::beyond_within instead of beyond; refused, not silently skipped, if the cut's own lean runs across this plane rather than along it (the same "too oblique" contract faces keeps). |
A birdsmouth seat, bounded so it stops at the heel rather than running the length of the board:
// 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
| Property | Meaning | Default |
|---|---|---|
face= | D6 face name the leaves bond to. | pos_z |
material= | The veneer's own species (or a type=sheet material, for a laminate) — a bare word, like face=, never a CEL expression. | required |
thickness= | Finished leaf thickness, mm. Real veneer runs roughly 0.6mm (paper-backed) to 3mm (hand-sawn) — pick a realistic one. | 0.6 |
grain_angle= | Degrees, the figure's angle off the host's own length axis. Recorded on the cut list; a book-matched leaf's mirrored half reads at the opposite angle automatically. | 0.0 |
leaves= | How many leaves divide the face, side by side, edge to edge (no gap — real leaves are taped and glued seam to seam). | 1, or 2 once match= is given |
match= | book (alternate leaves mirror about each seam — ADR-0019's mirror, exact, not merely "similar"), slip (leaves repeat, unmirrored) or sunburst (RFC-0141: leaves wedges radiating from a point — below). | none (a single leaf), or slip once leaves is more than 1 |
match=sunburst lays the leaves as a fan instead of side by side: leaves= wedges (default
8) radiate from center_u=/center_v= (face coordinates, default the face's centre) between
start_angle= and end_angle= (degrees from the face's +u, default a full 0 to 360), each
clipped to the face, so the leaves tile it with no gap. Each leaf records its mid-ray as its
grain_angle. A leaf is a convex wedge, so each must span under 180 degrees: two leaves
round a full circle is a named skip. Geometry only — paterae, bellflowers and marquetry are
out of scope.
A leaf follows the part's finished outline, not its blank rectangle: a cut_path keep="inside" outline or a pierced cut_path cut-out on a face parallel to the veneered one,
and a trim or scribed tenon shoulder square to it, cut the leaves exactly as they cut the host
(the seams of a book or slip match are still straight, so a pentagon panel's leaves are the two
halves of the pentagon). Not followed, and said so rather than guessed at: a leaning (tilt=)
cut_path on a parallel face, an outline written on a face square to the veneered one, and
blind cuts; there the leaf keeps the blank rectangle.
A sunburst leaf's blank is cut to the finished leaf's own bounding box, so the stock list
and wasteful_stock see the leaf that is made, not the wedge before the outline clipped it.
Each leaf becomes its own generated part (RFC-0090 §5's machinery, ww_expand::{ PlacedPart::generated, Provenance, GeneratedKind::Veneer }), never written back to the
source (D10) and never named by a join — it is bonded, not joined, so it is expected to
touch the host and never reported as a clash or a gap. The cut list shows each leaf's own
species, thickness, grain angle and match style as its own row (book match's mirrored half
is a distinct row: a different world handedness, the same way a mirrored part already gets
its own row, ADR-0019), and shows the host's own row twice over: its declared
(substrate) L × W × T — "the host part keeps its own dimensions" — is unchanged, and a
separate finished-thickness figure (substrate plus every leaf bonded to it) is printed
alongside it. The viewer needs no special case: a generated part already draws as an
ordinary mesh, and since each leaf is its own separate solid, the edge it shares with its
neighbour draws like any other mesh edge — that shared edge is the seam.
Banding and stringing#
banding and stringing let a strip of contrasting wood flush into a groove a fixed
offset= in from the edges of a face, mitred at the corners. One node does two things from
one set of numbers: it cuts the groove in the host and generates the filler part that
fills it exactly, so the two cannot disagree. stringing is banding with a thin default
section (1.5mm wide, 2mm deep) and a required offset=.
// A 12mm walnut band, 3mm deep, 8mm in from every edge of the top, with a fine holly line
// inside it — and a second line, by `repeat`, at a different offset.
banding face=pos_z material=Walnut width=12.0 depth=3.0 offset=8.0
stringing face=pos_z material=Holly offset=24.0
| Property | Meaning | Default |
|---|---|---|
face= | D6 face name the groove is cut in. | pos_z |
material= | The filler's species. A bare word, never an expression. | required |
width= | The strip's width across, mm. | 12.0 banding, 1.5 stringing |
depth= | Groove depth = filler thickness, mm. Must leave the part some thickness under it. | 3.0 banding, 2.0 stringing |
offset= | Distance from the face's edge to the strip's near side, mm. 0 opens the groove through the side (a flush edge band). | required for stringing, 0 banding |
lay= | inlay (let into a groove) or surface (bonded proud, no groove; thickness= instead of depth=, default 0.6). | inlay |
pattern= | plain or herringbone (each strip split lengthwise into two halves at +angle and -angle). | plain |
angle= | Degrees, the strip's grain off its length. 90 is cross-banding. | 0, or 45 for herringbone |
edges child | { edges pos_x pos_y }, neighbouring face names: banding on those edges only. Strips mitre only where two selected edges meet; the rest end square. | all four edges |
The groove and its filler share one section; the filler sits flush with the face with no
gap, so it touches the host and is never reported as a clash. The groove cutters overlap at
the corners and the filler is the mitred trapezoid; the volumes agree. A strip is an ordinary
generated part (GeneratedKind::{Banding, Stringing}) and shows in the cut list as strip
stock: species, grain angle in its name, L the outer edge's length (what the mitre is cut
from). repeat works (repeat i count=3 { stringing offset="10.0 + i * 6.0" … }). A bad
property, an impossible offset or a depth= that reaches through is a named skip, never
silent. The figure of a herringbone is a grain angle, not geometry.
7b. Naming and reading order#
Verb-like nodes are imperative. join, dado, chamfer, taper, repeat — never
joined, chamfered, repeated. These are the words a setup sheet already uses, and because
most feature names are nouns doing duty as verbs (dado, rabbet, mortise, groove), the
imperative form is identical to the noun. One word for the thing and the instruction.
Imperative mood does not mean imperative semantics. This matters and is easy to misread:
- Features on a part are order-independent. The compiler unions every cutter and performs a
single difference (D12), so
chamferbefore or afterdadogives identical geometry. Do not write a file that depends on feature order — nothing will honour it. joinis 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=andfemale=say which side gets which; the order you write the two properties in is irrelevant.
SQL is the precedent — SELECT is imperative mood in a declarative language.
Write a project in three sections, in this order:
| Section | What goes in it | Compiler stage |
|---|---|---|
| 1. Describe the parts | component — stock, dimensions, and the part's own features | 2–3 |
| 2. Describe placement | part — by, pos, rot | 3 |
| 3. Describe the joins | join | 4–5 |
This is document order, evaluation order, and error-attribution order all at once. Two things it does not make obvious:
- Features belong to section 1, not section 2. A dado is part of a board's intrinsic shape; where the board sits is a separate question.
- Section 1 is intrinsic shape, not final geometry. A join mutates both bodies, so a
leg's mortise will never appear in that leg's own
componentblock. 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#
| Kind | Key params | Level | Cut |
|---|---|---|---|
butt | — | L2 | nothing to cut |
dowel | diameter count depth | L2 | M5 |
pocket_hole | screw_type spacing, opt. face | L2 | M5 |
biscuit / domino | size spacing; domino opt. depth | L2 | M5 |
mortise_and_tenon | tenon_thickness tenon_length clearance, opt. tenon_width haunch centering, opt. through proud end_chamfer pin wedge key | L3 | M6; M13; M15; M32 |
half_lap / cross_lap | depth_ratio (default 0.5) | L3 | M6 |
bridle | cheek_thickness depth | L3 | M6 |
box_joint | pin_count or finger_width, clearance | L4 | M7 |
dovetail_through | slope ("1:8", "1:6") pin_count clearance | L4 | M7 |
dovetail_half_blind | slope pin_count socket_depth clearance | L4 | M7 |
sliding_dovetail | slope housing_depth tongue_thickness clearance, opt. stopped front_gap | L4 | M31 |
round_tenon | diameter length clearance, opt. taper_to | L3 | M24 |
tongue_and_groove | tongue_thickness depth clearance, opt. slot | L5 | RFC-0030 / ADR-0022 |
edge_glue | — (optional biscuit_spacing, biscuit_size) | L5 | M5: biscuits when biscuit_spacing is given |
stile_and_rail | groove_width groove_depth stub_tenon_length profile, opt. profile_size profile_face | L5 | M6: the stub tenon (profile "square"); ADR-0026: stuck profiles with coped rail ends |
miter | angle | L1 | M6 |
mitred_moulding | angle profile, opt. profile_size profile_face | L6 | #460: a mitred corner that also carries a stuck profile (miter's own bisector cut, stile_and_rail's own sticking, applied to both symmetric ends) |
sliding_sleeve | extension clearance, opt. depth | L3 | RFC-0146: a bore in the sleeve and a declared parked pose of the inner member — a pose, not a travel (ADR-0075 does not apply) |
hardware_mount | hardware <Name> (opt. hole …, mortise_depth) | L2 | M27: cuts nothing unless it has a hole or mortise_depth row; female= optional |
A kind not yet cut is declared, resolved and drawn in the viewer, and ww joins, ww check
and the viewer's diagnostics say it is not yet synthesised.
Every kind above that takes clearance also takes fit=slip|snug|press instead (issue
#55): see "Clearance resolution" above for what each gives and the chain that resolves one
when a declaration gives neither.
What the compiler cuts, and what it checks#
A joint is laid out from its male mark, on the stock face that mark sits on and faces.
The female mark names the face it mates with: the two marks must face each other (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—countof them, floored to a whole number with a warning.depthis 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 wholespacingof 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:facenames 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_typeis 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—size0,10or20.domino—sizeis the tenon's thickness,4568or10(the Festool DF 500's),depthinto each part, by default half the tenon's length.- Loose inserts.
dowel,biscuit,dominoand anedge_gluewithbiscuit_spacingalso 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 × depthrevolve; a biscuit is the table's #0/#10/#20 wafer; a domino is the table's tenon, and2 × depthshorter than its length is the errorinsert-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 astockpart: 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 asinsert-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:
fit=slip|snug|press, when written, names which of the female material's owntolerance_slip=/tolerance_snug=/tolerance_press=properties (§5) to use. Leaving bothclearance=andfit=out resolves as iffit=snughad been written — the class nearest theglue_clearance 0.15–0.2convention most examples already use. Giving bothclearance=andfit=on the same declaration is an error: a number and a class cannot both be asked for.- If the female material declares no
tolerance_*for that class (every example, until a material opts in), the project's ownglue_clearanceparam is used if declared. - If neither resolves anything, the declaration behaves as
clearance 0.2— matching the joint dialog's own seed value, and replacing the old silent0.0a declaration with noclearanceat all used to get.
fit/slip/snug/press are woodworking-trade categories, not three different textbook
clearances for every material pair: a material's tolerance_* is itself a shop-practice
choice, to be corrected from real experience, not a derived physical constant. Draft starting
values (mm, not independently sourced — correct them before relying on them):
| Material | tolerance_slip | tolerance_snug | tolerance_press |
|---|---|---|---|
| Hard maple, walnut, white/red oak (hardwood, generally) | 0.25–0.3 | 0.1–0.15 | 0.0 |
| Plywood | 0.3 | 0.2 | 0.0 |
| MDF | 0.35 | 0.25 | 0.0 |
| Hardboard | 0.3 | 0.2 | 0.0 |
| Acrylic / solid surface | 0.15 | 0.05 | 0.0 |
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 astile_and_rail) is clipped to that. A tenon with no end face left to stand on is refused (material), naming the outline. - Zero
glue_clearanceis 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 #trueasserts it — and, withtenon_lengthleft out, computes the length that reaches the far face exactly — so a resize that shortens the female cannot silently turn a through tenon blind. proudandend_chamfer:proudmoves the flush-trim plane out by that much instead of cutting the tenon flush — needs a through tenon.end_chamfercuts the proud end's four arrises at 45° — needsproudgreater than zero, and less than bothproudand a quarter oftenon_thickness.pin(M13, the drawbore's mechanism, RFC-0090):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 displacedpin shape=round size=12.0 draw=1.0 count=2 spacing=30.0drawtoward the shoulder so driving one pin through both pulls the shoulder tight.shapeisround(default) orsquare;sizeis the diameter (round) or the side at the big end (square, required);taper(square only) is 1-in-taperon the end-side face along the pin's own drive;atis how far from the female's mortise face the pin's axis sits (default: half the female's section when through, else halftenon_length);count/spacingspread pins across the tenon's width, centred on the mark;enternames the male-local cheek face the pin is driven from (default: the cheek's positive face);ends_proud,materialandlabelplace and name a generated part the joint makes on its own: the peg or komisen itself, id<male id>@<male mark>/Pin_<k>, appears inScene::partsbeside every declared part — in the cut list, the stock list, on a label, and in the viewer — with nopartormarkwritten for it, and nojoincan name it (it has none).materialdefaults to the male's own;labelto"Peg"(round) or"Pin"(square) and becomes its cut-list title.ww clashallows 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):Needs a through tenon (wedge count=2 kerf=1.5 depth=40.0 flare=1.5 flare_depth=20.0 material=Kashi label="Kusabi"through #true).countsaw kerfs,kerfwide,depthdeep from the tenon's own end, through its whole thickness, atk / (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 byflareover the lastflare_depthof it (wedge-flare:flareat most a quarter offlare_depth, and the flare must not run out of the female's own face).depthmust reach at leastflare_depthand at most three quarters of the tenon's length in the female (wedge-kerf, which also refuses kerfs closer than3 × kerfto each other or to the tenon's own edges).materialandlabelplace and name a generated part per kerf, the same mechanism aspin's: id<male id>@<male mark>/Wedge_<k>,kerfthick at its own tip andkerf + 2 × flare / countat its head, as thick as the tenon and as long asdepth, seated with its head flush with the tenon's own end.materialdefaults to the male's own;labeldefaults to"Wedge".ww clashallows 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. Apinand awedgeon the same declaration must not cross (pin-wedge-clash). Needs the same 90°-turned, square-shouldered framepindoes (wedge-frame, unsupported otherwise).key(M32, the keyed/tusk tenon, RFC-0125):Needs a through tenon standing well proud (key size=16.0 thickness=8.0 taper=10.0 draw=1.5 ends_proud=4.0 material=Wenge label="Key"through #true,proudlarge 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 sitsdrawmm 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 exceedsize(key-mortise) for a tapered key to have room to seat rather than bind at once.size,thicknessandtaper(1-in-taper, along the key's own length) are required;drawdefaults to1.5mm ("about 1–2 mm" is the shop range, RFC-0125 §3);ends_proud(default0.0) stands the key proud of the tenon's own width on both ends.materialandlabelplace and name a generated part : id<male id>@<male mark>/Key_0,size × thickness × (tenon width + 2 × ends_proud), tapered on one face fromsizeat 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".materialdefaults to the male's own;labeldefaults to"Key".ww clashallows the key to overlap the tenon by the design interference the draw declares — the sliverdrawwide at the head, tapering to nothing along the key's own length — without calling that a clash. Needs the same square, axis-aligned framepinandwedgedo (key-frame, unsupported otherwise).haunch length= depth=: thelengthof the tenon's width toward the nearer end of the mortised part stands onlydepthoff 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 needstilt=/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_widthoff 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 writetip 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 notenons-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). Onlymitreis 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 nominaldiameter, its widest point, whether or not it tapers — a straight bore is never reamed to follow a taper.taper_totherefore 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) bywedgeon 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
throughround 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_tonot less thandiameter, 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.kdldoes it withrabbet 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_countis the male's fingers:2 × pin_count − 1equal positions, both outer ones the male's. Or givefinger_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)"indesk.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_countcounts 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 ofpin_count, a graduated schedule — the same evenly-spaced centres, each tail's own root width given explicitly instead of shared:onejoinery 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)" } }pinper tail (pin_count − 1rows) — givepin_countor a schedule, not both. A through dovetail's tails are as long as the pin board is thick; a half-blind's aresocket_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 adado/groove's ownoffset=/start=, these are read as offsets from the joint's own low end — the same zero apin index=row's position already is — not the avoided feature's own frame; reuse a feature's run-axis property (adado'sstart=, not its width-axisoffset=) to land in the same axis the dovetail's own layout uses. At most oneavoidper joint;on=maleis the only side built (on=female, a zone on the pin board instead, is refused asunsupported). A tail whose root overlaps the zone but whose own centre does not is fixable — narrow that tail'spinrow. A tail whose fixed centre sits inside the zone is not fixable by any schedule at thatpin_count—avoidvalidates a layout, it does not write one.- Clearance grows each socket by
clearance / 2along the joint and on its closed side, and a half-blind socket's floorclearance / 2deeper.
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 = extentrule 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,lengthoverall along the run (diameterplus the travel either side), centred on the male mark,atout of the shoulder (defaultdepth / 2). Put it on the boards that move; a glued board takes a secondjoinerywithout 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
parthas eitherinstance=or an inlinestock— never both. posandrottakex=y=z=properties. Omitted axes are0.0. Rotations are degrees, applied about the anchor.mirrortakes exactly one ofx=y=z=: the value is a plane<axis> = <value>, in the frameposis written in (the enclosing assembly's), that the whole placement —by,posandrot, 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 sameby/pos/rotplusmirror, not arotplus overridden offsets. The solid stays watertight with positive volume (the kernel re-winds a negative-determinant transform, ADR-0008's postscript); marks and theirnormal=reflect with the part, so ajoinnaming 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>to0 .. n-1, as a real (never a CEL int — ordinary float arithmetic over the index,i / 2.0, always works). Useid="Name_{var}"to keep part IDs unique — required, or the IDs collide.pos,rotandmirrorare three nodes: one per line, or;-separated. KDL ends a node at a newline or a;, not at the next word, sopos x=10.0 y=0.0 mirror x="w / 2.0"is oneposcarrying the stray argumentmirrorand a secondx=. That is refused (ww::expand::stray_argument,ww::expand::duplicate_axis) rather than silently moving the part. Writepos x=10.0 y=0.0; mirror x="w / 2.0"or putmirroron the next line.- Assemblies nest. Nested transforms compose.
mirroris 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 anypart, instantiated or inline: overrides its material's own default for the cut sheet. Not valid onpart 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 — everymarkorid=-bearing feature must interpolate the loop variable (either delimiter form,{i}or<i>— ADR-0086), exactly as apart id=must (kdl-checkenforces both). Amarkinside therepeatgenerates one mark per iteration (mark Slot_<i> x=… normal=…, or the quotedmark "Slot_{i}"spelling), and ajoinmay name it by interpolating the same way (female=Board@Slot_<i>, bare, orfemale="Board@Slot_{i}", quoted). - Inside a
joinery, the body holds a schedule record —pinfor a graduated dovetail ,pegfor a drawbore — one row per iteration, consumed by the kind's synthesiser in place of a flat count. Nothing here is independentlyjoin-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=4is 4 compartments, 3 internal cross-walls.fit_clearance=is a per-side clearance gap, subtracted frominner_width/inner_lengthbefore anything else is computed —0.0for 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-linex(1..cols-1), within row-bandy(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 betweenfrom_x/to_x, in every row-band fully insidefrom_y/to_y; every horizontal wall-line strictly betweenfrom_y/to_y, in every column-band fully insidefrom_x/to_x. Sugar over the same single-coordinate mechanism, not a second one — it just saves writing oneomitper wall in a large merged region.
- Single-coordinate,
latticegenerates the border frame (four butt-jointed pieces) and every internal bar as ordinarypart/joinnodes — nothing here is inspectable before expansion, butww params/ww checksee 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 ownAnchor_{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
joinwith no matching planar contact was already reported (the BOM'sglue 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
joinat 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#
- 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.
- Comment the joinery reasoning, not the syntax.
// short grain here, so screws not a tenonis useful;// set the positionis not. - 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.
- Grain direction is structural. It is the fifth, optional argument of
stock—stock Oak 23.0 44.0 232.0 crosswise— and defaults tolengthwise, along the member's longest dimension. State it explicitly on parts where it decides the joint.lengthwiseandcrosswiseare 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 withalong_x,along_yoralong_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 atype=sheetmaterial is an error, not a nicety: writestock Birch_Ply 18.0 500.0 700.0with 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. - Sheet goods get
finished_th, and joinery must reference it, not the nominal. - 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. - Header comment on every file: what the piece is, its overall size, and which joints it exercises.
13. Limits#
The compiler runs files it did not write — on the server and in a visitor's browser — so
one project file may ask it for only so much. Each limit is a counter inside the compiler,
not a timeout, so a file over one fails with the same diagnostic, at the same line, in the
CLI, on the server and in the browser. Every file in examples/ is at least 6× inside
every limit; crates/ww-build/tests/budgets.rs measures it.
| Limit | Value | What counts | Largest in examples/ |
|---|---|---|---|
| Source size | 256 KiB | Bytes in the file, comments included | 41 KB (welsh_dresser) |
| Nesting depth | 64 | { … } blocks, with project as level 1; and assemblies, from a root down through every nested assembly block and every sub-assembly placed with part X instance=SomeAssembly | 6 (desk) |
| Parts | 2,000 | Parts the project expands to, with every repeat unrolled and every sub-assembly instance counted as the parts it places. Nested repeat counts multiply: repeat 64 inside repeat 64 is 4,096, refused before the inner loop is unrolled | 42 (welsh_dresser) |
| Features per part | 1,000 | Features on one part (dado, hole, …) with every repeat in its body unrolled; nested counts multiply here too | 13 (workbench) |
| Imports | 32 | Distinct files pulled in by one compile, std: libraries included | 2 (imports/two_banks) |
| Import depth | 8 | import chain length: file imports file imports file | 2 (imports/two_banks) |
| Specialisations | 256 | Distinct (assembly, override set) pairs: each instance= of an assembly with a different set of overrides | 2 (imports/two_banks) |
One repeat is also capped at 4,096 iterations by itself; the parts limit is the one that
bounds loops inside loops. There is no limit yet on triangles or on the size of the
compiled scene.
Known model limits#
Not budgets: places where the model is narrower than the woodworking, so a correct-looking result can still be wrong. Each is a refusal or a blind spot you can design around.
- A pin needs both parts axis-aligned.
pin(RFC-0090, drawbore and through-pin) takes the direction the pin runs — the male'sentercheek 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 anunsupportedpin-framediagnostic ("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,rotby anything but a multiple of 90°) is refused, as is a female that is itself rotated off the male's axes.wedgehas 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=Namebrings the file's exportedassembly(orcomponent) in asAlias(orAlias.Exportwhen 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 marksexport=is visible; everything else stays private to it.from=is relative to the importing file's directory.std:draweris a built-in library (DrawerBox: box-jointed Baltic-birch drawer box; paramswidthdepthheight, outside size, origin at the top face's south-west corner). No file access is needed forstd:, 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'sbase, 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, isww::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 isimport_too_deep/too_many_imports(§13); an unknown export name isunknown_export. - Hosts. The CLI reads files inside the entry file's directory only (no absolute paths,
no
..,.kdlonly). The browser/JSON request supplies afilesmap ofkey → 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:15in the CLI, and{"file": "lib/bad.kdl", "line": 7}in the JSON diagnostics (fileis absent for the entry file). - Known limits: computed values cannot be exported (only assemblies and components; ADR-0020
addendum C says why);
wwauthor 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=referencemarks 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 apartis the display name shown in the viewer and scene JSON; the part id is unchanged.color="#RRGGBB"on amaterialtints its parts in the scene (viewer and JSON only); on atype=fabricmaterial 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=Nameselects the collection in force. There is no default.extends=Basemerges the chain root-first. A derived collection may only override keys the base declares (ww::dsl::collection_unknown_key); a cycle iscollection_cycle; an unknown name isunknown_collection.- Params appear to every unit as
coll_<name>;base_his read ascoll_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 withrepeat _ count=coll_construction_is_face_frame { … }. Anything else isww::dsl::bad_choice.- Slots are declared once in the base as
name kind Target [requires="p1 p2"]where kind ismaterial,hardware,componentorassembly; a derived collection rebinds withname 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 isww::dsl::slot_contract, reported at the slot line. - Using a slot. Write
@nameanywhere a material, hardware, component or assembly name goes:stock @carcass …,part Door instance=@door {…},hardware_use @pull qty=1, joinery andfinish.@xwith no collection in force, or naming no slot, isww::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;choiceis 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 namev— the requested value.vis reserved: no parameter may be calledv. You write the tie rule:floor(x + 0.5)rounds ties up (the builtinroundis ties-to-even).- Several inverses:
inverse=pairs withsolves=; further ones are spelledinverse_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'sstep/min/max— a continuous target (e.g. drawer height) is clamped but not rounded to an integer unless its ownstepsays so. - What a request does. A
--set/configure/MCP request namingoverall_his solved for its target, the result is clamped then snapped to the target's ownmin/max/step, and the compile builds the target's value. The height you get is what that count makes, reported asrequested → resolvedon stderr (and inscene.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 checkaudits 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, notoverall_h, so two requests that snap to the same count are the same configuration. ww params --jsonlists each linked param'ssolvesand its reachableticks(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.