← All Writing
July 20, 202623 min read

Turning the Parking Tracker Into a 3D Model of My Block

There’s a webcam on my block that counts how many of the seven street parking bays are taken. Now there’s also a 3D model of that block, running in a browser tab, where a car shows up in a bay only when the bay is actually occupied right now.

YieldA live 3D model of my block in a browser tab — road, curbs, parkway grass, street trees, the real buildings across the street, and a car parked in each of the seven bays the tracker currently reports as occupied
DifficultyIntermediate (hand-written Three.js modules, free Creative-Commons assets, no game engine and no build step — the hard part is measurement, not code)
Total Cook TimeSeveral evenings across a couple of weeks — most of the calendar time went to debugging, not to building the street

Ingredients

What It Actually Looks Like

The finished thing is a Three.js scene of about a dozen modules, roughly 3.7 million triangles with everything visible, that renders my block live in a browser tab and repaints from the tracker’s feed every two seconds.

Open the dashboard, click the 3D tab, and you get my block. Asphalt with tire-polished lanes and oil stains in the parking spaces. Two curbs, with the driveway cuts in the right places. Parkway grass, street trees, the three buildings that actually stand across from me, a skyline behind them. You can drag to orbit around it.

And there are cars at the curb. Not decoration — each of the seven tracked bays renders a car only when the live feed says that bay is occupied. If four of seven are taken right now, you see four cars, in those four spots, and the readout in the corner says live · 4/7 bays filled. Come back in ten minutes and it’s different, because the street is different.

The 3D model of the block rendered in the browser, seen from an elevated angle looking down the street. Four cars sit in the far parking bays labelled F1 to F4 and three in the near bays labelled N1 to N3, each with a dark label pill floating above it. Purple-blooming jacaranda trees line both sides of the road, a red no-parking stretch runs along one curb, and apartment buildings, a street lamp, a downtown skyline and a Hollywood sign on the hillside fill the backdrop.
The twin as it renders today, from the default vantage — the same high, looking-down angle as the real webcam. Seven labelled bays, F1 to F4 across the street and N1 to N3 on my side; the cars in them are the live occupancy signal, not scenery. The jacarandas are in bloom, which is lovely here and a running problem for the detector in the companion post.

None of it is a game engine. It’s a folder of small JavaScript modules that each build one part of the street, composed by a single entry file and served straight to the browser. Here’s the order it went together in.

Step 1: Write the Street Down as Numbers

I wrote the entire block down as a stack of numbered Z-bands before modelling anything, and that one document is what let ten separate modules agree on where the street is. It also produced the single biggest realism fix in the project: narrowing the road from 45 units deep to 19.

The thing I got most wrong at the start was treating this as a modelling problem. It isn’t. Before anything gets drawn you have to decide where the street is — and then every module has to agree. So the first real artifact wasn’t geometry, it was a comment block: the scene runs along X (down the street) and Z (across it), and the block is a stack of bands in Z, each with explicit numbers. The road runs Z −30 to −11. The near sidewalk is −5.5 to 0.5. Every module reads those numbers instead of inventing its own.

That spec is where the realism win came from, and it was arithmetic, not art. The first version had a road 45 units deep. It looked wrong in a way I couldn’t name for days, until I counted car widths: the street read like ten lanes across. A real residential block here is two travel lanes plus one parked lane at each curb. Pulling the far side 26 units toward the camera took the road to 19 units deep, and the scene stopped looking like a boulevard. Every far-side element then had to shift by that same +26 — the far parkway, the hedges, the building facades, the treeline, the streetlight, the far parked row — which is exactly the kind of change a written-down spec makes survivable instead of fatal.

This is also the first place Claude Code earned its keep, and the prompt is worth copying because of what it asks for: not geometry, but a contract.

Claude Code — the spec prompt
# before any geometry exists

Before we model anything, write a WORLD BAND SPEC as a comment
block at the top of ground.js. The street runs along X; give me
explicit z-ranges for far frontage, far sidewalk, far parkway,
far curb face, road, near curb face, near parkway, near sidewalk,
near frontage.

Constraints: this is a residential street, so the road is TWO
travel lanes plus one parked lane at each curb — not a boulevard.
Put the near curb face at z=-11 and work outward from there.

Then list, per band, which module will own it (ground / grass /
houses / vehicles), so every module reads these numbers instead
of hardcoding its own.

The most useful prompt in the project asked for a document, not a mesh. Every later module was written against it.

🔧 Developer section: The world band spec

