loft grammar cheat-sheet (v0.4)
One statement per line. # starts a comment. Blank lines are ignored.
Statement order across the file never matters — references resolve across the whole file. Within a line, slot order is fixed: category → type → [id] → placement (see Line anatomy).
A model may span multiple .loft files: every file in a project directory compiles as ONE model with ONE namespace — reference names across files bare, with no import lines, and file layout never changes meaning (folders navigate, never scope).
Start here — build in this order
Everything below this section is reference. This section is method. Most broken models are correctly-spelled statements written in the wrong order and measured from the wrong thing.
# 1. datums first — the framework everything else is measured from level L1 elev 0' height 10'-0" grid A vertical at 0' grid B vertical at A + 24'-0" # the bay width, not grid B's coordinate grid C vertical at B + 30'-0" grid 1 horizontal at 0' grid 2 horizontal at 1 + 20'-0" # 2. then hosts — walls today (floors and roofs later); frame first, then the line # traveling (A,1) -> (C,1), heading east; right = south wall EXT-1 W1 from (A, 1) to (C, 1) justify left # 3. then what they host — a typed product, placed along the host off a datum door 3070 D1 in W1 at 4' past B # the 3070 doortype states the size once, below
- Datums first. Declare levels and grids before anything measured from them. Order doesn't matter to the parser, but it matters to you: an element you can't anchor is a datum you haven't declared yet
- Types declare facts once; instances reference a type and add only what it delegates. A door's size is a fact about the *product* — state it in a
doortype, and every placement follows. One rule, every category - Then hosts, then what they host. Walls carry doors and windows (floors and roofs will carry their own). An opening names its host and a station along it, so the host must exist as a decision before the opening can be placed — this is the order the model depends in, whatever order you type
- Relative by default; absolute is the last resort. Prefer, in this order: a declared datum (
(A, 1)) → an expression off one (A + 14') → another element's datum (W1.end,D1.jamb.far) → a bare literal. A literal is the right answer only where the number has no reference — an origin, or a free choice - Say the measurement, not the coordinate.
grid C vertical at B + 30'-0"states a bay width;at 54'-0"states a position and discards why it sits there. Widen the first bay and grid C follows on its own — the coordinate form leaves every later number to be recomputed by hand - One travel frame governs everything. A wall's own from → to direction decides its left from its right — which face
justifynames, which way layers stack, which sideswinghinges on, and whereat 0'starts for its openings. Choose the direction, state it in a comment, then write the line - When travel and measurement disagree, anchor to a datum. Closing a perimeter often forces a wall to run opposite the direction you'd naturally measure along it. Rather than counting backwards from that wall's start, place its openings off a grid:
at 4' past 2 - Check your work with the plan's `dims` button — it cycles
stated→measured→off. stated draws only the facts your statements assert: a grid dimensioned from the anchor you chained it off, a carrier's offset, an opening's position. measured draws the adjacent-pair chain a paper drawing would show, from resolved positions. If the stated view comes up nearly empty, you dimensioned in coordinates — there are no relationships in the model for it to draw
Line anatomy — how a declaration reads
Every declaration is category → type → [id] → placement, with a slot dropped where the category doesn't carry it. One of the two leading name slots is always required, so the first name after the keyword is never ambiguous.
# element: the type leads and is REQUIRED; the id follows, optional wall EXT-1 W1 from (A, 1) to (C, 1) justify left wall STUD4 from 1 to 2 on A # anonymous — id omitted, type still leads # datum: no type — the REQUIRED id leads (a datum exists to be referenced) level L1 elev 0' height 10'-0" grid A vertical at 0' # door/window: the type leads here too (and is required); the id follows, optional door 3070 D1 in W1 at 4' past B # the 3070 doortype states the size once, below # opening: the type-less cutter — a hole has no product identity, so its # dimensions live on the line; id optional, like walls opening O1 in W1 at 14' width 3' height 7' sill 4'
- Read the element form as a noun phrase:
wall EXT-1 W1= "an EXT-1 wall, called W1" — the type says what it's made of, the id names it, the rest places it - Declare an element id only when something will reference it — host an opening, anchor a chain (
W1.end), take a carrier offset. Datums always carry their id - The v0.1 trailing spelling (
wall W1 from … type EXT-1) is retired — the parser recognizes it and answers with the corrected line
Lengths
Always carry units. Bare numbers are never lengths.
12' 8" 12'-6" 12'-6 1/2" 27.5' 4.5" 5/8" 3-5/8" 12ft 8in- Word suffixes (
12ft,8in) are accepted on input; canonical output prints the symbol forms - Rejected near-misses:
12'6"(the hyphen is mandatory — write 12'-6") ·1/2'(no fractional feet — write 6") ·12(bare numbers are never lengths — write 12')
Coordinates
(x, y), plan view: positive X is right (east), positive Y is up the page (north).
Each slot is a length literal or a position expression over datums:
(12', 28') (A, 1) (A + 14', 1) (B - 4', 2) (A + (B - A)/2, 1)- A bare token in a coordinate slot is always a datum reference (a grid name), never a number.
Expressions
Anywhere a position or distance goes, a static expression works: + - * /, parentheses, datum references.
grid A.5 vertical at A + (B - A)/2 # midpoint between grids A and B grid D vertical at C + 12' # chained off C door 3070 D4 in W1 at (2 - 1)/2 # centered: half the 1-to-2 span
- Declared vs projected: grids and levels are *declared* datums — they get their own statements; elements *project* the rest (
W1.end,D1.hinge). Both are named datums. An expression likeA - 5'is a position, not a datum — nothing can reference it later; where a slot demands a named datum, declare one there:grid A.1 vertical at A - 5' - A datum reference is a position; two same-axis positions subtract into a distance; positions never add, multiply, or divide
- Bare numbers only multiply or divide — a bare number is never a length
- An offset needs an anchor: write
A + (B - A)/2, not(B - A)/2 - Space the minus between names:
(B - A)/2, never(B-A)/2— hyphens are name characters (as inEXT-1) - Cycles between datum positions are errors
- Qualified names (
grid.2,level.L1) are always legal and settle any ambiguity
Project & units
project "Sample Project" # optional units imperial # optional; imperial is the v0 default
Level
Exactly one level in v0:
level L1 elev 0' height 10'-0"Grids
grid A vertical at 0' grid 3 horizontal at 28'-0" grid B.5 vertical at B + 12'-6"
vertical= line of constant Xhorizontal= line of constant Y- Dotted names are fine (
A.5)
Wall types
walltype STUD4 width 4.5" note "3-5/8 stud + gyp ea side" walltype EXT-1 layers [brick 4", air 1", core: stud 6", gyp 5/8"]
- Layers stack left → right of the wall's travel (
flipmirrors them) core:marks the structural layer
Door & window types
The category requires; the type binds. Every door/window type must satisfy width and height — by fixing a value (stated once, every placement follows) or delegating it (the word instance: each placement supplies it).
doortype 3070 width 3'-0" height 7'-0" # fixed: a product fact, stated once doortype STOREFRONT width instance height 8'-0" # width delegated to each placement windowtype W-5040 width 5'-0" height 4'-0" doortype HM-A width 3'-0" height 7'-0" u-fire-rating 90 u-hardware instance text required
- Rhymes with
walltype: keyword, type name, parameter pairs. Type names are project-unique per category - Supplying a fixed parameter on a placement is an error naming the type and its value — duplicate the type to change it. Omitting a delegated
width/heightis an error too: the placement owes what the type left open u-<name>adds user parameters — job-specific data that rides the format (typed from the literal: length, number,true/false, or quoted text; a delegated one states its type:u-hardware instance text). One name has ONE value type within its category — u- vocabulary belongs to the component, so a windowtype may type the same name differently than a doortype- A delegated u- parameter is optional unless the type adds trailing
required— then every placement must supply the pair, same error as a missing delegated width swing,reverse, and windowsillstay on placements — per-placement facts, never product facts
Walls
A wall's line is stated one of two ways — by its endpoints, or by a carrier it rides plus two extents that cut it. Both are first-class; pick whichever states the facts you already know.
Carrier + extents is not a partition trick — an exterior wall running along a grid is the case it fits best. It is how a line-based element is placed, and walls are simply the only one so far.
# by endpoints wall EXT-1 W1 from (A, 1) to (C, 1) justify left # by carrier + extents — the carrier fixes the line, two transverse datums cut it wall EXT-1 W2 from 1 to 2 on A justify left # ON grid A — no offset, no side wall STUD4 W7 from 1 to 2 at 4' right of W2 # held 4' off wall W2 wall STUD4 from 1 to W5.end at 9' left of B # grid carrier; a wall's line datum cuts too
- Order is category, type, id, placement —
wall EXT-1 W1 from …reads "an EXT-1 wall, called W1" (and rhymes with itswalltype EXT-1declaration) - id (
W1) optional — required only to host doors/windows justify:centerline(default) |left|right— which face of the wall the drawn line is, in YOUR from → to direction (you typed it; you already know left from right)- Everything is in the travel frame: layers stack left → right, door arcs open right;
flipmirrors that content, never the body - Draw perimeters clockwise with
justify leftand the outside faces out - Carrier —
on <ref>puts the location line ON a declared reference plane andjustifyplaces the body;at <offset> <side> of <ref>holds it off one instead. A wall carrier always needs both a side and an offset (on W3is an error) - Extents — any two transverse datums: grids, crossing walls, or wall line datums (
W5.end) left/rightare the frame of the wall THIS statement declares — its own from → to travel — never the carrier's- Derive the frame in a comment before picking a side —
# traveling W4→W5 (east), right = south— and the side words stop biting - A bare wall carrier measures from its centerline; the wall must sit clearly on the stated side (zero or straddling offsets are errors)
- State each fact once: edit the offset and the wall, its openings, and its dimensions all move together
- Optional:
height 9'(defaults to the level height)
Element datums (anchors & accessors)
Every element projects datums you can reference — <id>.<datum>, kind-qualified when ambiguous (wall.W1.end).
wall EXT-1 W4 from W1.end to (C, 2) # chain: W4 follows W1 wall STUD4 W5 from (A + 12', 1) to (A + 12', 2) top W1.top door 3070 D5 in W1 at 6' past D1.jamb # measured from D1's jamb, not its center
- Projection table — wall:
start · end · centerline · top · base| door:centerline · head · jamb+hinge/strike(swinging leaf) | window:centerline · head · jamb · sill| opening:centerline · head · jamb(the base interface, nothing more) - Line datums (
W1.end) fill a whole coordinate slot; plane datums (W1.centerline,W1.top) join expressions on their axis; opening datums are stations — reference them in directed measurements (past D1.jamb) jamb.near/jamb.farare framed by the host wall — near toward its start, far toward its end (the originatmeasures from). Stable in any direction, soat 2' past D1.jamb.farreads as it sounds- Bare
jambis whichever jamb the measurement reaches first — the one nearer the reference you measured from. Swappastforshort ofand it changes ends, which is why barejambalways needs a direction word - Opening datums are stations (lengths from the wall's start), so they do arithmetic in that wall's own
at:door 3070 D9 in W1 at D1.jamb.far + 2' - Flip the swing and the jambs don't move; rehang the door and
hingefollows - Guardrail: anchor to declared datums (grids, levels) by default, to elements by exception — chains state genuinely relative intent, never a substitute for gridding
- Driver → driven, one direction: W2 follows W1, W1 never feels W2; cycles are errors
Doors
door 3070 D1 in W1 at 4' past B # the 3070 doortype states the size once, below door 3070 D2 in W5 at 2' past 1 swing left door 3070 D3 in W5 at 3' short of 2 door STOREFRONT D6 in W1 at 20' width 6'-8" # STOREFRONT delegates width; the placement supplies it
- The size lives on the
doortype, not the line (see Door & window types) — a parameter pair is legal on a placement only when the type delegates that parameter atmeasures from the wall's start to the door center — a plain length is a station from that start (at 4'-0"), and everything below measures the same station off a datum insteadpast <ref>measures forward from a reference;short of <ref>holds back from one — both along the wall's from→to travel, and both state the clear distance: the tape runs from the reference to this opening's facing jamb. A reference with extent measures from its facing surface — a bare element's jamb (past D4), a crossing wall's face (past W6) — soat 0' past D4is a flush sidelite; a point reference (grid line, or a datum likeD1.jamb.far) measures from its point- Centerline is the word when you don't want faces, at either end of the tape:
past D4.centerline/past W6.centerlinemeasure from the reference's center;to centerlinelands on this opening's center (at 4' past B to centerline); center-to-center says it at both ends - A directed measurement already lands on the near jamb, so
to jambmatters on undirected stations —at 2' to jambputs the jamb, not the center, 2' from the wall start.to jamb.farmeasures across to the far side instead ("the opening ends at 10'"):at 10' to jamb.far. (to edge, the older spelling, still parses) swingis the hinge side (wall's travel frame); doors open away from the wall's facing — addreverseto open toward it. Hardware handing verbatim:swing left= LH,swing left reverse= LHR- Defaults:
swing right; height and width come from the type, always
Windows
window W-5040 WIN1 in W2 at 6'-0" sill 3'-0" window W-5040 WIN2 in W2 at 4' past WIN1 # 4' clear of WIN1's jamb
- Same binding rule as doors: size on the
windowtype, placement facts on the line sillstays per-placement (default3'-0") — a sill height is where THIS window sits, not what the product is
Openings
opening O1 in W1 at 14' width 3' height 7' sill 4'- The type-less cutter: a hole has no product identity, so instance dimensions are *correct* here — not a workaround. Defaults:
height 7'-0",sill 0'(a floor-to-head passage) - Same placement grammar as doors/windows (
past/short of/to jamb/to centerline), and it projects the base datums (O1.jamb.far)
Rooms
room R1 label "OFFICE 210" at (7', 14') # point label; boundaries are future- A room point marks an enclosure — anchor it to declared datums only (grid expressions fine:
at (A + 12', 1 + 8')). Element datums (W1.end) are errors here: a wall edit must never drag a room along
Reserved keywords
Every major building category is reserved for future versions — using one is an error (not silently ignored). Highlights:
component import export const view refplane section sheet schedule floor roof ceiling stair ramp railing column beam shaft curtainwall duct pipe conduit furniture site topo zone space floortype rooftype materialtype hatchtype layertype …
instanceis a reserved value word: the delegation marker inside type declarations, an error as a value anywhere else