Every chair in this house is a TypeScript file
Duimstok renders a 52.80 m² ground floor at true scale. You walk through it in first person, open the doors, take a sofa out of a catalog and set it against a wall, then stretch a measuring tape between two points and read the distance in centimetres.
The repository that produces all of that holds no 3D models. There is no
.glb, no .gltf, no .fbx and no
.obj anywhere in it, and no image textures either. The only binary
file in the whole project is a social preview image, which exists so that a shared
link has a picture to show. Every wall, chair, lampshade and rug you see in the
room is TypeScript that runs.
This was decided on the first day and written down as an architecture decision record, because it is the kind of rule that is cheap to adopt and expensive to reverse. Nine months of building later, it has held. Here is what it actually looks like.
A coffee table, in full
This is the entire source of one piece of furniture. Not an excerpt — the file:
import * as THREE from 'three';
import type { ItemManifest, BuildContext } from '../types';
import { box, legs } from '../parts';
/**
* Coffee table, 1.10 × 0.60 m and 0.42 m high — deliberately the same height as
* the sofa's seat, which is what makes one usable from the other.
*
* The lower shelf is the only reason this is not four legs and a top: it gives
* the silhouette something to read at a distance, for 12 triangles.
*/
const item: ItemManifest = {
id: 'coffee-table',
name: 'Coffee Table',
category: 'table',
anchor: 'floor',
footprint: { w: 1.1, d: 0.6, h: 0.42 },
tags: ['living', 'oak'],
build(ctx: BuildContext): THREE.Group {
const wood = ctx.material('oak-warm', { color: 0xa9793f, roughness: 0.55 });
const shelf = ctx.material('oak-warm-dark', { color: 0x8a5f2f, roughness: 0.6 });
const group = new THREE.Group();
const W = 1.1;
const D = 0.6;
group.add(box([W, 0.04, D], [0, 0.4, 0], wood));
for (const leg of legs(W, D, 0.38, 0.05, 0.04, wood)) group.add(leg);
group.add(box([W - 0.22, 0.02, D - 0.16], [0, 0.14, 0], shelf));
return group;
},
};
export default item;
Thirty-six lines, including the comment. A top, four legs, and a lower shelf, positioned in metres. The catalog holds twenty of these files today, and the starter items run from 24 to 360 triangles each.
Two details in that file carry more weight than they look like they do.
The first is the comment. It records that the table is 42 cm high because that is the height of the sofa seat beside it, and that the lower shelf earns its twelve triangles by giving the silhouette something to read from across the room. That reasoning lives three lines above the numbers it explains. In a modelling tool it would live in someone's head, or in a Notion page nobody opens.
The second is ctx.material('oak-warm', …). Materials come from a
shared cache keyed by appearance rather than by role, so every oak surface in the
house is literally the same THREE.MeshStandardMaterial instance. That
has a consequence we will come back to, because it is also the one genuine trap in
the whole design.
There is no list of items to maintain. The registry finds them with
import.meta.glob('./defs/*.ts', { eager: true }) and requires that an
item's id equals its filename. Adding furniture is creating one file.
There is no second place to register it, so an item cannot exist and be invisible
at the same time — which is the failure mode that every manually maintained
registry eventually produces.
Why refuse the asset
The default path here is well paved. You download or model a .glb, you
load it with GLTFLoader, and you have a chair in an afternoon.
Refusing that needs a reason.
The reason is that an asset is opaque. It cannot be diffed, reviewed, parameterised or corrected without the tool that made it. Ask a colleague to review a pull request that changes a binary and the honest answer is that they cannot. Ask an agent to make the table 20 cm longer and it can only shrug, because the geometry is not something it can read. Change one number in the file above and the diff shows exactly what moved and by how much.
Keeping the project entirely textual has a second effect that compounds quietly. There is no binary in the repository, so there is no licensing question about where a model came from, no asset pipeline, no build step that fetches anything from a server that might be gone next year, and no git history slowly filling with four-megabyte revisions of the same sofa. A clone is a clone.
The walls work the same way
Furniture is the visible half. The house is the half where the rule had to survive contact with a genuinely hard problem, and that problem is holes.
Walls need rectangular openings in them for doors and windows. The textbook solution is constructive solid geometry: build a wall box, build an opening box, subtract the second from the first. Every CSG library will do this.
Duimstok subtracts nothing. house/wall.ts splits a wall into several
boxes instead — a pier between each pair of openings, plus a sill below and a
header above each one — and assembles the wall from those pieces. The same
technique splits a door leaf around its vision panel and its letterbox.
Splitting works here because of a property of the specific problem rather than a property of geometry in general. The holes are rectangular, the walls are rectangular, and their edges are axis-aligned in the wall's own local frame. Under those three conditions the split is exact, it is instant, and it emits clean quads. CSG would be slower, would produce fragile geometry along shared edges, and would add a dependency, all to solve a harder problem than the one actually present.
The cost is stated plainly in the decision record: only rectangular openings are possible. An arched window would need a real change of approach. That is a limit we accepted with our eyes open rather than a bug waiting to be found.
The split also threw off a side effect that turned out to matter elsewhere. Because a pier is emitted per span, every collider endpoint in the house is either a room corner or the jamb of an opening. That was not designed; it fell out. It later tempted us into a related mistake about measurement snapping, which is its own decision record and its own story.
The rule the whole thing rests on
build() must be pure and repeatable. No module-level mutable state, no
unseeded Math.random(). If an item can render differently on two
calls, the same chair looks like two different chairs in two places, and nothing
downstream can be trusted.
A manifest can also lie. Nothing stops a file from declaring a footprint of 1.1 × 0.6 m and then building geometry 1.3 m wide. That kind of lie is invisible at the point it is written and surfaces much later as furniture placed half inside a wall, at which point it gets debugged in entirely the wrong file.
So the registry audits it. auditItems() compares each item's declared
footprint and anchor origin against the bounding box the item actually builds, and
?view=items runs the audit. A discrepancy is caught in the file that
caused it, on the day it was written.
The dividend nobody planned
The catalog needed pictures. Listing an item as "Costa Dining Table, 2.20 × 1.02 × 0.77 m" is legible for seven items and stops being legible well before seventy, and the start menu shows the whole inventory at once.
Every other app ships a folder of PNGs. This one cannot, by its own rule.
So ui/thumbnails.ts renders each picture from the very same
buildItem() the room uses, into a temporary off-screen
WebGLRenderer, and caches the result as a data URL keyed by item id.
The renderer is created inside that one call and destroyed at the end of it, with
dispose() and forceContextLoss(), because a browser
grants a page somewhere around sixteen WebGL contexts and this one is idle after
its first pass.
What makes this work at all is purity. Because build() is repeatable,
an item renders the same picture every time and the cache can never go stale. Had
items been allowed module state or an unseeded random, every session would produce
different pictures and the whole approach would be worthless. A constraint adopted
for reviewability paid out, much later, as a feature.
It also set the trap promised earlier. Items draw on that shared material cache,
and disposeGroup() frees materials. Tidying up a 256-pixel picture of
a sofa with disposeGroup() would blank every sofa in the house.
Thumbnail geometry is freed with disposeGeometry() and nothing else.
The same rule already governed the item placer, for exactly the same reason, which
is usually the sign that a rule is real rather than local.
What it costs
Furniture is low-poly by necessity and looks it. Twenty-four to three hundred and sixty triangles buys a recognisable chair, not a beautiful one. We took that trade deliberately: a recognisable low-poly match beats an unfinished high-detail one, and for a tool whose job is answering "does this fit and does it work here", recognisable is the requirement.
Building furniture is also slower than downloading it, at least at first. The
twentieth item is much faster than the first, because by then parts.ts
has box, cylinder, sphere and
legs in it and most items are compositions of those. The early ones
are genuinely slower.
And some things are simply closed off. Arched windows, as noted. Anything organic. Anything where the visual target is photorealism rather than legibility.
When to make the other choice
This rule fits a narrow set of conditions, and it is worth being honest about which.
It works here because the subject matter is architectural, which means it is mostly boxes at right angles; because the visual bar is legibility rather than realism; because the catalog is twenty items rather than two thousand; and because the person writing the geometry is the same person writing everything else.
Change any one of those and the answer flips. If you need photoreal interiors, buy or model assets. If your catalog runs to thousands of items, no one is hand-writing them. If you have artists on the team, taking away their tools to make them write TypeScript is a bad trade for everyone. Load the models, and set up the pipeline properly.
But if you are building something architectural, small, and precise, and you have been assuming an asset pipeline is the price of entry to 3D on the web, it is worth knowing that it is not. Anything that seems to need a downloaded asset here did not. It needed to be built.