Skip to content

Anchors

This page as Markdown/docs/schema/anchors.mdView .md

Parts are never placed by absolute transform. Every part exposes named anchors, and a joint mates anchor A to anchor B. There is no escape hatch, and no at: [x, y, z] on a part.

Read this page before writing a joint. It is the concept that a model composing YAML gets wrong most often, and the failure is the quiet kind: the file validates, the geometry builds, and the piece of furniture is simply incorrect.

Absolute coordinates encode an answer — where the rail ended up — instead of the reason: the rail’s end sits against the post’s inner face. When the mattress size changes, the reason still holds and the answer does not. A design written in coordinates has to be recomputed everywhere; a design written in anchors recomputes itself.

It also removes an entire failure class. A wrong anchor path is caught in milliseconds with a JSON Pointer at the line that wrote it. A plausible-looking wrong coordinate is caught by someone in a workshop.

Every blank is a box centred on its own origin:

+Z thickness length +X ±length/2
│ ┌───────────┐ width +Y ±width/2
│ ╱ ╱│ thickness +Z ±thickness/2
│ ┌───────────┐ │
│ │ │ │ The origin is the centre of the
───────┼─│─────── ───│─┼── +X box, not a corner.
╱│ │ │ ╱ length
╱ │ └───────────┘
+Y ╱
width
Face Direction On a board
start −X One end, along the length
end +X The other end
left −Y One long edge
right +Y The other long edge
bottom −Z One broad face
top +Z The other broad face

start and end are the ends of a board — the end-grain faces on a solid part. top and bottom are the broad faces. These names are about the part’s own axes, not about how it ends up sitting in the room: a post standing vertically still has its start at the bottom, because that is the low end of its length.

An edge is named for the two faces that meet at it, and the order does not matter — top_left and left_top are the same edge, and both resolve to the canonical top_left.

top_left top_right top_start top_end
bottom_left bottom_right bottom_start bottom_end
left_start left_end right_start right_end

Opposite faces never meet, so there is no top_bottom.

Wildcards work in edge selectors, which is how chamfers and roundovers are usually written:

Selector Selects
top_* The four edges of the top face
*_start The four edges around the start end
end_* The four edges around the end end
* All twelve
features:
- kind: chamfer
edges: [end_*]
size: 3
<part>[@<instance>]:<kind>.<name>[.<qualifier>]
side_rail:face.start # a part with no instances
side_rail@left:face.start # one named instance
head_post@left:feature.panel_slot # a named feature on an instance
slat_cleat@left:edge.top_start
centre_rail:point.center
centre_rail:center # shorthand — a bare name means point.center
Kind Names What the frame is
face top bottom left right start end The centre of that face, normal pointing out
edge top_left, … The midpoint of the edge, normal bisecting the two faces
feature a feature’s id The feature’s own frame — see below
point center (or centre) The part’s origin
axis length width thickness The origin, normal along that axis

A malformed or unknown path is RES-007, and the finding lists what the part does have.

A named feature exposes an anchor at its own frame, sitting in the recess with its normal pointing out of the part. Mating to it seats the neighbour in the recess rather than on the surface:

parts:
- id: head_post
features:
- kind: groove
id: panel_slot # ← the id is what makes it mateable
on: right
along: length
width: 18
depth: 10
center: true
stopped: 40
joints:
- id: panel_to_head_post_left
kind: dado
mate:
a: head_post@left:feature.panel_slot
b: headboard_panel:face.start

The point of that indirection: the groove and the panel that lives in it cannot drift apart. Move the groove and the panel follows. Positioning the panel with an offset and cutting the groove independently is exactly the quiet error this model exists to prevent.

Grooves, dados, rabbets, mortises, pockets and holes can be mated to. A chamfer cannot — it has no frame to sit in.

A point and a normal leave the roll about that normal free. One unconstrained degree of freedom is precisely what an author would otherwise have to pin down with a coordinate, which would put the escape hatch back.

So every anchor carries a secondary axis as well, and a single mate is a complete placement. The convention is mechanical:

The secondary axis is +Z, except on the faces whose normal is already ±Z — top and bottom — where it is +X.

When a part arrives rotated the wrong way about the mate normal, that is the secondary axis disagreeing with you, and align.roll overrides it. roll in 90° steps is normal and expected — it is not a sign you have done something wrong.

Holes and pockets are positioned in a face’s own 2D frame, measured from the face’s minimum corner, so both numbers are positive and read the way a person would measure them with a tape.

Face u runs along v runs along
top, bottom length width
left, right length thickness
start, end width thickness
- kind: hole
on: top
diameter: 8
depth: 12
at: { u: 100, v: center }

u and v always run along the positive part axis. That means the triad (u, v, normal) is right-handed on some faces and left-handed on others. It is a deliberate trade: predictable authored numbers matter more here than a consistent handedness nobody writing a design would notice.

A part with instances named exactly left and right is mirrored about the design’s YZ plane. Mirroring carries the faces and features with it, and there are two consequences to hold onto.

The inner face swaps sides. The inner face of the left post is its right face; the inner face of the right post is its left face. No {i} substitution can express that, which is why rail-to-post joints are normally written out twice rather than with for_each:

- id: rail_to_head_post_left
mate: { a: side_rail@left:face.start, b: head_post@left:face.top }
- id: rail_to_head_post_right
mate: { a: side_rail@right:face.start, b: head_post@right:face.bottom }

Features arrive flipped. The groove cut on the right face of the left post is on the right face of the right post too — but upside down. A panel mating into both goes in backwards on one side until you turn it over:

- id: panel_to_head_post_right
mate:
a: head_post@right:feature.panel_slot
b: headboard_panel:face.end
align:
roll: 180 # ← the mirror, undone

If a mirrored part comes out inverted, roll: 180 is almost always the fix.

fcad inspect <file> prints where every part ended up — world bounds and transform, per instance. It is the fastest way to find out whether an anchor said what you meant, and it needs no CAD kernel:

fcad inspect fixtures/bed-queen/design.yaml

In the app, the workbench’s isolate and dimensions controls do the same job visually.