Spatium is a browser-based 3D floor planner for gyms. You drop treadmills, racks and cable machines onto a floor plan, and it checks clearances and compliance as you go. Until this week, every one of those items was a grey box. A gym owner couldn't tell a squat rack from a Smith machine from a storage cabinet, and every screenshot a prospect shared looked like a warehouse of crates.
Over two days I replaced all 63 catalog items with recognizable shapes. Each item still renders as exactly one mesh with one material, which means one draw call, and none of the editor's interaction code changed. This post covers how that works, why I didn't use AI-generated 3D models, and the bugs the tests caught that I would never have spotted by eye.
Why the first attempt was dead code
There was already a file called equipment-models.tsx: 339 lines of composed low-poly silhouettes, zero call sites, switched off by a comment in Equipment.tsx saying "the per-sub-mesh material attach proved unreliable."
That sounded like a vague rendering bug. It turned out to be structural. Equipment.tsx renders one mesh with one inline meshStandardMaterial and holds a single materialRef to it. A useFrame callback eases that material's opacity for the hover pulse, and an effect sets its emissive colour to red when a compliance violation involving the item is hovered. The old file's own docstring promised that all parts would share the same material. But rendering N sub-meshes gives you N materials, one ref only captures one of them, and so only one part of each item ever animated.
Once the cause was that specific, the fix was obvious: don't render N meshes.
Why not generate the models with AI
Before designing anything, I tested the tempting shortcut. I generated four items through Tripo's text-to-model API (v3, 80 credits, $0.80 total). The results were better than I expected: recognizable, structurally right, 0.59 to 0.89 MB each, with proper PBR material sets and ambient occlusion in the ORM channel rather than baked into the base colour.
They still couldn't drive the editor, because the generator ignores dimensions. Every mesh arrives normalized into a unit cube at whatever proportions it likes. Here are the width-to-depth ratios, mesh versus catalog:
- Power rack: 1.49 vs 0.86
- Treadmill: 2.20 vs 0.45
- Flat bench: 0.41 vs 0.50
- Dumbbell rack: 0.55 vs 3.33
Stretching that dumbbell rack to its catalog footprint is a six-fold aspect change. Three of the four also arrived lying on their side. In a floor planner the footprint is the product: it drives clearance checks and collision. The line I wrote in the design spec was: "A mesh that is beautiful and the wrong shape is worse than a box that is honest."
Generated models got scoped out of the editor and into marketing, where a hero image on an equipment page doesn't need to be dimensionally correct. At $0.20 per item that track is estimated at about $12.60 for the whole catalog, and there's already an unused modelUrl column to hold the files. I haven't built it yet, and it shares no code with what follows.
Parts in, one geometry out
The design is a pure function. Given an item's name, category, width, depth and height, getEquipmentParts returns a list of boxes and cylinders in local space: origin at the item's centre, y running from minus half the height to plus half, and +Z as the front. A second function turns that list into one BufferGeometry:
// equipment-geometry.ts (doc comment trimmed, Part union split into two names)
import { BoxGeometry, CylinderGeometry, Euler, Matrix4 } from 'three';
import type { BufferGeometry } from 'three';
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
type Vec3 = [number, number, number];
type Box = { kind: 'box'; pos: Vec3; rot?: Vec3; size: Vec3 };
type Cyl = { kind: 'cyl'; pos: Vec3; rot?: Vec3; r: number; len: number; seg?: number };
export type Part = Box | Cyl;
const _m = new Matrix4();
const _e = new Euler();
export function buildGeometry(parts: Part[]): BufferGeometry | null {
if (!parts.length) return null;
const geos: BufferGeometry[] = [];
for (const p of parts) {
const g: BufferGeometry =
p.kind === 'box'
? new BoxGeometry(p.size[0], p.size[1], p.size[2])
: new CylinderGeometry(p.r, p.r, p.len, p.seg ?? 10);
if (p.rot) {
_e.set(p.rot[0], p.rot[1], p.rot[2]);
g.applyMatrix4(_m.makeRotationFromEuler(_e));
}
g.applyMatrix4(_m.makeTranslation(p.pos[0], p.pos[1], p.pos[2]));
geos.push(g);
}
const merged = mergeGeometries(geos, false);
for (const g of geos) g.dispose();
return merged;
}
Merging is the load-bearing decision. One geometry means one mesh, which means one material, so the hover pulse, the compliance tint, the placement-mode raycast toggle, drei's Edges outline and the billboarded label all keep working untouched. The integration in Equipment.tsx is a memo, a disposal effect and a swap in the JSX:
// Equipment.tsx, trimmed to the lines this change touched
const partGeometry = useMemo(() => {
if (!item) return null;
const parts = getEquipmentParts(item.name, item.category, item.widthM, item.depthM, item.heightM);
return parts ? buildGeometry(parts) : null;
}, [item]);
// We construct this geometry ourselves, so we own its disposal.
useEffect(() => () => partGeometry?.dispose(), [partGeometry]);
// ...and in the JSX, the only swap:
<mesh castShadow receiveShadow raycast={isPlacingMode ? NO_RAYCAST : DEFAULT_RAYCAST}>
{partGeometry ? (
<primitive object={partGeometry} attach="geometry" />
) : (
<boxGeometry args={bbox} />
)}
<meshStandardMaterial ref={materialRef} color={color} transparent opacity={baseOpacity} />
</mesh>
Any item without an authored shape gets null back and keeps the plain box, so the catalog is never in a broken intermediate state.
The other property that matters: parts are computed from the catalog dimensions and never scaled after the fact. A custom item typed in at any size gets correct proportions, and round parts stay round. That's exactly what the generated meshes couldn't do.
What a shape function looks like
Here is the start of the lifting rack, simplified:
// Simplified from shapes/racks.ts
export function rackParts(n: string, w: number, d: number, h: number): Part[] {
const post = Math.min(0.075, w * 0.07);
const ox = w / 2 - post / 2;
const oz = d / 2 - post / 2;
// A power rack has four uprights; a half rack and a squat stand have two.
const zs = /squat stand|half/.test(n) ? [-1] : [-1, 1];
const parts: Part[] = [];
for (const sx of [-1, 1]) {
for (const sz of zs) {
const x = sx * ox;
const z = sz * oz;
parts.push({ kind: 'box', pos: [x, 0, z], size: [post, h, post] });
}
}
// ...then cross members, feet, a pull-up bar, J-cups and a racked bar with plates
return parts;
}
Every size is either a fraction of w, d or h, or clamped with Math.min so a post doesn't turn into a pillar on a wide rack. The full function comes to 16 parts and 336 triangles for a standard power rack.
What it costs
A throwaway spike measured four items before I committed to the approach: a power rack at 20 parts and 472 triangles, a treadmill at 216, a flat bench at 164, and the worst case, a dumbbell rack with 38 parts and 1,568 triangles, or 313,600 at 200 instances. The shipped shapes came in lighter. The heaviest item in the catalog is now the 15 ft modular med ball rack at 1,176 triangles (235,200 at 200 instances), and the mean across the test suite's catalog list is about 200.
The budget, enforced per item in the tests, is under 400,000 triangles at 200 instances. Draw calls are unchanged, since one box was already one draw call. There's no download, no asset hosting and no licensing. The spec's performance target is 60fps with 200 instances on the floor. That's a target, not a number I've profiled yet, but the triangle counts leave it plenty of room.
Whole categories, never half
A catalog with six beautiful items and 57 boxes doesn't look like progress. It looks broken. So the rule was to convert complete categories in one style, ordered by how often they show up on a real gym floor:
- Wave 1: cardio (8), racks and platforms (5), and three benches. 16 items.
- Wave 2: the rest of free weights (6) and all of strength machines (7). 29 of 63.
- Wave 3: functional (6) and group fitness (8). 43 of 63.
- Wave 4: storage (7), furniture (9), stretching and recovery (2), cables (1). 63 of 63.
The benches were a wrinkle. There's no benches category in the data, even though the project docs list one; the benches sit inside free weights. That's why dispatch matches on the item's name first and falls back to category second. The rule is enforced by tests, not just discipline: in wave 3, matching "yoga mat" by name alone pulled a stretching-recovery mat in a wave early, and a coverage guard caught it. The mat shape is now gated on category too.
Only one category got a fallback shape. Every selectorized strength machine really does share a frame, a stack and a seat, so an unrecognized one still gets a machine silhouette instead of standing out as a box next to seven that aren't. Storage and furniture don't get one. A locker bank and a vending machine share nothing, so an unknown item there stays a box, which is honest.
What the tests caught
Geometry is pure data, which makes it unit-testable in a way GLB files aren't. Every shape family runs through one harness:
// equipment-shapes.test.ts, trimmed (assertion messages removed)
export function expectFits(name: string, category: string, w: number, d: number, h: number,
overhang: { x?: number; y?: number; z?: number } = {}) {
const parts = getEquipmentParts(name, category, w, d, h);
const geo = buildGeometry(parts!)!;
geo.computeBoundingBox();
const b = geo.boundingBox!;
const size = b.getSize(new Vector3());
expect(size.x).toBeLessThanOrEqual(w * (overhang.x ?? 1.02));
expect(size.y).toBeLessThanOrEqual(h * (overhang.y ?? 1.02));
expect(size.z).toBeLessThanOrEqual(d * (overhang.z ?? 1.02));
// HARD ceiling: a mesh taller than its catalog box would sink into the floor or float.
expect(b.max.y).toBeLessThanOrEqual(h / 2 + 0.02);
// SOFT floor: a shape that fills half its box looks wrong next to one that fills it.
expect(b.max.y).toBeGreaterThanOrEqual((h / 2) * 0.85);
const tris = geo.index!.count / 3;
expect(tris * 200).toBeLessThan(400_000);
}
The height ceiling is the invariant that matters most: the editor positions each item by its mount elevation plus half its height, so a mesh taller than its catalog box would float or sink. The soft floor is what flagged the Pilates reformer, whose risers sat on the rails and left the mesh at 40% of its catalog height. The overhang argument exists for real protrusions, like a racked Olympic bar that's wider than the frame holding it. A separate test re-runs every item at plus and minus 20% on every axis, because custom items are authored at whatever size the user types.
Here's what that harness, and a few targeted tests, found:
- The word "rack" matched 21 items instead of 16. It's the most overloaded word in the catalog. Both wall-mounted med ball racks, the barbell rack and the kettlebell rack were rendering as squat cages, and a foyer bench was rendering as a weight bench. The fix excludes on name and category together. The name exclusion lists what a rack holds (dumbbell, kettlebell, med ball, plate and so on), since a storage rack is always named for its load and a lifting rack never is. Category alone would have been wrong the other way: custom items carry no useful category, so a custom power rack would have lost its shape.
- J-cups pushed a 1.5 m power rack to 1.57 m deep. Centred on the upright, the 0.11 m plate on the racked bar hung past the front face. The cups now cradle the bar inside the upright line.
- A lat pulldown grew a 24 cm thigh roller. The roller was sized off height alone, and the machine is 2.2 m tall. It's now clamped, because a roller pad is a roller pad however tall the machine is.
- The 15 ft med ball rack blew the budget. It's 4.57 m long against the standard rack's 1.2 m. With 14 balls a tier drawn as 10-segment cylinders across three tiers, 200 instances came to 453,600 triangles. Spacing and segment count both came down, to 10 balls a tier at 8 segments. The same rack also hung half a ball past each end until the balls were spread between the end balls' outer edges instead of across the full width.
- The spike's treadmill stood 1.60 m tall in a 1.5 m box. A 7% overshoot from a tilted console that no visual review would have noticed.
One more is a guard rather than a catch. Turf is 2 cm tall and a yoga mat is 1 cm, so nothing on those items can assume a fixed thickness; every part is a fraction of h. A dedicated test asserts that no part on either is thicker than the item itself.
And one thing no test could see. A browser check of the strength machines showed the seat, back pad and weight stack reading as three separate objects standing near each other. The machine family now carries a spine, a seat post and a front cross member to tie them together. Bounds tests tell you a shape fits. They can't tell you it reads as one object.
Testing against the real catalog
The unit tests have one weakness I only dealt with at the end. They check a hand-copied list of names and dimensions, and a copy of the catalog goes stale the moment someone seeds a new row. A new item that falls through to a plain box would pass every test.
So wave 4 added scripts/verify-catalog-shapes.ts. It queries the real catalog table through Prisma, runs every row through getEquipmentParts and buildGeometry, and prints each category as shaped over total, marking any that are half converted. It exits with a failure if any shape tops out above its catalog height. At the end of wave 4 it reported 63 of 63 shaped, with every category whole.
Lessons learned
1. Find the specific reason the last attempt failed. "Proved unreliable" was a comment, not a cause. The cause was one ref pointing at one material, and once I had that, the design followed from it.
2. Measure the shortcut before you reject it. Eighty cents of generated models turned "AI models probably aren't accurate enough" into a table showing a six-fold aspect error. That's a decision I won't have to re-argue from gut feel.
3. Derive geometry from the data it has to respect. Parts computed from width, depth and height stay correct at every size, including items that don't exist yet. Scaling a finished mesh never does.
4. Ship whole categories. Uniformly plain looks intentional. Half converted looks broken.
5. Test the invariant, then test against the real data. Bounds and budget assertions caught bugs I would never have seen by eye. But a hand-copied fixture list can't tell you about the row someone adds next month; only a query can.
6. Still look at it. The machine that read as three loose objects passed every test.
Building something in the browser that has to stay accurate and fast in 3D? Let's talk, or see more of what went into Spatium.