THE BLOCK, AS A STACK OF NUMBERSfar frontage — lawns, buildingsfar sidewalk-36.5 to -33.5far parkway — trees, streetlight-33.5 to -30far curb face — z = -30far parked lane — bays F1 to F4two travel lanesthe road is 19 units deepnear parked lane — bays N1 to N3near curb face — z = -11near parkwaynear sidewalk-5.5 to 0.5near frontage — my side of the streetEvery module reads thesenumbers instead of inventingits own.The camera sits past the bottom band, looking across and down at the street.
The spec the whole scene is built against. Getting the road from 45 units deep down to 19 did more for realism than any texture I added afterwards.

Step 2: Lay the Ground

I built the road, sidewalks and curbs as three flat planes and a set of curb boxes, then layered nine wear decals on top. Swapping the flat grey planes for real photographed material sets was the single cheapest believability upgrade in the project — one afternoon, zero modelling.

With the bands settled, the ground is almost boring: one flat plane per band, 240 units long, rotated flat and positioned at that band’s centre. Asphalt for the road, concrete for the sidewalks, grass in its own module.

The surfaces are real photographed material sets — colour, normal, roughness and ambient-occlusion maps from ambientCG, all CC0. That’s the highest-leverage free upgrade in the entire project. A flat grey plane looks like a prototype; the same plane with a real asphalt material on it looks like a road, for no modelling work at all.

What sells it after that is the wear, and every mark is a translucent flat decal drawn over the road in a stacked order: tire-polished bands down each travel lane, sun-bleached patches with a soft radial falloff, a fresher repave rectangle, tar-snake cracks from a procedural squiggle texture, and oil stains sitting exactly where cars park. I spent longer on the bleached patches than I’d like to admit — the first version had such a wide, soft falloff that it read as fog hovering over the road.

The curbs are the detail that matters most later. They’re not one long box: each curb is a series of segments with gaps where the real driveways are, because a driveway means the curb is physically cut. Those cuts become no-parking zones for everything downstream — parked cars, ambient traffic, the red curb paint — so the geometry and the rules stay in sync by construction rather than by me remembering.

🔧 Developer section: Ground, curbs and wear decals

Step 3: Build Upward From the Bands

I split everything above ground level into one module per subsystem — lawns, buildings, trees, hedges, palms, street furniture, backdrop — each reading the band spec and owning nothing else. The payoff was being able to rebuild the building facades four times without touching a single line of road code.

Everything above ground level hangs off the same spec, which meant each piece could be its own module and I could work on one without breaking the others.

The facades are the part worth stealing, and the prompt is the whole trick. The naive version — a solid wall with glass rectangles pasted on it — renders flat and dead at any distance, because real windows read as windows by virtue of sitting in a hole with a shadow in it. Asking for genuine holes changes the whole look.

Claude Code — punched facades
In houses.js, build the front facade as a REAL punched slab, not
glass planes glued to a wall.

# the technique
Use THREE.Shape for the facade outline, push a THREE.Path into
shape.holes for every window opening, and ExtrudeGeometry with
bevelEnabled:false to give the slab depth. Then pull the building
body back behind it to leave a cavity, line each hole with a
4-bar reveal ring, and set the glass at the cavity floor so it
reads recessed from across the street.

# the constraint that bit me
Every opening rect must sit fully inside the facade bounds
vertically or ExtrudeGeometry triangulation breaks silently.
Add an assertion.

Naming the actual three.js primitives (Shape, Path, ExtrudeGeometry) is what separates a usable prompt from “make me a building.”

Step 4: Light It With the Actual Sun

I wired the sun’s position to my street’s real latitude and longitude and the system clock, so the twin’s shadows match the shadows outside my window at that hour on that date. It costs about forty lines of solar-position math and it is the cheapest thing in the project that makes it feel like a specific place.

Lighting is an HDRI environment for soft ambient light plus one directional sun for crisp shadows, with filmic tone mapping over the top. The part I like: the sun’s position isn’t a setting. It’s computed from my street’s real latitude and longitude and the current clock, using standard solar-position math. So shadows in the twin fall the way shadows on my actual block fall, at that hour, on that date — and after sunset the scene goes to night with streetlight pools.

Concretely: the rig loads venice_sunset_1k.hdr through three.js’s RGBELoader, runs it through PMREM for the environment map, and adds a DirectionalLight with a 4096×4096 shadow map plus a HemisphereLight for fill. The solar math is the standard NOAA/SunCalc formulation — mean anomaly, ecliptic longitude, declination, hour angle — evaluated against 34.0538, -118.4431. Renderer is on ACES filmic tone mapping with a per-mode exposure, and the composer chain adds ambient occlusion, a restrained bloom, a vignette-plus-saturation pass and anti-aliasing — each one wrapped so that if it fails to load, the scene degrades to a plain render rather than a black frame.

There are URL overrides for golden hour, noon or night so I can check the scene without waiting for the weather, but the default is the real sun — and that mattered more than shadow quality did. A model of a place that’s lit like somewhere else stops feeling like that place.

