> Duimstok — a true-scale 3D home you can walk through, furnish and measure in a > browser. This file is the contract for driving it from a script. > https://duimstok.gemelyn.com/ # Duimstok, for an agent with a browser Duimstok renders a house from a JSON floorplan at true scale and lets you walk through it, put furniture in it, measure it in centimetres and photograph it. Everything is built from Three.js primitives in code — there are no model files and no image textures anywhere in it. Nothing is uploaded: a visitor's layouts, plans and items live in their own browser. Open https://duimstok.gemelyn.com/ and the page exposes `window.duimstok`. That is the whole interface. There is no install, no account and no API key. ## Start here await duimstok.ready; `duimstok.ready` resolves once the house is standing. Any call before it returns an error saying so. `duimstok.version` is "1.0"; it moves only for a breaking change, and calls are only ever added within a version. **No step below needs pointer lock.** The app's own controls (W A S D to move, the mouse to look, `E` to open a door, `Tab` for the catalog) need a real user gesture that a script cannot make. `camera.teleport()` and `camera.photo()` exist precisely so none of that is in your way. ## Conventions - **Every length is in metres.** 2.4, never 240. Centimetres appear only on the measuring tape a person reads on screen; nothing crosses this interface in anything else. - **A position is `[x, z]`** — a pair on the horizontal floor plane, not `[x, y]`. `+Y` is up. `measure()` is the one exception and takes `[x, y, z]`, because floor-to-ceiling is a real measurement. - **Angles are degrees, clockwise from `-Z`.** `heading: 0` looks along `-Z`, 90 looks along `+X`. The same convention as a floorplan's `spawn.heading`. - **The floor is at the level's `floorY`**, usually 0. Heights are measured up from it. ## Every call returns the same shape { ok: boolean, why: string, errors: string[], ...payload } Nothing throws. Ever. Check `ok`. - `ok: true` — `why` is empty, `errors` is empty, and the payload is present. - `ok: false` with `errors` **empty** — your input was fine and the answer is no. `why` says why: `"into a wall"`, `"overlaps the sofa-three-seat"`, `"covers a window"`, `"you are standing there"`, `"off the end of the wall"`. Move and try again. - `ok: false` with `errors` **filled** — the input was rejected and nothing happened. Each entry names the field, the constraint and what you sent: `parts[3].size[1] must be greater than 0, received -0.12`. Every problem is reported at once, so one corrected attempt is usually enough. ## The calls ### Reading duimstok.version -> "1.0" duimstok.ready -> Promise, resolves when the house is up duimstok.items.list() -> { items: [{ id, name, category, anchor, footprint: {w,d,h}, source: "catalog" | "user" }] } duimstok.plan.get() -> { planId, schema, plan } the whole floorplan document duimstok.plan.list() -> { plans: [{ id, name, source: "bundled" | "user" }] } duimstok.plan.rooms() -> { rooms: [{ id, name, centre: [x, z], area, bounds: { minX, maxX, minZ, maxZ } }] } duimstok.measure([x,y,z], [x,y,z]) -> { metres, from, to, clipped } `plan.rooms()` gives the area-weighted centroid. In an L-shaped room that can fall outside the room or inside a wall; `bounds` is always exact. `measure()` stops at the first masonry it crosses and sets `clipped: true`. A doorway and a staircase do not stop it, exactly as the on-screen tape behaves. ### The room duimstok.room.list() -> { items: [{ instanceId, itemId, at: [x, z], height, rotation, size: {w,d,h} }] } duimstok.room.place(itemId, { at, rotation, height }) -> { placed: { instanceId, itemId, at, height, rotation, size }, warnings } duimstok.room.move(instanceId, { at, rotation, height }) same options duimstok.room.remove(instanceId) duimstok.room.clear() -> { removed } `at` is required. Which of the other two apply depends on the item's `anchor`, and sending one that does not is an error rather than being ignored: - `anchor: "floor"` — `rotation` only. The item rests on the floor. - `anchor: "ceiling"` — `rotation` and `height`. `height` defaults to the ceiling. - `anchor: "wall"` — `height` only, defaulting to 1.5 m. There is no ray to aim along, so **`at` is read as a point standing in the room**: the item goes on the nearest wall face that point is in front of, at the spot on that face closest to it. The facing comes from the wall, which is why `rotation` does not apply. **Nothing is snapped.** What you send is where it goes, to the millimetre, and `room.list()` reads back what you sent. The app's own on-screen preview rounds to 5 cm and 15 degrees because a hand on a mouse cannot do better; you can. `instanceId` is unique while this room is open. It is **not** kept across a page reload — a saved layout records what and where, not which — nor across an undo: someone pressing `⌘Z` puts the whole room back, and every piece in it comes back with a new id. Call `room.list()` again after a reload, a `plan.open()`, or when an id you hold is no longer in the room. `warnings` is `[]` unless the spot, though allowed, is worth knowing about. Today that is `"in the swing of a door"`: the item stands where a door's leaf sweeps, so the door will not open fully. That is **never** a refusal — it is a design choice you may mean — but it is usually worth moving. A solid floor item cannot be placed where the walker is standing. Teleport out of the way first; that is what `"you are standing there"` means. The rules are about walls, openings, other furniture and the walker. **There is no rule about being indoors**, so a coordinate outside the building is accepted and puts the item in the garden. `plan.rooms()` gives you `centre` and `bounds`; work from those and it will not come up. The room autosaves. There is no save call and there does not need to be. ### Items duimstok.items.define(definition) -> { item, audit: { declared, measured, drift, originError, triangles, problems }, stored, notes } duimstok.items.remove(id) `define` adds a piece of furniture described as data — see the schema below. Defining again under the same id replaces it, which is the intended way to correct one. **Read the audit.** It builds the geometry your definition describes and measures it against the footprint you declared: declared: [w, h, d] what you said measured: [w, h, d] what the primitives actually came out as drift: the largest disagreement, as a fraction originError: how far the anchor face is from the origin, in metres problems: [] when they agree `problems` being non-empty is **not** a rejection — the item exists and can be placed. It is the fastest way to correct your own numbers: adjust the footprint to match `measured`, or move the parts, and define it again. `stored: false` means the item works for this page load but the browser would not keep it. Nothing is ever deleted to make room; `notes` says what happened. `items.remove()` refuses while instances are still in the room. Remove those first. ### Plans duimstok.plan.create(json) -> { planId, stored, notes } duimstok.plan.open(planId) -> { planId, schema, plan } `create` takes a `house-maker/floorplan@1` document, as an object or as a JSON string. It **never switches to it** and **never overwrites an existing id** — a name that collides is given a suffix. `open` is the only call that takes the room you are standing in off the screen. The floorplan schema is a separate contract, documented at https://github.com/gemelyn/Duimstok/blob/master/docs/FLOORPLAN_SCHEMA.md — and the app's own "Add your own layout" dialog will hand you a prompt for it. ### Camera duimstok.camera.teleport({ at, heading }) -> { at, heading } duimstok.camera.overview({ heading, tilt }) -> { heading, tilt } duimstok.camera.photo({ at, heading, scale, maxWidth, format, quality }) -> { dataUrl, width, height, bytes, format } `teleport` stands the walker at `at`, facing `heading` (default: the way you are already facing). A point inside a wall is refused. **It also closes the start menu**, and leaves the overview if someone is furnishing from above — the reason your first photograph shows a room rather than the menu's turning preview or a view from overhead. `overview` is the other way to be in the house: the whole floor from above, furnished, the way the app's own Overview shows it. `heading` is the way the view looks, degrees clockwise from `-Z`; `tilt` is degrees off straight down, 0 to 70. Both default to 0 — straight down, north at the top, as a plan is drawn. The walls between the camera and the middle of the house are cut away, and every door's swing is drawn on the floor. It closes the start menu too. Those two calls are the only ones on this interface that change what is on screen, and they are the two you call before `photo()`: duimstok.camera.overview({ tilt: 35 }); const plan = duimstok.camera.photo(); // the furnished floor from above `teleport` walks back in. `photo` renders one frame and returns it as a data URL. Every option is optional: - `at` — `[x, z]`; the camera is put at eye height above the floor for this one frame only. Omit it to shoot from wherever the walker is. - `heading` — degrees, as above. - `format` — `"jpeg"` (default) or `"png"`. - `maxWidth` — long edge in pixels, **default 1280**. - `scale` — 1 to 3, render larger than the window. Rarely worth it. - `quality` — 0.1 to 1, JPEG only. Default 0.85. **The defaults exist to protect your context window.** A 1280 px JPEG of a room is usually 40–90 kB — as little as 10 kB facing a bare wall, and up to 150 kB looking down a long room. The same frame as an uncapped PNG can run to megabytes: 9.8 MB, 13 MB of base64, from a 1440 × 900 window on a 2× screen, about eighty times the size, for a picture you are going to look at once. Raise `maxWidth` or ask for `png` only when you actually need the pixels. `bytes` is what the image decodes to, so you can check before reading `dataUrl`. The photo contains no crosshair, no prompts and no panels — those are HTML over the canvas — and the placement preview and the overview's selection are taken out of the frame. If you have not teleported or called `overview` yet, you are still on the start menu and the photo is the doll's-house preview with the ceilings off. That is genuinely what is on screen. Teleport first, or look from above. ## The item schema: duimstok/item@1 A definition is data. No code from it is ever executed. It names primitives, and the app builds them with the same helpers every piece of furniture that ships with it uses. { "schema": "duimstok/item@1", "id": "poang-armchair", "name": "Poäng-style Armchair", "category": "seating", "anchor": "floor", "footprint": { "w": 0.68, "d": 0.82, "h": 0.98 }, "materials": { "frame": { "color": "#c8a06a", "roughness": 0.5 }, "cushion": { "color": "#4a5d52", "roughness": 0.9 } }, "parts": [ { "shape": "box", "size": [0.6, 0.12, 0.62], "at": [0, 0.4, 0.02], "material": "cushion" }, { "shape": "cylinder", "radius": 0.025, "height": 0.46, "at": [-0.315, 0.23, 0.36], "material": "frame" } ] } `schema`, `id`, `name`, `category`, `anchor`, `footprint`, `materials` and `parts` are all required. **Any other key is an error**, and so is any unknown shape — nothing is silently ignored, because the thing you were ignored about is usually the thing you meant. - `id` — kebab-case, at most 48 characters, and not the id of an item that already ships with the app. - `category` — one of: seating, table, storage, bed, lighting, decor, rug, appliance, window-dressing. `rug` is walkable and may have furniture on it; `window-dressing` is the only category allowed to cover a window or a door. - `anchor` — `floor`, `wall` or `ceiling`. It decides where the origin is: - `floor`: centred on the footprint, **resting on y = 0**. - `wall`: centred on the **back face**, extending toward `+Z`, with y = 0 the item's vertical midpoint so placement chooses the hanging height. - `ceiling`: at the mount point, hanging toward `-Y`. - `+Z` is the **front** of an item. A chair's seat faces `+Z`. - `footprint` — the tight bounding box, `w` × `d` × `h` in metres. The audit checks it against what you actually built. - `materials` — a map of your own names to `{ color, roughness, metalness }`. `color` is a hex string and is required. `roughness` defaults to 0.6, `metalness` to 0; both are 0–1. Keep `roughness` at 0.25 or above on anything metallic — the environment here is a smooth sky gradient, so a near-mirror reflects a flat wash and reads as plastic. - `parts` — 1 to 64 of them. Every part has `shape`, `at` (`[x, y, z]` of its centre) and `material`, plus optionally `rotate` (`[x, y, z]` in degrees). - `"box"` — `size: [width, height, depth]`. - `"cylinder"` — `radius`, `height`, and optionally `radiusTop` for a cone. Upright along `Y`. - `"sphere"` — `radius`. No dimension may exceed 5 m and every number must be finite and positive. Real sizes matter: a dining chair seat is 0.45 m high, a worktop 0.90 m, an interior door 2.05 m. Aim for a recognisable silhouette out of 5 to 15 parts rather than detail — the whole catalog that ships with the app is 24 to 360 triangles per item. ## Worked example 1 — define a chair and put it in the room Paste this into the browser console at https://duimstok.gemelyn.com/. await duimstok.ready; const made = duimstok.items.define({ schema: 'duimstok/item@1', id: 'poang-armchair', name: 'Poäng-style Armchair', category: 'seating', anchor: 'floor', footprint: { w: 0.68, d: 0.82, h: 0.98 }, materials: { frame: { color: '#c8a06a', roughness: 0.5 }, cushion: { color: '#4a5d52', roughness: 0.9 }, }, parts: [ { shape: 'box', size: [0.6, 0.12, 0.62], at: [0, 0.4, 0.02], material: 'cushion' }, { shape: 'box', size: [0.6, 0.52, 0.12], at: [0, 0.72, -0.31], material: 'cushion', rotate: [-12, 0, 0] }, { shape: 'cylinder', radius: 0.025, height: 0.46, at: [-0.315, 0.23, 0.36], material: 'frame' }, { shape: 'cylinder', radius: 0.025, height: 0.46, at: [0.315, 0.23, 0.36], material: 'frame' }, { shape: 'cylinder', radius: 0.025, height: 0.98, at: [-0.315, 0.49, -0.3], material: 'frame' }, { shape: 'cylinder', radius: 0.025, height: 0.98, at: [0.315, 0.49, -0.3], material: 'frame' }, { shape: 'box', size: [0.68, 0.05, 0.05], at: [0, 0.34, 0.36], material: 'frame' }, ], }); if (!made.ok) throw new Error(made.errors.join('\n')); console.log('audit', made.audit.measured, made.audit.problems); const room = duimstok.plan.rooms().rooms[0]; // Stand clear: a solid floor item may not be placed on top of the walker. duimstok.camera.teleport({ at: [room.centre[0], room.centre[1] + 2.6], heading: 0 }); const put = duimstok.room.place('poang-armchair', { at: room.centre, rotation: 180 }); console.log(put.ok ? put.placed : put.why); `made.audit.problems` is `[]`, and `put.placed.at` is the centre you asked for. ## Worked example 2 — photograph it await duimstok.ready; const room = duimstok.plan.rooms().rooms[0]; duimstok.camera.teleport({ at: [room.centre[0], room.centre[1] + 2.6], heading: 0 }); const photo = duimstok.camera.photo(); console.log(photo.width + '×' + photo.height, photo.bytes + ' bytes', photo.format); // photo.dataUrl is "data:image/jpeg;base64,…" — an src, or decode it. const img = new Image(); img.src = photo.dataUrl; About 1280 × 800 and usually 40–90 kB, depending on the window and the room. ## What is not here No pointer lock, no walking a step at a time, no doors, no measuring tape UI, no undo. Those are the app's controls, for a person at a keyboard. If you need one of them from a script, it is not in version 1. Anything already running on the page can call this interface. There is no account and no server: the whole of what it can reach is one visitor's own browser storage.