# EX3D Studio — guide for AI models

You can model buildings in EX3D Studio (https://www.ex3d.com/studio/) through one
small JSON API, `window.EX3D`. This guide is everything you need. It is written for any model,
small or large: follow it literally and you will build correctly.

**Worked example:** the public Farnsworth House sample was built only with this API —
`llm/examples/farnsworth.ops.json` (open it in the app: `studio/?public=farnsworth`).

---

## 0. The fast lane: eleven tools, few tokens

**API keys are secret.** In the AI panel your key stays in your browser and is sent only to the provider you choose — EX3D never
stores it on its servers or in project data (collaborators, viewers and published copies never see it).

The same small tool set serves the in-app **AI panel** (left rail › AI: plug in any model with tool use, your own key) and
agents that drive the page from outside (a browser MCP). From outside, everything is ONE call returning compact text:

```js
EX3D.prompt                      // the short system prompt (rules + every op in one line) — read once
EX3D.tools                       // [{ name, description, args }]
await EX3D.call('get_state')     // name, stories, counts, bounds, the open view, the USER'S SELECTION, the views
await EX3D.call('catalog', { what: 'families', filter: 'door' })     // families | materials | library | sections | species | views
await EX3D.call('find', { type: 'wall', near: [4000, 2500], radius: 1500 })
await EX3D.call('guide', { topic: 'stair' })                         // ONE op's fields, or rules | recipes | pitfalls | sheets | layout …
await EX3D.call('run_ops', { ops: [ … ] })                           // → { ok, created, changed: { wall: '+4' }, bounds } or errors
await EX3D.call('look', { view: 'Plan - floor' })                    // PLAN / ELEVATION first: the clearest feedback (no light, no camera)
await EX3D.call('look', { view: 'Outside', eye: [x,y,z], target: [x,y,z], style: 'work' })   // 3D: flat colours + edges by default (never dark); 'lines' | 'clay' | 'render' (lit)
                                                                     // from outside it opens the view ON SCREEN: take your own screenshot
await EX3D.call('plan_text', { story: 'Floor' })                     // a tiny SVG of the plan, no picture needed
await EX3D.call('show', { view: 'Plan - floor', ids: ['w1'] })       // point the user at something
await EX3D.call('undo')
await EX3D.call('reference')                                         // reference IMAGES on plans / sheets + where they lie (mm)
await EX3D.call('reference', { id, crop: [0, 0.5, 0.5, 1] })         // one image sharp (in-app models get the picture; from outside: its
                                                                     // pixel → mm mapping — open its plan and screenshot it to see it)
await EX3D.call('remember', { note: 'scale 1 ft = 304.8 mm; outline 15 × 15 m' })   // a pinned fact (EX3D.notes)
```

Token rules: never dump `EX3D.elements()` whole — `find` with a filter; read `guide(op)` only for ops you use;
batch a whole building part per `run_ops`; take a screenshot after a meaningful batch, not after every op
(from outside, `look` puts the view on screen so your browser tool's screenshot is a real, cheap image —
never pull a base64 data URL through JavaScript).

## 0d. Big jobs: the plan of plans (optional)

A tool, not a rule — use it if it helps you. Small models that design a whole building in one thought run out of tokens
and build nothing; for them `plan({action:'start', brief})`
lays out an architect's phases — program, site, massing, layout per floor, openings, spaces, furnish, outside — with review
tasks that zoom out between them. Split a phase with `plan({action:'add', parent, items:['Ground floor','First floor']})`,
finish each task with `plan({action:'done', id, note:'one line: what was decided, key sizes'})`. In the AI panel the app then
hands you ONE task at a time in a small window (the plan outline, the task, the decisions so far); from outside, call
`plan({action:'show'})` to see where you are.

## 0c. The reference workflow (guide topic: underlay)

1. `reference()` — every reference with its `next`: a **photo / scan** → `prepare_image({id})` (cleans, straightens, splits the sheet
   into drawings d1…; a clean CAD export is left as it is); a **DXF** → `find_walls({id, layers})` — exact walls from its own
   parallel lines, no picture; a **3D / IFC model** → already 3D, use it.
2. Name the drawings with `remember` ("d3 = FLOOR 1"); if the sheet was not separated, `mark_drawing({id, name:'FLOOR 1', box})`
   with a box read off the overview's grid. Work on ONE: `reference({id, drawing:'FLOOR 1'})` (+ `crop` = a part of that drawing).
3. `calibrate({on, a, b, length})` once from one written dimension (images only — a DXF is already in mm).
4. `find_walls({id, drawing})` → numbered candidates → `{op:'trace', on:'<view>', use:'all', skip:[…]}` (skip dimension
   lines, text, furniture). Add what it missed with `"on"` points — walls `a`/`b`, doors `at`, rooms `box` — snapped onto the drawing.
5. `reference({id, drawing, overlay:true})`: your walls (red, yellow = off the drawing) over it, untraced lines orange. Fix, repeat.

## 0b. Real-world work: plan photos, unclear briefs, small models

- **A plan image is an UNDERLAY — draft on it.** Standard practice: `reference({ id, crop })` gives a picture with a pixel
  grid and a view id (`v1`…); `calibrate({ on: 'v1', a: [x,y], b: [x,y], length: "17'-1\"" })` scales the image from one
  written dimension; then every op can take points in that picture's pixels: `{ op: 'wall', on: 'v2', a: [120, 340],
  b: [480, 340], thickness: 150 }`, doors `at`, or a whole floor `{ op: 'rooms', on: 'v2', rooms: [{ name, box: [x0,y0,x1,y1] }] }`.
  `reference({ id, crop, overlay: true })` shows your walls (red) and rooms (blue) over the drawing — fix what is off.
  No re-measuring, no unit arithmetic: the app maps pixels to mm.

- **"The plan is in the plan view"** means a reference image: `reference()` lists every image on the plans and
  sheets with its rectangle in model mm; `reference({ id, crop })` zooms into one floor or one label. A photo is
  almost never to scale where it was dropped — scale it from ONE written dimension (the overall length, or the
  longest room) and `remember` that scale.
- **Write lengths as they are drawn:** `"21'-6\""`, `"14'"`, `"9'-1 1/2\""`, `"8in"`, `"3.2m"`, `"450mm"` are
  converted to mm inside `run_ops` (names and texts are left alone). No feet-inches arithmetic in your head.
- **Don't interrogate the user.** A hand drawing is ambiguous: list your assumptions in a few short lines, build,
  and let them correct you. At most one round of questions, all at once, only if something blocks you.
- **Stages, not one giant plan.** Stories → outer walls of one floor → its interior walls → openings → slabs →
  the next floor → stairs → roof → views. One `run_ops` per stage, a check (`plan_text` or `look`) after each.
  Reasoning models: think briefly per stage — a long plan of the whole house in your head runs out of tokens.
- **Your context is small; the project is not.** The in-app panel compacts old turns into a digest when the
  conversation nears its budget (Settings › Context budget, about half the loaded context length). The model in
  the project is the record — re-read it with `get_state` / `find`; keep facts you will need in `remember`.
- **Playbooks** (AI panel › Playbook): prepared prompts for these jobs — model from a plan photo, design from a
  brief, furnish a room, check the model, change only the selection, presentation views. Copy their steps.
- **Local models** (LM Studio, Ollama): load with Parallel / concurrent predictions = 1 (the context is split
  between slots), 32–64k context that fits in VRAM (a context spilling into system RAM crawls), Thinking = Brief.

## 1. How to connect

- **Through a browser tool (works today, any model).** Open the app, then evaluate JavaScript in
  the page: `EX3D.info()`, `EX3D.run([...])`. Any MCP server that drives a browser works
  (Claude in Chrome, Playwright MCP, Chrome DevTools MCP).
- **Start clean:** open `studio/?new` (a fresh project, no login) and pick "Just open the app",
  or load a public project with `studio/?public=farnsworth`.
- **Headless (scripts):** `node dev/build-samples.js` replays an ops file with the same API.

## 2. The three rules

1. **Millimetres everywhere.** A 4 m wall is `4000`.
2. **Plan axes: x → east, y → north.** Points are `[x, y]`. Heights (z) are measured **up from the
   floor of the element's story** — except camera eyes and targets, which are **absolute** (from
   the ground, z = 0).
3. **`EX3D.run(ops)` is all-or-nothing.** If any op is wrong, *nothing* changes and you get
   `errors` in plain words (`"op 3 (wall w2): the two ends are the same point"`). Fix and resend
   the whole batch. A successful batch is one undo step for the user.

## 3. Read before you write

```js
EX3D.info()                    // stories, families (doors, windows, objects), materials, site, counts, ops
EX3D.elements({ type: 'wall', story: 'Floor' })   // compact list: ids, points, sizes
EX3D.describe('w12')           // one element in words
EX3D.bounds()                  // { x0, y0, x1, y1, z0, z1 } of everything, mm
EX3D.plan({ story: 'Floor' })  // a small SVG of the plan (1 unit = 10 mm, north up) — cheap to read
EX3D.views()                   // the boards: plans, 3D views, sheets
await EX3D.screenshot({ view: '3D View', w: 960, h: 600 })   // a JPEG data URL of a 3D view — LOOK at it
EX3D.info().sections           // the steel section catalogue (W8x48, IPE 300, C15x33.9, SHS 100x6 …)
```

## 4. The ops

Give an op an `id` of your own (letters, digits, `_`, `-`) and later ops in the same batch — and
later batches — can refer to it. Stories and materials can be named by id **or** name.

| op | fields | notes |
|---|---|---|
| `name` | `name` | the project's name |
| `site` | `lat, lon, utc (hours from UTC; omitted with lat/lon = lon/15, so `time` is the place's local time), date 'YYYY-MM-DD', time 'HH:MM', north, sky, clouds, fog, weather, night` | sun position, sky. `sky: 'atmosphere'` = physical sky. `clouds: { on, type: 'cumulus'\|'overcast'\|'cirrus', coverage 0–1 }`. `fog: { on, visibility m, height m, base m, albedo 0–1, g, tint }` or `{ on, preset: 'haze'\|'forest'\|'mist'\|'fog'\|'dense'\|'smog' }` = ground fog. `weather: 'clear'\|'fair'\|'hazy'\|'overcast'\|'damp'\|'mist'\|'fog'` sets air + clouds + fog at once |
| `story` | `id, name, elevation, height` | a level. With an existing `id` it edits that story. A new project has one story, id `story-0` |
| `material` | `from, id, name, color '#rrggbb', roughness 0–1, metalness 0–1, opacity 0–1, antiTile 0–1, macroVar 0–1, cut` | adds or edits. `antiTile` breaks visible texture repeats (hex-tile blending), `macroVar` adds large-scale colour variation — both 0 = off, good on lawns, concrete, stone, gravel. `cut` = plan cut pattern: `solid`, `none`, `concrete`, `brick`, `steel`, `wood`, `insulation`. Built in: `Concrete`, `Brick masonry`, `Drywall`, `Plaster`, `Insulation`, `Glass` (`mat-vidrio`) `from`: copy one of the EX3D materials with its textures (travertine-pavers, primavera-veneer, oak-chevron, marble-bianco-lassa, steel-brushed, leather-tan, silk-shantung, concrete-formwork, brick-capuccino … — `info().library` lists all 37; real sizes, colour / normal / roughness maps); name / cut / … still apply on top |
| `wall` | `story, a [x,y], b [x,y], thickness, height, base, material, refline, roof` | the line a→b is the wall's **centre** (`refline: 'center'`). `height` null = the story height. `roof: 'extend'` = its top follows the underside of a roof of its story (gable ends); `'trim'` = cut where it rises above it |
| `slab` | `story, poly [[x,y]…], thickness, top, material, finish, underside, fascia { section, material }, holes [[[x,y]…]…], subtract [ids]` | `top` = the slab's top above the story floor; it hangs `thickness` below that. Floors: `top: 0`. A roof: `top: story height + thickness`. `fascia` = a steel channel round the whole edge, web outside, flush with top and bottom (`section` a channel, e.g. `C15x33.9`, `UPN 300`) `finish` = the top face (the floor you walk on), `underside` = the ceiling below; default: the slab's own material (the plan shows the finish) **holes / subtract** cut outlines out of the slab (a pool in the terrace, a stair well): `holes: [[[x,y]…]]` in mm, or `subtract: ["<id>"]` = another element's outline (slab, zone, lawn, roof, a drawn polygon or box); edit later with `edit {set:{holes:[…]}}` (`[]` clears) or `{set:{subtract:[ids]}}`. |
| `column` | `story, at [x,y], w, d, height, base, angle, material, section \| profile, tw, tf` | a rectangle `w` (along x) × `d` (along y), centred on `at`. `section: 'W8x48'` makes a real steel section (its sizes from the catalogue, `info().sections`); or `profile: 'I'\|'C'\|'round'\|'tube'\|'pipe'` with your own `w, d, tw` (web / wall) `, tf` (flange). An I's web runs along y; turn it with `angle` |
| `beam` | `story, a [x,y], b [x,y], top, section \| profile + w, d, tw, tf, roll, material` | a steel member from a to b; `top` = its top above the story floor (default: the story height). `roll` turns the section about its axis (degrees) |
| `stair` | `story, a [x,y], b [x,y], wide, rise, base, risers, kind, tread, nosing, material, stringer` | a straight flight walking **up from a to b** (the run, on the stair's centre line); `wide` across. `rise` = total height, from `base` above the story floor. `risers` defaults to rise / ~175. `kind: 'solid'` (a stepped mass), `'floating'` (slabs `tread` thick), `'stringers'` (floating treads + two steel plates, `stringer` = their material). The top tread is one riser below the landing: the landing itself is the last step |
| `glazing` | `story, a [x,y], b [x,y], height, base, panel \| panels, mullionW, mullionD, head, sill, transoms [h…], glassT, material, glass` | a curtain wall: glass on the line a→b, mullions every ~`panel` mm (the length is shared evenly) or `panels` bays, frame bars `head` / `sill` high (0 = none), horizontal `transoms` at those heights. `material` = the frame, `glass` = the glass material (default `mat-vidrio`). `height` null = the story height |
| `curtain` | `story, a [x,y], b [x,y], open, split, top, drop, fullness, pleat, track, material` | pleated fabric on a track a→b. `open` 0 (drawn shut) … 1 (pushed open); `split: 'center'` stacks at both ends, `'left'` at a, `'right'` at b. `top` = the track height (default: the ceiling), `drop` = fabric height (default: to the floor). Built-in fabric `mat-cortina` (a sheer) |
| `cabinet` | `story, a [x,y], b [x,y], kind, modules, depth, height, elev, plinth, handle, worktop, flip, material, front, top, handles` | a run of cabinets whose BACK is the line a→b; they stand on its **right** (draw along the wall as you face it, left to right; `flip: true` = the other side). `kind: 'base'` (worktop) `\| 'wall'` (hangs at 1450) `\| 'tall' \| 'island' \| 'wardrobe'`. `modules: '600 drawers, 900 sink, dishwasher, hob'` — a width is optional (none = they share the rest); fronts: `doors, door, drawers, drawers2, drawers4, open, sink, hob, oven, dishwasher, fridge, blank`. `handle: 'bar' \| 'knob' \| 'push'`. Materials: `material` = carcass, `front`, `top` (worktop), `handles` |
| `tree` | `story, at [x,y], species, height, seed, season, density, prune, fullness, z, bark, leaves` | a procedural tree: `species: 'maple' \| 'oak' \| 'ash' \| 'birch' \| 'beech' \| 'poplar' (Lombardy, columnar) \| 'willow' (weeping) \| 'pine' \| 'scotspine' (bare trunk, high crown) \| 'spruce' \| 'fir' \| 'larch' \| 'shrub'`; `height` mm (null = the species' own); `seed` = which tree (the same seed, the same tree — vary it for a group); `season: 'spring' \| 'summer' \| 'autumn' \| 'winter'` (bare; conifers keep their needles); `prune` 0.03…0.85 = where the crown starts up the trunk (low = branches near the ground; null = the species' own); `fullness` 0.3…2.5 (1 = the species) = more branches and leaves |
| `scatter` | `story, poly [[x,y]…], species [..], density, spacing, height, vary, seed, season, edge, prune, fullness, holes [[[x,y]…]…], subtract [ids]` | a wood, a hedge, a planting: plants of the listed species inside the outline, `density` per 100 m² (at most 400), at least `spacing` mm apart, sizes ± `vary`; `edge` 0…1 crowds them toward the outline (a woodland edge); `prune` / `fullness` as for `tree`, for every plant **holes / subtract** keep the plants out (a pool, the house); a `water` element does it by itself. |
| `lawn` | `story, poly [[x,y]…], look, z, stripes, stripeW, stripeAngle, grass, bladeH, tufts, dry, dryScale, leaves, weeds, dandelions, flowers, flowerColor, bumps, clump, clover, seed, material, stripe, holes [[[x,y]…]…], subtract [ids]` | a grass surface. `look: 'manicured' \| 'natural' \| 'meadow' \| 'wild'` sets all the rest at once (then override any). `dry` 0…1 = dry patches (`dryScale` mm), `leaves` = fallen leaves (heaped under trees), `weeds` / `dandelions` per m², `flowers` = wildflowers in patches (`flowerColor` '#rrggbb'); `bumps` mm = a gently uneven ground under the blades, `clump` 0…1 = tufts growing in clumps of varied height, `clover` 0…1 = clover patches; mowing stripes `stripeW` mm toward `stripeAngle`°; `z` = its top above the story floor **The grass surrounds the house**: `subtract: ["<the house slab / zone id>"]` or `holes: [[[x,y]…]]` cut the building (a terrace, a path) out of the lawn — mowing stripes, blades and flowers keep out of the holes; a `water` pool / pond cuts itself out of the lawn and slabs under it. |
| `water` | `story, poly [[x,y]…] or rect [x0,y0,x1,y1], look 'pool'\|'pond'\|'lake'\|'sea'\|'still', depth, z, rim, basinT, color '#rrggbb', absorb, waveH, waveL, waveSpeed, waveChop, waveDir, sparkle, rough, crest, foam, caustics, causticScale, carve, material, basin` | a **swimming pool, pond, lake or fountain basin**: the water's edge as `poly` (or a `rect`), `depth` (mm, default 1500) = the basin below the surface, `z` = the surface above the story floor (default -150: a pool sits a little under its deck), `rim` = the wall above the water. It builds the basin (tile floor and walls) and the water. **Absorption**: `absorb` (per metre: 0 = glass, 0.9 = a pool, 2+ = a murky pond) and `color` (what deep water tends to): the deeper, the more of that colour, red first. **Waves**: `waveH` (mm, 0 = a mirror), `waveL` (m, the longest wave), `waveSpeed` (0 = still), `waveChop` (0…1), `waveDir` (deg). `crest` (0…1) tints troughs deeper and crests lighter, `foam` (0…1) = whitecaps on the steepest crests (lakes, sea). `caustics` (0…2, 0 = off) = the sun's moving net of light on the basin floor and walls, `causticScale` its size in m. `sparkle` = the sun's glitter (HDR: Post › Glare / Bloom make it shine). It **cuts itself out of the lawns and slabs of its story** (`carve:false` to stop), so grass and terrace surround it with no extra op. Live views animate it; path tracer / SSGI see glass of index 1.33. A deck around a pool: a `slab` with a hole, or just a slab under it |
| `object` (Mies pieces) | `family: 'fam-barcelona-chair' \| 'fam-barcelona-stool' \| 'fam-barcelona-couch' \| 'fam-barcelona-table' \| 'fam-brno-chair' \| 'fam-mies-table' \| 'fam-bed' \| 'fam-rug'`, `params { W, D, H … }` | parametric furniture with plan symbols; a seat faces +y (turn it with `angle`) |
| `rooms` | `story, units ('ft' \| 'in' \| 'm' \| 'cm' \| 'mm'), rooms [{ name, at [x,y], w, d, category, walls }], outer (300), inner (120), height, zones, walls, replace` | a FLOOR from a room list — the fastest way to model what you read on a drawing. `at` = each room's south-west corner, `w` along x (east), `d` along y (north), measured to the wall centre lines. Work in the drawing's units: `units: 'ft'` with `at: [7.667, 0]`, or strings like `"21'-6\""`; without `units` the app works them out (numbers under 200 cannot be mm) and says what it assumed. The app makes one `zone` per room (category guessed from the name) and the walls: every room edge, shared edges once, outer (one room beside it) `outer` thick, inner `inner` thick; porches / decks / patios / terraces get no walls of their own. `replace: true` rebuilds that story's rooms and walls from the new list (doors stay). The answer lists the wall ids for doors and windows |
| `zone` | `story, poly [[x,y]…], name, number, category 'living'\|'sleeping'\|'wet'\|'kitchen'\|'circulation'\|'service'\|'outdoor', showArea, holes [[[x,y]…]…], subtract [ids]` | a room: coloured on the plan with a stamp (name, number, net area). `poly` = the inside faces of its walls. Numbers itself (01, 02 …) per story when `number` is left out **holes / subtract** make a void in the room (a stair well, a courtyard): the plan fill and the net area follow. |
| `roof` | `story, poly [[x,y]…], kind 'hip'\|'gable'\|'shed'\|'flat', pitch (°, 30), overhang (500), thickness (200), base (null = story height), eave (edge index; null = longest), gableWalls (true), material` | `poly` = the pivot line (the wall centre lines); the overhang is added outside. Gable: the edges parallel to `eave` slope, the others are gables (filled up to the roof when `gableWalls`); shed: only `eave` slopes |
| `opening` | `wall, family, at [x,y] or t, params, flip` | a door / window **in a wall**. `t` = mm from the wall's start `a`; or give a point `at` near the wall. Doors: `fam-puerta` (swing), `fam-door-double`, `fam-door-glazed`, `fam-door-sliding`, `fam-door-pocket`, `fam-door-bypass`, `fam-door-bifold`, `fam-door-sidelight` (`DW` door width), `fam-door-garage`. Windows: `fam-ventana` (sliding), `fam-win-casement`, `fam-win-french`, `fam-win-fixed`, `fam-win-awning`, `fam-win-double-hung`, `fam-win-louvre`, `fam-win-ribbon`. All take `W, H, SILL` |
| `object` | `story, family, at [x,y], z, angle, params` | furniture and other object families (see `info().families`). Fixtures (back = −y against the wall, `Z` = mounting height): `fam-wc`, `fam-washbasin`, `fam-shower-tray` (`SH` screen, 0 = none), `fam-bathtub`, `fam-kitchen-sink`, `fam-urinal`, `fam-floor-drain`, `fam-water-heater`, `fam-socket`, `fam-switch`, `fam-data-outlet`, `fam-panel-board`, `fam-smoke-detector`, `fam-ac-split`, `fam-radiator` |
| `light` | `story, at [x,y], z, kind, power, color, target [x,y,z]` | `kind: 'point'\|'spot'\|'rect'\|…`, `power` in lumens |
| `view` | `kind 'plan'\|'3d'\|'sheet'\|'elevation'\|'section', name, story, eye [x,y,z], target [x,y,z], fov, side 'south'\|'east'\|'north'\|'west', a [x,y], b [x,y], depth` | adds a board. An **elevation / facade / section** (`kind 'elevation'`, `side` = round the whole model, or `a`,`b` in mm — it looks to the LEFT of a→b) puts its marker on the plan and makes a sheet of its own with the drawing; it needs the model to exist (views run after the other ops of a batch), and a sheet shows it with an item `{ type: 'modelview', at, view: '<its name>', ratio: 100, crop, poche }` (make the elevation BEFORE the sheet). Plans need `story`. 3D eyes/targets are **absolute** heights. A sheet takes `size [w,h]` (px), `paper`, `first: true` (the project opens on it) and `items: [{ type: 'text', at, text, width, size, weight, color, lineHeight } · { type: 'image', at, w, h, src } · { type: 'rect', at, w, h, fill } · { type: 'schedule', at, kind: 'doors'\|'windows'\|'rooms', size, title } · { type: 'planview', at, source, ratio, crop } · { type: 'modelview', at, view, ratio }]` in sheet px, y down. A schedule lists the model as it is at every draw |
| `edit` | `id, set { field: value }` | change any fields: `a, b, at, poly, thickness, height, material, story, top, base…` |
| `clone` | `from, by [dx,dy], story, id` | a copy of one element, moved and / or onto another story (heights stay story-relative); a wall brings its doors and windows |
| `copy` | `where \| ids, by [dx,dy], count` | an ARRAY: `count` copies of the matching elements, each `by` further — fins, columns, a repeated bay (`where` = the same rules as find) |
| `stack` | `from, count, height, name, types` | repeat a FLOOR upward: `count` new stories above `from` (its height unless `height`), every element of `from` copied onto each — a tower of typical floors in one op |
| `remove` | `id` | removing a wall removes its doors and windows |

Check a batch without applying it: `EX3D.run(ops, { dry: true })`.

## 5. The loop that works

1. `EX3D.info()` — learn the stories, families, materials.
2. Plan the batch on paper: list every point. Build **from the ground up**: site → stories →
   materials → slabs → columns / walls → openings → objects → lights → views.
3. `EX3D.run(batch)` — if `ok` is false, read `errors`, fix *those ops*, resend the whole batch.
4. **Verify in plan first** — an architect's abstraction, as clear for a model as for a person: `EX3D.plan({ story })` for positions,
   `EX3D.bounds()` for extents, a plan or elevation view for the picture; 3D (`EX3D.screenshot({ view })`) only as a last look —
   the Work view style (flat colours) is never too dark to read, a lit render at night is. Compare with what you intended.
5. Correct with `edit` / `remove` ops, small batches.

## 6. Recipes

**A room 4 × 3 m with a door**
```js
EX3D.run([
  { op: 'slab', id: 'floor', poly: [[0,0],[4000,0],[4000,3000],[0,3000]], thickness: 200 },
  { op: 'wall', id: 'ws', a: [0,0], b: [4000,0], thickness: 200 },
  { op: 'wall', a: [4000,0], b: [4000,3000], thickness: 200 },
  { op: 'wall', a: [4000,3000], b: [0,3000], thickness: 200 },
  { op: 'wall', a: [0,3000], b: [0,0], thickness: 200 },
  { op: 'opening', wall: 'ws', family: 'fam-puerta', t: 1000 },
  { op: 'view', kind: '3d', name: 'Outside', eye: [-4000,-6000,1600], target: [2000,1500,1200], fov: 50 },
]);
```

**A raised glass pavilion** (the Farnsworth House idea): a story whose floor is in the air, a floor
slab and a roof slab with steel fascias, steel columns welded to the fascias, glazing between the
slabs, floating stairs — see the worked example `llm/examples/farnsworth.ops.json`.
```js
EX3D.run([
  { op: 'story', id: 'fl', name: 'Floor', elevation: 1600, height: 2896 },
  { op: 'slab', story: 'fl', poly: [[0,0],[12000,0],[12000,6000],[0,6000]], thickness: 381, top: 0,    fascia: { section: 'C15x33.9' } },
  { op: 'slab', story: 'fl', poly: [[0,0],[12000,0],[12000,6000],[0,6000]], thickness: 381, top: 3277, fascia: { section: 'C15x33.9' } },
  // a W8x48 is 216 deep: its centre 10 (the channel web) + 108 outside the slab edge touches the fascia
  { op: 'column', story: 'story-0', at: [1500, -118], section: 'W8x48', height: 1600 },
  { op: 'column', story: 'fl',      at: [1500, -118], section: 'W8x48', height: 3277 },
  { op: 'glazing', story: 'fl', a: [0,60], b: [12000,60], panel: 1676, mullionW: 40, mullionD: 90 },
  { op: 'stair', story: 'story-0', a: [3000,-1400], b: [3000,0], wide: 2400, rise: 1600, kind: 'floating', tread: 60 },
]);
```

**A steel frame:** columns with `section: 'HEB 200'` on a grid, `beam` ops between their centres
with `top` at the floor above (`top: story height`), a slab on top.

**A kitchen:** base cabinets along the back wall, wall cabinets above them on the same line,
a tall unit for the fridge:
```js
EX3D.run([
  { op: 'cabinet', a: [0, 3000], b: [3000, 3000], flip: true, kind: 'base', modules: '600 drawers, 900 sink, 600 dishwasher, 900 hob' },
  { op: 'cabinet', a: [0, 3000], b: [3000, 3000], flip: true, kind: 'wall', modules: '600, 900, 600, 900' },
  { op: 'cabinet', a: [3000, 3000], b: [3600, 3000], flip: true, kind: 'tall', modules: 'fridge' },
  { op: 'curtain', a: [100, 150], b: [2900, 150], open: 0.5 },
]);
```

**A garden:** a lawn round the house, a big tree, a group of birches (different seeds), shrubs:
```js
EX3D.run([
  { op: 'lawn', poly: [[-20000,-20000],[30000,-20000],[30000,20000],[-20000,20000]], stripeW: 1200 },
  { op: 'tree', at: [18000, -8000], species: 'maple', height: 20000, seed: 11 },
  { op: 'tree', at: [-9000, 9000], species: 'birch', seed: 1 },
  { op: 'tree', at: [-7500, 11000], species: 'birch', seed: 2 },
  { op: 'tree', at: [2000, -3000], species: 'shrub', height: 1500, seed: 5 },
]);
```

**A shopfront:** `glazing` with `panels: 3, transoms: [2400], sill: 0` between two walls.

## 7. Pitfalls (each one happened while building the sample)

- **Camera heights are absolute; element heights are story-relative.** An eye at the floor of a
  story at +1600 looking at 1.5 m is `z: 3100`, not 1500.
- **A wall's line is its centre.** A 200 mm wall on `y: 0` spans y −100…+100. To keep glass inside a
  slab edge, move its line in by half its thickness (or more).
- **Slab `top` is the TOP.** A 381 mm slab with `top: 0` occupies −381…0 under the story floor.
- **Columns start at their story's floor.** A column that must reach a roof on another story needs
  `height` = the distance from its own floor to the roof's top.
- **A plan shows only its own story.** A column running from the ground to the roof is invisible on
  the upper plan: model one column per story (same `at`, heights meeting at the floor).
- **Cabinets stand on the RIGHT of a→b.** On a north wall (y = 3000) drawn west → east that is
  south… of the line, i.e. into the room only if the room is south of it; otherwise `flip: true`.
  Check with `EX3D.plan()` — the worktop outline must be inside the room.
- **Trees are tall.** A mature maple is 14–25 m, a birch 10–15 m, a shrub 1–2 m: `height` is in mm.
  Leave room: a 20 m maple's crown is ~12 m across. Lawns and trees go on the ground story.
- **A stair's `a` is its bottom, `b` its top**, both on the centre line. `a → b` is the run, not the
  width — `wide` is across.
- **An I column's web runs along y.** Columns on a south or north edge welded to a fascia are right
  as they are; on an east or west edge give them `angle: 90`.
- **Glazing and walls on the same line fight.** Glass goes on its own line inside the slab edge
  (the fascia is outside it); leave walls for opaque parts.
- **`screenshot` is async** in a live browser: `await` it. A view that has just been created may need
  a moment before it can draw.
- **Ids are unique for good.** Reusing an id (even one removed long ago in the same batch) is refused.
- **Doors and windows need a wall** and a family of category `door` / `window` (`info().families`).
- **One batch, one story's worth of intent.** Very long batches are fine but harder to debug; group
  by building part.

## 8. What is coming (and will be added here)

Doors in glazing, furniture as GLB models, image planes / backdrops, terrain and water — each as a new op. A dedicated MCP server with typed tools will follow for
smaller models.

## Sheets that link to views

Any sheet item (`text`, `image`, `rect`) can carry `link`: a board's name (`'Living room'`, `'Plan - floor'`)
or an `https://` address. In Showcase a click or tap opens it (the board slides in, a Back chip returns);
while editing, Ctrl+click. Make buttons from a `rect` (`fill`, `stroke`, `radius`) and a `text` on it,
both with the same link. Names are turned into board ids when the run finishes.

3D views take `fx` for the camera's look: `{ autoEv: true, eye: true }` (measured exposure),
`autoWb: true` (auto white balance), `ev`, `temperature`, `tint`, `contrast`, `saturation`, `vignette`, `bloom`.

## Dressing furniture

`object` takes `materials: { slot: material }` — the family's part slots (`info().families`: e.g. a
Barcelona couch has `frame`, `legs`, `cushion`). `material` takes `tint: '#rrggbb'` to grade a textured
material (`from`) — one leather texture gives cognac, brown, navy and cream.

## Layout sheets with a plan view (ARCHICAD-style)

A floor plan can be placed on a layout sheet as a **reference**: its own scale, window, plan type and layers, never
touching the original plan. `view` with `kind: 'layout'` makes the whole sheet — frame, overall dimensions, the title
block (2D elements) and one plan view:

```js
EX3D.run([
  { op: 'view', kind: 'plan', name: 'Venue plan', story: 'story-0' },
  { op: 'view', kind: 'layout', name: 'Venue 1 · layout', source: 'Venue plan',
    crop: [-14600, -12700, 15300, 9500],      // the window, model mm: x0, y0, x1, y1 (a venue)
    ratio: 100,                               // 1:100 (omit = the largest standard scale that fits an A3 sheet)
    paper: 'A3', show: 'plan',                // 'plan' floor plan (no ceiling lights) | 'ceiling' | 'all' (everything)
    layersOff: [],                            // project layer ids hidden in THIS plan view only
    titleBlock: { project: 'EVENTO EN LISBOA', client: 'CLIENTE PRIVADO', drawing: 'PLANO DIAGRAMACIÓN EVENTO',
                  designer: 'APPLETREE', architect: 'Pablo Ferro Serrano', number: 'A-01', scale: '1:100', date: '07/10/2026' } },
]);
```
On any sheet, `items: [{ type: 'planview', at: [x, y], source: 'Venue plan', ratio: 200, crop: [x0, y0, x1, y1], show: 'all', layersOff: [] }]`
puts a plan view; the Properties panel edits scale, plan type, window and layers per reference.

`view` ops take `group: 'Venue 1'` — sheets and views with the same group are listed together under a folding heading in the Sheets panel.
Floor plans draw what hangs above the cut at head height (a mezzanine, its parapet, a soffit) as dotted ghosts; ceilings stay out, and ceiling lights show only in the 'ceiling' and 'all' plan types.