Step 5: Get Real Cars Into It

I replaced my hand-built parametric cars with 22 real Creative-Commons models from Sketchfab and put them through a decimation pipeline that took the Porsche from 16 MB to 1.6 MB — the whole fleet now loads in about 16 MB. Then I had to write a normalisation pass, because 22 models from 22 authors agree on nothing.

I built parametric cars first — bodies assembled from numbers — and they were fine, and they cheapened the whole thing. Real downloaded models were a big step up, and they brought the entire second half of this post’s problems with them.

Raw Sketchfab models are far too heavy for a browser tab — the Forester source is about 480,000 triangles across 162 meshes, and the Porsche was 16 MB by itself. A build script runs every model through the same pipeline: weld vertices, simplify toward a shared triangle budget, then compress geometry with Draco and re-encode the textures. That Porsche comes out at 1.6 MB and the whole fleet lands around 16 MB.

🔧 Developer section: The glTF decimation pipeline

Then each model has to be normalised, because downloaded assets agree on nothing. My scene’s convention is: length along +X, width along Z, wheels at zero. Getting an arbitrary .glb into that convention is four steps at load time.

Step 6: Put the Right Cars in the Right Places

I mapped the tracker’s camera-pixel bay polygons onto the world ground plane, then threw away the raw positions and packed the bays nose-to-tail instead. All seven now sit between 0.03 and 0.07 units past their curb face, which is about where a real parked car sits.

The tracker knows where its parking bays are, but it knows in camera pixels — each bay is a polygon drawn on a 1920×1080 frame. So there’s a small mapping function that turns a pixel position into a position on the world ground plane, and each bay’s polygon centroid becomes an X position along the street.

That gets you approximately right and visibly wrong, because perspective squashes the far end of the frame: bays that are genuinely a car-length apart on the real street come out bunched or smeared. So on top of the raw positions there’s a packing step. For each side of the street, take the bays in order, lay them out nose-to-tail with realistic parallel-parking gaps, and keep the run clear of the driveway cuts and red curb. If a run doesn’t fit the curb available, the gaps tighten rather than letting two cars overlap. Across the street it’s the curb that decides: each car is seated so its curb-side edge sits a hair past the curb’s road-facing face, which — because a curb is a box with thickness — is not the same as the line the curb is centred on. That distinction cost me an evening later.

The specifics, if you want to reproduce this on your own street. The mapping is a two-term linear fit, camXZ(cx, cy), calibrated by eye against two known rows: x = (cx / 1920 - 0.5) * 110 and z = 0.0889 * cy - 74.9. The seating constants are CURB_FACE = { near: -11.3, far: -29.7 } — the two curb centre lines of -11 and -30, each offset by half of the curb’s 0.6 depth — plus a hairline CURB_OVERHANG = 0.05 applied with the opposite sign on each side, since the two curbs face each other. Half-widths come from the model’s bounding box but are capped at HW_CAP = 1.8, because one outlier body measuring wide could otherwise shove itself a full car-width into the road.

Claude Code — camera pixels to world
# give it the data contract, not a description

/api/geometry returns parking_spots[], where each spot has:
poly — a polygon in 1920x1080 camera-frame pixels
side — 'near' or 'far'

Write camXZ(cx, cy) mapping a camera pixel to world ground x,z.
Calibrate it so far bays at cy~280 land at z=-50 and near bays
at cy~640 land at z=-18. Keep it a plain linear fit — I want to
be able to re-derive the constants by hand later.

Then bayCentroid(spot) = camXZ of the polygon's mean point.

# the important half
Do NOT place cars at the raw centroids. Perspective bunches the
far row, so we pack in a separate step. Just return positions.

Handing Claude the endpoint’s actual shape and two calibration points produced a working mapping first try. The last paragraph is what stopped it from helpfully doing the placement wrong.

One honest caveat about that mapping, since it’s the piece most likely to bite anyone copying this: mine was calibrated before I narrowed the road, and I never re-derived it. It still maps far detections to about z = -50, which is 20 units past where the far curb now lives. The packing step papers over it by overriding the far bays’ X positions by rank rather than trusting their centroids — which works, and is exactly the kind of compensating hack I complain about elsewhere in this post. If I were starting over I’d re-calibrate camXZ and delete the override.

Step 7: Wire It to the Live Feed

I wired the scene to three existing dashboard endpoints on a two-second poll, with a strict occupancy gate so a bay renders a car if and only if the feed says it’s occupied. This is the smallest piece of code in the project and the only one that makes it a twin rather than a diorama.

The scene polls the dashboard every two seconds for three things: the bay geometry (fetched once and cached, since it doesn’t change), the current parking state, and the live frame state. Then it reconciles what’s in the scene against what came back.

