In my Nexus dashboard post I skimmed over the Home 3D view in a couple of paragraphs. It deserves more than that, because it’s the part of the dashboard that fought back hardest.
The idea was simple. The Twin view already shows the house from above, with each light glowing in the right room. I wanted the same thing in 3D: a model of the house I could spin around with a finger, where the lights match the real ones and tapping a bulb switches it.
Getting a model on screen took an afternoon. Getting it to run smoothly on a phone took four rewrites of the renderer.
Note: as with the rest of Nexus, I built this with Claude in the Claude Code add-on. I supplied the photos and the corrections (“the stairs don’t go that way”), and Claude wrote the geometry and rendering code.
The Model
There’s no CAD file behind this. The source is the pair of top-down Floorplanner renders I made when we moved in, the same images the Twin view uses.
A Python script, gen_home3d.py, holds the whole house as data. Everything is traced in render pixels: wall start and end points, door and window positions, floor polygons and furniture boxes. One function converts pixels to metres for each floor:
BOX = {"g": (40, 150, 1870, 1300), "f": (46, 103, 2246, 1390)}
LEVEL = {"g": 0.0, "f": 2.65} # floor height (m)
WALL_H = 2.45 # floor-to-ceiling
def m(fl, x, y):
x0, y0, x1, y1 = BOX[fl]
return (round((x - x0) * FOOT_W / (x1 - x0), 3),
round((y - y0) * FOOT_D / (y1 - y0), 3))
The two renders were taken at different zoom levels, so each floor gets its own bounding box, and both are stretched onto the same 9.15 m × 5.75 m footprint. Where did those numbers come from? I don’t know the real dimensions of the house, so they were calibrated from things I do know: the size of the bed and the sofa. It’s surprisingly close.
Each wall is a line with a thickness and a list of openings. An opening is a door, a window or a glazed door, with a sill and head height. Floors are polygons tagged with a material (carpet, oak, tiles, lino, paving) and the room pop-up to open when tapped. Furniture has a type (sofa, bed, units, wardrobe, bath, toilet, lamp and so on) and the side its back faces. The script writes all of this to a model.json of about 14 KB.
Light positions don’t live in this script. They come from the same coordinate table the 2D Twin uses, so a lamp is in the same spot on every view.
Correcting the Renders
The first version looked right from directly above and wrong from any angle. Floorplanner’s camera is quite close to the house, so in a “top-down” render the tops of the walls appear further out than where the walls meet the floor. Anything traced against the wall tops ends up floating 20–30 cm away from its wall.
The fix is a snap() pass. Any furniture type listed as wall-backed (bookcases, wardrobes, beds, kitchen units and so on) gets pushed flush against the nearest wall behind it, as long as that wall covers at least half of the item. If a traced item pokes into a wall, it’s pulled back out. Whatever sits on it (plants, lamps, the monitor on the desk) moves with it, and items can be told to “follow” another one: the bench at the end of the bed moves with the bed.
After that came a long round of corrections from photos of each room. The angled wall in the living room has a mirror, not a window. The office has one window, not two. The fridge is pale-blue retro. The stairs run straight up along the front wall, partly over the downstairs WC (whose ceiling slopes under them), then turn on winders at the top onto the landing. The stairs alone took three goes.
Getting It into Home Assistant
The view is a custom Lovelace card, nexus-home3d-card, built on three.js r170. I keep a local copy of three.js in /config/www rather than loading it from a CDN, so the view still works if the internet doesn’t.
One annoyance was caching. The Home Assistant Companion app holds on to card JavaScript for a long time, so changes often didn’t show up. The generator now hashes every .js file in the folder and updates the Lovelace resource URL to match:
h = hashlib.sha1()
for f in sorted(os.listdir(d)): # card + worker + engine + three.js
if f.endswith(".js"): h.update(open(d + f, "rb").read())
url = f"/local/nexus/home3d/nexus-home3d-card.js?v={h.hexdigest()[:8]}"
That ?v= is passed down to the worker and the engine as well, so every file reloads together and you never end up with a new card running an old engine.
Version One: A Slideshow
The first renderer was standard three.js: one mesh per piece of furniture, a real point light for every bulb, a directional “sun” with shadow maps, and the stock OrbitControls. On my laptop it was fine. On my OnePlus 10 Pro it was a slideshow.
There were three problems. Every real-time light adds cost to every pixel, and I had around a dozen. Shadow maps mean drawing the scene again from each light’s point of view. And there were lots of separate meshes, each needing its own draw call.
There was a sneakier fourth problem too. Home Assistant gives a card a new hass object whenever any entity in the house changes. Power sensors update every few seconds, so the card was restarting about a second of rendering many times a minute, even when nobody was touching it.
How It Runs Smoothly Now
The current engine (home3d-core.js, about 850 lines) works on one rule: do the expensive work once at load, and make each frame as cheap as possible.
1. Baked Lighting
There are no real-time lights or shadows. When the model is built, sun shading is worked out once for every vertex and stored as a colour. A soft “contact darkening” near the floor gives the furniture some weight:
let shade = floor ? 0.98
: 0.6 + 0.32 * Math.max(0, n.dot(SUN)) + 0.1 * Math.max(0, n.y);
if (!floor) shade *= 0.8 + 0.2 * Math.min(1, Math.max(0, pos.getY(i)) / 0.5);
Because the sun’s direction is fixed, the result never changes, so the GPU never has to work it out again.
2. Room Lights in One Small Shader
So how do the lights work without lights? At build time every triangle is tagged with the room it’s in, using a point-in-polygon test on its centre. That room number is stored as an extra attribute on the geometry. The shader keeps a small array with one colour per room. When the ceiling light in the kitchen is on, the kitchen’s entry gets warm white, and every triangle tagged “kitchen” brightens. Nothing else changes.
Lamps are handled a bit differently, because a lamp should light a pool around itself rather than the whole room. There are up to eight lamp slots, each with a position, radius, colour and room number:
int r = int(vR + 0.5);
vec3 L = vec3(amb) + roomL[r];
for (int i = 0; i < 8; i++) {
if (abs(lamp[i].w - vR) < 0.5) { // only inside the lamp's own room
float k = clamp(1.0 - distance(vXZ, lamp[i].xy) / lamp[i].z, 0.0, 1.0);
L += lampC[i] * k * k;
}
}
gl_FragColor = vec4(base * L, opacity);
The room check is what stops light leaking through walls, the same rule I had on the 2D Twin. There was one catch. A long wall is just two big triangles, and both get tagged with whichever room their centre lands in. So one room’s lamp glow could run along the wall into the next room. The fix was to build walls from short sections, 25 cm or less, so each bit of wall takes the light of the room it actually faces.
Switching a light is now just a few numbers in an array changing. Day and night work the same way: sun.sun sets the ambient level, and room lights count for more after dark.
3. One Mesh per Floor
Every wall, floor, sofa, plant and cushion on a floor is merged into one big geometry with mergeGeometries, split only by material: plain, glass, each floor texture and the stone exterior. The whole house comes to about twenty draw calls.
The Cutaway and Full walls buttons don’t rebuild anything. Both versions of the walls are built up front, and the button just shows one and hides the other. The Ground, First and Both buttons work the same way, by hiding or moving a whole floor group.
4. Detail Only Where It Shows
Furniture uses RoundedBoxGeometry for the soft edges you’d expect on a sofa or a mattress. A rounded box costs around 300 triangles, though, and a table leg or drawer handle is too small to notice the difference. So small parts stay as plain 12-triangle boxes. The whole house, with both floors, the decor, the stairs and the stone exterior, comes to roughly 50,000 visible triangles.
Floor textures (the carpet speckle, oak planks, the blue-and-white bathroom tiles, patio paving and Yorkshire stone) are all drawn in code onto a small canvas once at load. There are no image files to download, and each texture repeats across the floor using world coordinates, so you never see a seam.
5. Only Draw When Something Changes
There’s no constant animation loop. A frame is drawn only when you’re dragging, while the view is still gliding after you let go, or when a light changes. The rest of the time the GPU does nothing at all, which also matters for battery life on a wall tablet.
The card also ignores most of the house. It builds a list of the entities it actually shows (the lights, the room temperature sensors and sun.sun) and does nothing unless one of those has changed:
_sync(force = false) {
const st = this._hass.states, seen = this._seen ||= {};
if (!force && this._watch.every(id => seen[id] === st[id])) return;
...
}
Home Assistant reuses the same state object when an entity hasn’t changed, so this is a cheap reference check rather than comparing values.
6. Room Labels as Sprites
Room names and temperatures used to be HTML elements, repositioned over the canvas every frame, which is a classic way to make a 3D view stutter. Now each label is drawn onto a small canvas texture and shown as a sprite that stays the same size on screen. It’s only redrawn when its text changes, such as a new temperature.
The Big One: Moving It off the Main Thread
After all of that it was much better, but it still hitched now and then. The cause wasn’t the 3D at all. Home Assistant’s frontend does a lot of work on the browser’s main thread: websocket messages, other cards re-rendering, the sidebar. Whenever that work ran, my frame had to wait.
The fix was to move the whole engine into a Web Worker. The card hands control of its canvas to the worker with transferControlToOffscreen(), and from then on the worker owns WebGL entirely:
const off = this._canvas.transferControlToOffscreen();
const worker = new Worker(new URL(`./home3d-worker.js${V}`, import.meta.url),
{ type: "module" });
worker.postMessage({ type: "init", width: w, height: h, dpr, model,
mode: this._mode, canvas: off }, [off]);
The card on the main thread is now very thin, about 170 lines. It forwards pointer events, resizes and the few states it cares about as small messages. The worker sends back only one thing: what you tapped. If you tapped a bulb, the card calls light.toggle. If you tapped a floor, it opens that room’s pop-up on the Twin view. Rendering never waits on the dashboard again.
To make that work, the engine had to be “DOM-free”: no document, no window, nothing a worker doesn’t have. Textures use OffscreenCanvas, and the render loop falls back to setTimeout if requestAnimationFrame isn’t available. If a browser can’t do OffscreenCanvas WebGL at all, the card loads the same engine on the main thread instead, so it still works, just with the old occasional hitch.
Smaller Touches
- Own orbit controls.
OrbitControlsneeds DOM events, which a worker doesn’t have. Its replacement is about 60 lines: one finger orbits, two fingers pinch-zoom and pan, and there’s inertia after you let go. If your finger rests for more than 70 ms before lifting, there’s no fling, which stops the view drifting off when you meant to stop. - Low-latency context. WebGL2 with
alpha: false(an opaque canvas is cheaper for the browser to composite),desynchronized: trueto cut latency, andpowerPreference: "high-performance". - Capped pixel ratio. The OnePlus has a 3.5× screen. Rendering at 1.75× on touch devices cuts the number of pixels to a quarter, and the difference is hard to see.
- Latest touch only. Each pointer move sends only the newest position, with no event coalescing to add lag. The canvas uses
touch-action: noneso the browser doesn’t try to scroll the page mid-drag. - No blur over the canvas. The toolbar was frosted glass at first.
backdrop-filterover a WebGL canvas makes the browser re-blur every frame, so the toolbar is now solid.
The result: the model builds in about a third of a second, and orbiting is smooth on the OnePlus 10 Pro. That was the original goal.
Testing Without a Screen
The rest of Nexus is tested by rendering screenshots with the Puppet add-on. That doesn’t work here, because Puppet’s headless Chrome has no WebGL and just shows Error creating WebGL context.
Instead there’s a Node smoke test (t4.mjs). It loads the real engine with a fake renderer swapped in, builds the whole house, then sends it some light states, a mode change, a drag and a tap. It prints the build time and triangle counts, and checks that tapping the screen picks the right room. A second script renders top-down previews to SVG with three.js’s SVGRenderer, which is how the geometry was checked before I ever saw it on my phone.
Why does that matter? Because the generator publishes the engine: it bumps the resource URL, and every open dashboard picks up the new code. Twice, a broken edit (a stray inline comment that swallowed a line of code) went live for under a minute because the generator ran before the test did. Now the smoke test always runs first.
What’s Next
The obvious one is real measurements. Everything is still calibrated from the size of my sofa, so a tape measure and an hour would make it properly accurate.
After that I’d like more of the house to be live: blinds that open and close on the model, and maybe doors that show open or closed from their sensors. The shader approach makes that fairly cheap, since it’s just more numbers in an array.
The main lesson from all four rewrites is that the 3D part was never the hard bit. What mattered was how often it redraws, and what else is competing for the same thread.