Solutions architecture · Live at openhouse.mellette.net · Personal project
OpenHouse
A 3D room planner in the browser. A real barn-style room is modelled to the millimetre, sloped ceiling included. You lay out furniture in a CAD-style plan, orbit it, or walk through it, and all three cameras share one data model and one scene definition, with no backend.
Status
Live; paused, with no commits since
Version
v0.2.5 live (deployed 24 Sep 2026); v0.3.2 in the repo. Work from the 16 commits since the deploy is marked in the repo, not yet live below.
Licence
AGPL-3.0-or-later, chosen for a personal project; private repository
Plan a real room before buying the furniture, when the ceiling comes down and a 2D plan can’t show what fits.
At a glance
cameras × render styles, over one scene definition
3 × 2
lines in the first commit of first-person walking, the third camera; data model untouched
+184/ −5
backend calls; layouts live in the browser or a file you export
0
to a live 3D canvas in our test (384–443 ms over 10 cold loads)
<0.5 s
How these were measured
3 × 2: read from source. The store defines three camera modes (plan, orbit, walk) and, separately, two render styles (blueprint, realistic); one canvas component picks an orthographic or a perspective camera per mode over the same scene components.
+184 / −5: git show --stat on the first Walk-mode commit (6 Jun 2026) lists four files changed; the data-model types file is not among them. That commit had looking and moving only; gravity, step-up, jumping and collision came in later commits, and git log -i --grep=walk --stat shows none of them touching the types file either.
0: a source search for fetch(, XMLHttpRequest, WebSocket, sendBeacon and EventSource finds nothing; layouts persist in browser storage and in a JSON file the user exports. A file search finds no PNG, JPEG, WebP or GIF in the repository, and the live bundle holds no inline image data. Loading the live page contacts one other host family: Google Fonts.
<0.5 s: Playwright with headless Chromium, a fresh browser context per load, timed from navigation until the canvas element exists. Ten cold loads in two runs on 12 Oct 2026 ranged from 384 to 443 ms. Network and machine change this, so read it as “under half a second in our test”.
For scale: 6,188 first-party lines (TypeScript, CSS, shell and the Vite config) and 59 commits, counted from the committed tree and git history on 12 Oct 2026.
Fig. 1 Same camera, two render styles: Blueprint, then Realistic, whose wood grain and carpet are generated in code. Canvas only, from the live site (v0.2.5) in headless Chromium, 12 Oct 2026.
The short version
One data model in millimetres and three cameras; the first cut of the third took 184 lines. Plan, Orbit and Walk share one scene and one store, so walking needed no new renderer.
Sloped, raked geometry modelled to the millimetre exposed a bad tape measurement. One wrong depth broke the slopes and the door clearances; corrected, both matched the real room.
One collision description powers both walking and placement. The same boxes let a walker stand on a desk and let a dropped monitor land on the desk top, not the floor.
Case 1 of 5
One model, three cameras
Two day-one decisions (a renderer-agnostic model in millimetres, and no separate 2D system) made the first version of the third camera a 184-line change.
The model lives in a store that knows nothing about rendering: every length in millimetres, and every piece with a height and a floor offset from the first commit. The metric/imperial switch changes only the display, and conversion happens once, at the render boundary, where millimetres become metres and CAD-style Z-up becomes three.js’s Y-up.
Nothing is drawn by a second, 2D system: Plan is an orthographic camera locked top-down over the same scene components, so a footprint on screen maps one-to-one onto the floor; Orbit is a perspective camera. The planned 2D drawing library never became a dependency.
The payoff came the next day: the first cut of Walk mode shipped as a first-person third camera, touching four files, with no new renderer and no change to the model. Gravity, step-up, jumping and collision followed over the next three days, and they left the model alone too.
Fig. 2 The Plan camera: the same scene, orthographic and locked top-down, with door-swing arcs and furniture footprints on a one-foot grid. Live site (v0.2.5), 12 Oct 2026.How we know
Read from source on 12 Oct 2026: the model types (every length in millimetres, with a height and floor offset per piece), the display-only unit formatter, the one units module that converts millimetres to metres and swaps the axes, and the canvas component that chooses the camera per mode. git log -S konva finds the 2D library only in a planning document, never in the package manifest. git show --stat on the first Walk-mode commit (6 Jun 2026) gives +184 / −5 across four files, none of them the types file. The startup rule that rebuilds built-in rooms from code, not from the saved copy, holds in the live v0.2.5 source as well.
Case 2 of 5
Geometry you can’t plan in 2D
Modelling a barn-style room’s slopes to the millimetre turned a wrong tape measurement into a visible error on the second day: the slopes and door clearances stopped fitting.
The first room is a real barn-style room with knee walls and a sloped ceiling, where a tall piece that fits against one wall won’t fit where the ceiling comes down. One slope description, an off-centre flat with a different pitch on each side (about 40° and 41°), drives roof height, gable walls and trim clipping.
Door casing on a gable wall is cut against the slope plane with three.js local clipping planes, as real raked trim is cut, so it follows any change in pitch. The starter layout sets a buffet just clear of a door’s swing arc, and a comment in the code marks the two as coupled, so moving one means checking the other.
Tray ceilings and closet notches followed for a second built-in room. That work is in the repo, not yet live.
Fig. 3 Facing a gable end in Orbit: knee walls, then two slopes up to the flat ceiling. Live site (v0.2.5), 12 Oct 2026.How we know
Read from source on 12 Oct 2026: the room-shell component derives slope geometry, roof height and the slope plane from one description, and applies clipping planes only to the door slab and its casing; local clipping is switched on when the canvas is created. The two pitches are recorded beside the room’s measurements. The depth correction and the asymmetric roof landed together on 6 Jun 2026; the buffet’s door-swing clearance is the v0.2.5 change; the tray ceiling and closet notch arrived after the deploy (v0.3.0).
Case 3 of 5
One collision model, two features, and walls that recover on their own
One description of each piece’s solid boards drives first-person collision and editor placement alike.
One function describes each piece’s solid boards as boxes, mirroring what is drawn: dividers, shelves and doors for a cube shelf; a top slab and two pedestals for the pedestal desk; shelves, legs and feet for the printer stand; one solid block for anything else.
In Walk mode, with the walker’s eye 127 mm off the floor, that lets you stand on a desk, walk under it, bump your head on a shelf, and climb steps only up to 4 cm. In the editor, a monitor dropped onto a desk lands on its top, not the floor.
Walk-mode walls follow one monotonic rule: a move may never increase how far outside the room’s rectangle you are. That makes the outer walls solid (the small wall notch isn’t carved out yet) and also recovers from bad states, because from outside the only allowed moves lead back in. A separate rule frees a walker already clipped into furniture.
How we know
Read from source on 12 Oct 2026: the editing module’s per-piece box function (branches for the cube shelf, the pedestal desk and the printer stand, with one solid block as the default), the surface, ceiling and can-stand queries built on it, the room-penetration measure, and the drop-time collision resolver, which uses rotation-aware bounds. The walk controls set a 0.04 m step limit and a 0.127 m eye height, and accept a move only if room penetration does not grow. Box collision, solid walls and surface-snap were committed on 8–9 Jun 2026 and are live in v0.2.5.
Case 4 of 5
Interaction bugs fixed at the root cause
Dragging failed silently because the camera controls took the press; the fix removed the cause instead of working around it.
On day one, dragging failed without an error: a piece could be selected but not moved. The camera controls took the pointer-down even with pan and rotate off, and react-three-fiber’s on-demand render loop froze updates made outside React. The fix turns the controls off entirely in Move and Rotate and renders continuously; each drag records exactly one undo step. That fix is live.
Two later fixes are in the repo, not yet live. A capture-phase listener extends right-drag panning to Move and Rotate, waking the controls only while the right button is held. And drop push-out checked every piece in the house although positions are room-local, so a desk was pushed 448 mm off its wall by a desk in another room at the same local spot. The fix checks the same room only, tested against a control: a same-room overlap must still push.
How we know
Read from source on 12 Oct 2026: the drag layer keeps a per-drag flag so it pushes one undo snapshot, and resolves snap, wall-snap and collision on release; the camera controls are enabled only while navigating; the canvas sets no on-demand frame loop. The root-cause fix is from 5 Jun 2026. The same-room filter in the collision resolver (v0.3.1) and the right-button listener (v0.3.2) exist only after v0.2.5; both were committed on 24 Sep 2026, and the filter’s commit records the 448 mm displacement and the control case.
Case 5 of 5
A guarded deploy for a static app
Production has one path: a script that refuses a build that fails the strict compile or comes out empty, then checks the live site.
The script gates on the strict TypeScript compile, builds, and aborts if the build has no index.html. It streams the build to a shared host over ssh, swaps the new assets folder in by rename instead of overwriting in place, and confirms the live URL answers HTTP 200 before reporting success.
The tooling around it fixes what a cloud-synced folder broke. Build output and the Vite cache live outside the synced tree, which raced Vite’s dependency optimizer; tools run through node because the sync strips execute bits; .gitattributes forces LF endings after a cross-platform move. The dev-server manager stops only the process listening on the project’s registered port, and Vite’s strictPort makes a collision fail loudly.
How we know
Read on 12 Oct 2026: the ship script (set -euo pipefail; compile and build through node; the index.html check; a tar stream over ssh; the rename swap; a curl status check), the Vite config (cache and output under the home directory, a fixed port with strictPort), the dev-server script’s stop routine (lsof on the listening port, a numeric check on each process ID) and .gitattributes. Host and account details are left off this page on purpose.
More views
Fig. 4 The whole interface in Orbit: the room, piece and catalog sidebar, and header switches for camera, render style and theme.Fig. 5 Plan view in the light theme. Both from the live site, 12 Oct 2026.