The rule for a tracked bay is deliberately strict: render a car if and only if that bay’s live state says occupied, coerced to a real number first. That sounds paranoid until you picture the failure mode — a feed that sends the string “0” is truthy in JavaScript, and a loose check would quietly park a car in every empty bay and show a permanently full street. The entire value of the thing is that it says 1 of 7 when it’s 1 of 7.

Each bay also keeps its identity: a given bay always gets the same model and paint colour, derived from its index, so the street stays stable instead of reshuffling its cars every two seconds. Bays are labelled N1 to N3 and F1 to F4 with tags projected from their 3D positions onto DOM elements floating over the canvas.

🔧 Developer section: The live endpoint contract

What the scene asks for, every 2 seconds
# fetched once, then cached
GET /api/geometry → the 7 bay polygons, in camera pixels

# polled
GET /api/parking → per-bay occupied: 0 or 1
GET /api/live → current frame state

# reconcile the scene against the feed
N1 occupied → car rendered
N2 empty → bay stays empty
N3 occupied → car rendered

live · 4/7 bays filled · drag to orbit

Three endpoints and a reconcile loop. The dashboard feeds the twin; the twin never talks back.

One softer touch: the rest of the block, beyond the seven tracked bays, gets ambient parked cars whose density scales with the tracked occupancy. Those aren’t data — they’re cheaper generic models filling untracked curb so the street doesn’t look abandoned on a quiet afternoon. The seven tracked bays keep the detailed models and stay the only honest signal in the frame, a distinction I wanted living in the code rather than in my head.

Step 8: Serve It With No Build Step

I gave the twin its own repository and symlinked it into the dashboard, which serves the modules with Cache-Control: no-store. There is no bundler and no build: I edit a module, reload the tab, and the change is on screen.

The twin lives in its own git repository and is symlinked into the tracker’s dashboard, which serves the module files with caching turned off and provides Three.js through the page’s import map. The coupling is one-way on purpose: the twin fetches the dashboard’s endpoints by URL, and the dashboard imports nothing from the twin. So I can rewrite the detection code without touching the 3D scene, and rebuild the scene without touching anything that watches the street. And there’s no build step at all — edit a module, reload the tab, the change is on screen. For work that is almost entirely “does that look right yet,” deleting the build was worth more than any tooling I could have added.

What Went Wrong Along the Way

That’s the build. This next part is where the time actually went. Skippable if you came for the recipe.

The invisible glass towers

Some backdrop towers rendered as blank pale slabs while the masonry ones beside them kept their window grids, and the texture turned out to be fine — 8-pixel panes with faint mullions, which looked great zoomed in and was exactly the problem. Those towers occupy a few dozen pixels of screen, so every band of the pattern landed smaller than one pixel, and a GPU that can’t draw a feature that small averages it instead — the average of pale glass with faint mullions being flat pale colour. The fix was to pick the pitch from the measured on-screen size rather than from how it looks up close: a coarse 32-pixel pitch that works out to about 2.6 pixels per band, where a middle version at roughly 1.3 looked perfect in a screenshot and shimmered the moment anything moved.

The bounding boxes were lying

Cars were rendering slightly small, and some hovered above the road — which, from a high camera angle, doesn’t read as floating at all, it reads as parked in the wrong space, so I spent days trying to fix my parking geometry. The cause was that Three.js’s bounding-box helper defaults to a fast path that boxes each part’s own local box and transforms the corners, which over-reports for any model whose parts carry rotations, and several of mine do: scaling divided by an inflated length, and ground-seating subtracted an inflated bottom, lifting each car by exactly the error — one floated 1.55 units, another 2.90. The fix was one optional flag that makes it walk the real vertices, passed at all eight call sites; and once the cars landed, one of them turned out to have been parked backwards the entire time, a bug I’d never seen because a car hovering three units in the air is so obviously wrong that nobody checks which way it’s pointing.

Both of these were measurement problems, not rendering problems. The renderer did precisely what it was told; it was told the wrong numbers.

Which changed how I prompt for debugging. My old habit was to describe the symptom and let Claude propose a fix, which reliably produces a plausible constant adjustment that hides the bug. Now I explicitly forbid that in the prompt and ask for instrumentation instead — this one found the bounding-box problem in a single pass.

Claude Code — measure, do not tune
Some cars render undersized and float above the road; others
sit correctly. Same loader path for all of them.

Do NOT adjust any scale or offset constants.

Instead, for every model at load time, log:
- the Box3 dimensions and min.y
- the mesh names, vertex counts and any node rotations
- the spec length we are scaling toward

Then tell me which measured value disagrees with the real car
spec, and why the disagreement differs per model.

The two load-bearing lines are “do not adjust any constants” and “tell me which measurement disagrees.” Without them you get a fix that works and a bug that stays.

What went fast

What needed patience

← Back to all writing