Use this reference when authoring JSON fields, coordinates, materials, or model primitives. For imports, layout changes, image generation, and verification, follow the matching workflow.
Public examples use rooms/<room>/ and assets/<id>/. Personal projects use private/rooms/<room>/ and private/assets/<id>/. All format examples below apply to either location. New import/fetch commands create private data; existing public ids take precedence. Use different ids for a public snapshot and a private working room.
Texture catalogs are merged from private/textures/index.json and textures/index.json (public entries win). Image lookup likewise prefers the public file. The public catalog is empty; example rooms use plain colors.
Undo versions live in private/.history/<repo-relative-file>/<timestamp>.json. Development watches both public and private data. Browser URLs remain /rooms/<room>/... and /assets/<id>/...; the server resolves their location. Private notes and history have no web route.
All editable files use the plan frame:
| Thing | Definition |
|---|---|
| Units | Inches, as decimal numbers. Scripts round what they write to 0.1 in. |
| Axes | Right-handed. +x = East, +y = North, +z = Up. |
| North | A room label, not true north. import-scan sets it to the room axis closest to where the phone faced at scan start. |
| Origin (0,0,0) | The SW corner of the floor polygon's bounding box, at finished-floor height. For a rectangular room this is the corner where the interior faces of walls S and W meet. Every in-room coordinate is ≥ 0. |
| Rotation | Degrees, counterclockwise seen from above (from +x toward +y). |
| Item placement | x, y = center of the item's footprint. elevation = height of the item's bottom above the floor (default 0). rotation = degrees (default 0). |
| Item facing | At rotation 0 an item's front faces South (-y). 90 faces East, 180 faces North, 270 faces West. So an item against the N wall facing into the room uses 0, against the W wall uses 90, against the S wall uses 180, and against the E wall uses 270. |
| Walls | Listed clockwise seen from above. Wall i runs from walls[i].start to walls[i+1].start, and the last wall closes back to walls[0].start. The room is on the right of the direction of travel. |
Along-wall coordinate s |
Distance in inches from the wall's start. Standing inside the room facing the wall, s runs left to right, the same as reading a photo of that wall. |
| Wall faces | The wall line is the interior face (the surface you measure from). thickness extends outward, away from the room. |
| Wall names | The compass letter of the wall's outward normal, 8-way (N, NE, E, SE, S, SW, W, NW). When a letter repeats, the walls are numbered in list order: N1, N2. |
| Door hinge | left/right as seen from inside the room facing the door. left = the jamb at the smaller s (the offset edge). |
| Heights | z above the finished floor. A wall's top gives ceiling-line heights along s. |
| Asset model frame | Origin at the footprint center, on the floor. +x is to the right of a person standing in front of the item facing it, +y runs toward the item's back, +z is up. The front faces -y. The model occupies x ∈ [-W/2, W/2], y ∈ [-D/2, D/2], z ∈ [0, H], where dims = [W, D, H]. |
| Cameras (views) | position and target are plan-frame points in inches. fov is vertical, in degrees. |
| Renderer (internal only) | Three.js world is meters, Y-up: three = 0.0254 × (plan.x, plan.z, −plan.y). This is done once, by a planRoot group with rotation.x = −π/2 and scale = 0.0254. All scene content is built in plan inches inside it. glTF content (Y-up, meters, front +Z) gets an inner wrapper with rotation.x = +π/2 and scale = 39.3701. That is why glTF models face South at rotation 0 with no extra turn. |
Worked example (the demo room): a 144 x 120 in room. Walls in list order are W (start [0,0]), N (start [0,120]), E (start [144,120]) and S (start [144,0]). In the demo layout the bed sits in the NW corner at rotation 0 (front facing South): its left side is against the W wall and its back (the headboard, +y) is against the N wall. The N wall runs from x=0 to x=144, so a door 92 in along it (offset 92) sits at x 92–124.
- Plain JSON, with no comments or trailing commas. Every script writes with
writeJson()(insrc/disk.ts): 2-space indent, arrays of plain numbers on one line ([144, 0]), a trailing newline. Keys are written in the order shown in the examples below. Agents should keep that style. - Examples below are condensed for reading. Use
formatJsonfor files on disk. - Colors are
"#RRGGBB"(six hex digits). - Lengths are inches. Angles are degrees.
- Optional fields are marked
?. When an optional field is omitted, its default applies. - Validation lives in src/model.ts and runs only in Bun:
check, the scripts, and the viewer (which gets its bundle from the server's/api/bundle) all see the same errors.
{
"port": 8766,
"eyeHeight": 64
}| Field | Type | Meaning |
|---|---|---|
port |
integer 1024–65535 | Dev server port on 127.0.0.1. |
eyeHeight |
number 36–84 | Walk-mode eye height, in inches. |
Nothing else is configurable. Check thresholds are constants in src/model.ts.
The room shell and permanent fixtures live here. See the runnable demo for a complete example derived from the synthetic scan.
All top-level fields except defaultLayout are required. Arrays may be empty.
| Field | Type | Meaning / rules |
|---|---|---|
walls |
array (≥ 3) | Clockwise seen from above (see Coordinate system). The polygon of all start points is the floor polygon. It must be simple (no self-intersection), clockwise (signed area < 0 with y up), and have no edge shorter than 1 in. |
walls[].id |
string | Compass name, ^[A-Z]{1,2}[0-9]*$, unique. |
walls[].start |
[x, y] |
Start corner, on the interior face. The end is the next wall's start. |
walls[].thickness |
number > 0 | Visual only, extruded outward. Import default 4.5. |
walls[].top |
[[s, h], ...] (≥ 1 point) |
Height of the wall top (the ceiling line) along the wall. s must not decrease, h ≥ 0, and at least one h is > 0. Heights between points are linear. Before the first point and after the last, the height stays constant, so after a wall-length edit the profile doesn't have to end exactly at the new length. Two points with the same s make a vertical step. A flat wall is [[0, 96], [L, 96]]. A side that is really open (no wall at all) is a wall with one full-width opening cutout. |
walls[].cutouts |
array | Holes in this wall. Cutouts on one wall must not overlap. |
cutouts[].id |
string | Unique within room.json. Import uses <wall>-d<n>, <wall>-w<n> and <wall>-o<n>, numbered by offset. |
cutouts[].kind |
"door" | "window" | "opening" |
opening is a doorless passage or archway. Closet doors are doors. |
cutouts[].offset |
number ≥ 0 | s of the cutout's left edge (seen from inside). |
cutouts[].width |
number > 0 | Width. offset + width ≤ wall length + 0.5. |
cutouts[].sill |
number ≥ 0 | Bottom height. Doors and openings normally use 0. |
cutouts[].height |
number > 0 | Opening height. |
cutouts[].swing |
"in" | "out" | "none" |
Doors only, required on doors. none covers sliding or pocket doors. |
cutouts[].hinge |
"left" | "right" | "both" |
Required on doors with swing in or out. both is a double door (two leaves of width/2). A bifold is modeled as both, which errs on the safe side. |
ceiling |
array | Planar polygon panels. Together the non-riser panels should cover the floor polygon. |
ceiling[].id |
string | Unique. |
ceiling[].points |
[[x, y, z], ...] (≥ 3) |
Any winding. Must be planar within 0.5 in. It must be a simple polygon in the projection that drops its normal's largest component (for ordinary panels, that's the plan projection). A panel whose unit normal has |z| < 0.1 is a riser: a vertical face between two ceiling levels, such as a tray edge, a soffit face or a dormer cheek. Risers only close the gap visually: ceilingZ, CEILING and SHELL ignore them. |
trim.baseboard |
number ≥ 0 | Baseboard height (0 = none). Depth is fixed at 0.75 in. It runs along every wall except across door and opening cutouts with sill < 1. |
trim.casing |
number ≥ 0 | Door and window casing width (0 = none). Depth is fixed at 0.75 in. Doors and openings get 3 sides, windows get 4. |
fixtures |
array of placements | Permanent room details (outlets, switches, vents, radiators, lights). Same shape as layout items. Shown in every layout. |
materials |
object | Keys: walls (default for every wall), floor, ceiling, trim, or a wall id (overrides walls for that wall). Values are materials. Unknown keys are an error, which catches typos such as "north". |
overlay.file |
string | null | Mesh path relative to the room folder, such as "scan/mesh.glb". Supports .glb, .gltf, .usdz and .obj (OBJ without materials). |
overlay.yaw |
number | Degrees CCW. |
overlay.offset |
[x, y, z] |
Inches. A mesh point is transformed as p_room = Rz(yaw) · p_mesh + offset, where p_mesh is the mesh's own coordinates converted from glTF meters, Y-up, into plan inches, z-up: (X, −Z, Y) × 39.3701. Import sets yaw and offset to the transform it applied to the RoomPlan data, which is exact when the mesh shares the RoomPlan session frame. |
defaultLayout |
string, optional | The layout the viewer opens when the URL has no ?layout= (check and render still default to default). |
notes |
string[] | TODOs for the agent, written by import, and printed by check until the agent removes them. |
{ "color": "#FFFFFF", "texture": "laminate_floor_02" }| Field | Type | Meaning |
|---|---|---|
color |
#RRGGBB |
Required. It multiplies the texture when one is present, so use #FFFFFF for the texture's own colors. |
texture? |
string | An id from the merged public/private texture catalog. It repeats every size inches in plan x/y on the floor, and along s/z on walls. |
Roughness is fixed in the renderer, not stored: walls, ceiling and trim 0.9, floor 0.6.
Resolution: merged = { ...room.materials, ...layout.materials }, where each key replaces the whole entry. Wall X uses merged[X] ?? merged.walls. So a room-level accent on N survives a layout that sets walls, unless the layout also sets N. If a key is missing entirely, the defaults are walls #EDEAE4, floor #B89A76, ceiling #FFFFFF and trim #FFFFFF.
Layout items and room fixtures share one shape:
{ "id": "lamp", "asset": "demo-lamp", "x": 75, "y": 112, "elevation": 24, "rotation": 0, "colors": { "shade": "#F3EFE6" } }| Field | Type | Meaning |
|---|---|---|
id |
string | ^[a-z0-9][a-z0-9-]*$. Unique across the layout's items and the room's fixtures. |
asset |
string | Asset id, resolved in public or private storage. |
x, y |
number | Footprint center, in plan inches. |
elevation? |
number ≥ 0 | Bottom height above the floor. Default 0. Use it for wall-mounted and stacked things. |
rotation? |
number | Degrees CCW. Default 0 (front faces South). Any value works. |
colors? |
object | Parts assets only: { "<parts material name>": "#RRGGBB" } overrides that material's color. On GLB assets it is ignored, with a warning. |
The footprint is the asset's dims W x D rectangle, rotated about (x, y). Its vertical extent is [elevation, elevation + H]. An item with H ≤ 1 in is flat (rugs, mats). Flat items are ignored by the overlap, door, window, facing and walkway checks.
The filename without .json is the layout id. Copy a layout to a new filename to create an option in the picker. See the runnable demo layout for a complete example.
| Field | Type | Meaning |
|---|---|---|
items |
array of placements | The furniture for this layout. |
materials |
object | Overrides with the same keys and shape as room.materials, merged as described above. |
views |
object | Name (^[a-z0-9][a-z0-9-]*$) → { "position": [x,y,z], "target": [x,y,z], "fov": 10–120 }. Used by render. The user makes them with the app's Copy view button, and the agent saves them. |
All three keys are required (they may be empty). An optional fourth, shop, makes components swappable:
"shop": {
"slots": [
{ "id": "nightstands", "label": "Nightstands (2)", "items": ["nightstand-n", "nightstand-s"], "options": [
{ "id": "ikea-tarva", "items": [
{ "id": "nightstand-n", "asset": "ikea-tarva", "x": 136.9, "y": 138.1, "rotation": 270 },
{ "id": "nightstand-s", "asset": "ikea-tarva", "x": 136.9, "y": 53.6, "rotation": 270 } ] } ] }
],
"presets": { "budget": { "nightstands": "ikea-tarva" } }
}| Field | Meaning |
|---|---|
slots[].items |
Ids of base items the slot replaces (each item in one slot at most). Unpicked, the base items show: option concept. |
slots[].decided |
Optional true: the user has chosen this slot, and its base items are its first option. The Shop panel collapses decided slots under the open ones, drops their concept entry and shows (and prices) the first option when unpicked. |
slots[].options[] |
id (not concept), optional label, and items: the placements that replace the slot's items. They may add new ids, but not ids of base items outside the slot. |
presets |
Name → { slot id → option id } (concept allowed). |
A pick spec (?pick= in the viewer, --pick for check and render) is comma-separated tokens applied in order: a preset name, or slot:option. An item standing on another in the base layout (its elevation at the other's top, its center over the other's footprint) follows it when either is swapped: the same relative spot, scaled to the new top and kept on it, at the new top height. Prices, links and the MOCK flag in the viewer's Shop panel come from each asset's product.
Parts example (assets/demo-bed/asset.json):
{
"name": "Demo queen bed",
"source": "demo",
"file": "parts.json",
"dims": [64, 84, 40],
"notes": "Hand-built demo asset: frame, legs, mattress, blanket, pillows, arched headboard."
}Retailer example (written by fetch-ikea):
{
"name": "IKEA HOLMERUD",
"source": "https://www.ikea.com/us/en/p/holmerud-side-table-dark-brown-20538721/",
"file": "model.glb",
"dims": [31.5, 12.2, 20.5],
"modelYaw": 0,
"notes": "IKEA 205.387.21. Listed dims from IKEA measurements (length->W, width->D, height->H). Model bbox 31.7 x 12.1 x 20.6 in. Verify dims on the product page."
}| Field | Type | Meaning |
|---|---|---|
name |
string | Human name, used in prompts and the UI. |
source |
string | Product URL, or free text such as "photos" or "demo". |
file |
string | parts.json, model.glb or model.gltf, in the same folder. |
dims |
[W, D, H] numbers > 0 |
Listed (retailer or measured) outer dimensions in inches, always in the item's own frame: W left–right seen from its front, D front-to-back, H height. check uses these for every rule. GLB and GLTF models are rescaled per axis to exactly these dims. Parts models are not scaled: they must match (see MODEL in verification). If the largest of the three is below 3.5, check and render --asset print a DATA warning, "looks like meters?". |
modelYaw? |
0 | 90 | 180 | 270 | GLB/GLTF only. Turns the model CCW about up before fitting, so that its front faces −y, for models whose front isn't glTF +Z. Default 0. It doesn't change what dims mean. |
notes? |
string | Free text: listed-dims source, variants, colors. |
product? |
object | A buyable product: retailer, url, price (USD, >= 0) and model ("retailer" = the retailer's own 3D file, "mock" = hand-built from listed dims and photos) are required; pack (units per purchase, default 1: two lamps from one 2-pack cost one price), title, tier, priceChecked, listedDims, finish, deck (a bed's mattress-deck height) and mockReason are free. |
This is the default furniture source. The agent builds it from product photos plus listed dimensions. It uses four primitive types, each with named, recolorable materials. Coordinates are in the asset model frame: origin at the footprint center on the floor, +x right (seen from the front), +y back, +z up, front at y = −D/2. The union of all parts must fill exactly x ∈ [−W/2, W/2], y ∈ [−D/2, D/2], z ∈ [0, H] (within 0.5 in). MODEL checks each end of each axis, so a model built around its center (z from −H/2) is caught even when its size is right.
For working models, see demo-bed for boxes, cylinders, and extrusions, and demo-lamp for a lathe. Read their adjacent asset.json files for dimensions.
| Field | Type | Meaning |
|---|---|---|
materials |
object | Name → "#RRGGBB", the same shape as a placement's colors. At least one entry. Roughness (0.8) and metalness (0) are fixed in code. |
parts |
array (≥ 1) | Primitives, drawn in order. |
parts[].name? |
string | For humans and editing only. |
parts[].type |
string | box, cylinder, lathe or extrude (see below). |
parts[].at |
[x, y, z] |
Where the part's origin goes (the origin for each type is in the table below). |
parts[].rotate? |
[rx, ry, rz] |
Degrees, XYZ Euler order (three.js default), about the part's origin, applied before at. Default [0, 0, 0]. |
parts[].material |
string | Must be a key of materials. |
| Type | Fields | Part origin | Shape |
|---|---|---|---|
box |
size: [sx, sy, sz] (> 0), radius? ≥ 0 (corner rounding, default 0, clamped to half the smallest side) |
center | Axis-aligned box, optionally rounded (RoundedBoxGeometry, 4 segments). Use it for bodies, panels, cushions and mattresses. |
cylinder |
radius > 0 (bottom), radiusTop? ≥ 0 (default = radius), height > 0 |
center | Along local z, 32 radial segments, closed. Tapered legs and cones use radiusTop. Rotate [0, 90, 0] for a cylinder along x, or [90, 0, 0] for one along y. |
lathe |
profile: [[r, z], ...] (≥ 2 points, r ≥ 0) |
axis point (r=0, z=0) | The profile revolved about local z, 48 segments. Use it for lamps, vases, round pedestals and knobs. Close the top and bottom by ending on r = 0. The profile may run bottom-up or top-down, because parts render double-sided. |
extrude |
profile: [[u, v], ...] (≥ 3, simple polygon), depth > 0 |
profile (0,0), at mid-depth | The polygon lies in local XZ (u → x, v → z) and is extruded along local y from −depth/2 to +depth/2. No bevel. Use it for shaped headboards, chair sides, arched panels and L-shapes. |
ponytail: there are no textures on parts and no CSG or holes (a drawer pull is a small box, not a recess). Add them only when a real asset needs them.
Reusable starter assets are demo-bed, demo-nightstand, demo-lamp, demo-rug, outlet, switch, and ceiling-light. Their files under assets/ define the current dimensions and parts. Wall fixtures face into the room with their back (+y) against the wall.
The public file contains {}; example rooms use plain material colors. A private catalog might look like:
{
"example-wood": { "size": 48, "name": "Example wood texture" }
}The image for <id> is private/textures/<id>.jpg (or textures/<id>.jpg for a deliberately public texture). size is the real-world width of one image repeat in inches; use the texture source's measured repeat size. Add the entry to the matching index.json. Public catalog entries and files take precedence on duplicate ids.
private/.history/<repo-relative path>/<stamp>.json, for example private/.history/rooms/demo/layouts/default.json/2026-09-27T17-51-02.123Z.json. stamp is new Date(ms).toISOString() with : replaced by -, where ms = max(Date.now(), newest existing version's ms + 1). Stamps are therefore unique and strictly increasing even within one millisecond or when the clock steps back, so the files sort chronologically by name. The files are versions, the full contents of the data file at that time. The newest version equals the current file whenever the dev server is running. Versioned files are the .json files under public or private rooms/ and assets/, except raw scan directories. ponytail: no pruning; each version is a few KB, so delete old folders in private/.history/ by hand if it ever matters.
rooms/<room>/renders/<layout>--plan.png: top-down orthographic plan.rooms/<room>/renders/<layout>--<view>.pngand<layout>--<view>.prompt.txt: one pair per saved view.assets/<id>/preview-34.png(3/4 perspective),assets/<id>/preview-front.png(orthographic front elevation) andassets/<id>/preview-side.png(orthographic side elevation seen from +x, so the front is on the left), fromrender --asset.rooms/<room>/renders/photoreal/: where agents save image-tool results. The repo never writes here.