3D configurator · WebGL
2026
The shopper sets the floor area, picks bays from a library and drags them into place — units snap to each other face to face, corner to corner and top to bottom. While that happens, the same geometry produces a bill of materials, a mass, a sheet cutting plan and a price.
01 · The problem
A shop owner buys shelving once every few years and almost never pictures the result. They know the floor area and roughly the budget; the supplier knows the product range. Between them sits a sales rep who assembles the layout verbally, prices it in a spreadsheet and emails an invoice. A 200 mm error in an aisle, or a bay that turns out too tall, surfaces on installation day — after the steel has been cut.
A conventional shelving calculator does not solve this, because it prices one bay. The buyer is asking a different question: how many bays fit my floor, and what will it look like.
That gives one tool two very different users. The buyer wants ready-made bays, floor area in square metres and a running total — nothing else. The supplier's rep wants upright thickness, perforation pitch, shelf tilt, bay mass, a line-by-line bill of materials and a CSV export.
So the configurator has two modes over one scene: customer and sales. Not two builds and not two datasets — one model with two interfaces on top of it. The switch sits in the top right corner.
02 · How it works
The decisive architectural call was made on day one: dimensions are computed in
exactly one place. The module domain/frame.ts knows where every cell
sits, what a part's effective thickness is and how high off the floor a unit stands.
The part breakdown, the snapping solver, the inspector and the library thumbnails all ask
it. They cannot drift apart, because there is nowhere else to do the arithmetic.
geometry.ts expands that intent into a part list: 26 roles, from side
panel and shelf to perforated upright, bracket, front lip and price rail. Every part
carries its size, centre, material and flags — glass, perforation, edge banding.
bom.ts folds the same parts into priced lines: area, edge-banding length,
mass from material density, hardware, cost. The quote does not "roughly match" the
model — it is built out of it.
cutlist.ts: MaxRects packing with guillotine cuts, kerf
and edge margin, and a per-sheet map with yield and offcuts.
Units are kept strictly apart: the entire domain model is in millimetres, the
three.js scene is in metres, and conversion happens only at the render boundary
through a single mm() helper. A dull rule, but it is precisely what removes
the whole class of bugs where a shelving bay suddenly comes out the size of a house.
03 · The hard part
Snapping looks trivial right up to the first implementation. The naive approach — walk the neighbours, find the nearest face within a threshold, pull the unit onto it — falls apart immediately: for a bay in the corner of a room, the wall snap, the neighbour snap and the grid are all simultaneously valid, and threshold logic picks whichever the person did not mean.
The solver does not take the first acceptable joint. It generates every candidate of six kinds — face to face, corner to corner, to a wall, to the grid, a rotation nudge, and stacking on top — and prices each one. Then it sorts by price and takes the first that does not collide with anything.
The price is the distance from the point under the cursor plus a penalty for the kind
of joint and for any rotation. The ordering then falls out on its own:
face 0 < stack 40 < corner 60 < wall 120 < grid 400 < angle 500.
The grid is a last resort rather than a competitor to a real joint.
Equal height takes 40 off the price, equal depth another 40. Bays of the same size run themselves into a line, while joining mismatched ones asks you to aim more precisely. That is exactly what the first screenshot shows: seven bays in a row, no manual nudging.
A face joint permits up to 50° of correction, but every degree costs 4 mm of price. A slightly skewed unit reaches its neighbour and straightens out; one turned 45° does not, because that is no longer "nearly joined" — it is a different intent.
Units stack, and height is not a separate mode. A side joint carries the unit's current
elevation, stack takes the neighbour's top, and if the proposed position
has nothing underneath, the unit drops to the floor as part of the same
snap. Bays never hang in mid-air.
An upper unit holds if it covers at least 55 % of the smaller of the two footprints. That single rule rules out both "large on top of small" and a bay perched on the corner of its neighbour — without a special case for either.
Two tricks keep the solver cheap. Neighbours are coarsely rejected by bounding circles, so candidates are never even built for a unit the magnet could not reach. And collision is tested one candidate at a time in price order, returning on the first clear one, instead of an honest all-against-all filter.
04 · Code
const cands: SnapCandidate[] = [] if (settings.magnets) { for (const n of near) { faceMates(unit, n.u, settings, cands) cornerMates(unit, n, settings, cands) } } if (settings.stack) for (const n of near) stackMates(unit, n.u, settings, cands) if (settings.walls) wallSnaps(unit, room, settings, cands) if (settings.grid) cands.push(gridSnap(unit, settings)) // gravity: a side joint keeps the elevation, but if the proposed spot has // nothing underneath, the unit slides to the floor along with the snap if (myElev > 0) { for (const c of cands) { if (c.kind === 'stack') continue if (!supportedAt(unit, c.pos, c.rotY, myElev, near)) c.elevation = 0 } } if (!cands.length) return fall() cands.sort((a, b) => a.cost - b.cost) if (!settings.collide) return cands[0] for (const c of cands) if (!candidateHits(unit, myR, c, near)) return c return fall()
Twenty lines that contain the entire behaviour of the magnets. Note the order: every option is built first, gravity is then applied to each, and only after that does the choice happen. Had gravity run after the choice, a unit would first stick to its neighbour and then fall in a separate motion — two events instead of one, and undo would have to be hand-written.
function supportedAt(u, pos, rotY, elev, near): boolean { if (elev <= 0) return true // on the floor — always const probe = moved(u, pos, rotY, elev) const myArea = footprintArea(u) for (const n of near) { // the neighbour's top must line up with this unit's underside if (Math.abs(unitTopY(n.u) - elev) > SUPPORT_EPS) continue if (overlapArea(probe, n.u) >= Math.min(myArea, footprintArea(n.u)) * MIN_SUPPORT_RATIO) return true } return false }
Math.min is the whole point. Take the upper unit's area and a small bay may
legitimately perch on the corner of a large one. Take the lower unit's and a large bay can
never sit on a small one, even where that makes sense. The minimum of the two produces a
rule that matches physical intuition: the smaller of the two must rest on most of
itself. Overlap is computed on rotated footprints, so the rule holds for bays at
an angle too.
/** Nearest catalogue thickness, but DOWNWARDS only: a part may never outgrow the slot it sits in. */ function buildableThickness(materialId: string, t: number): number { const list = getMaterial(materialId).thicknesses if (!list.length) return t let best = -Infinity for (const th of list) if (th <= t + 0.01 && th > best) best = th return Number.isFinite(best) ? best : t }
The user sets shelf thickness with a slider and will happily ask for 18 mm where glass only comes in 10. Rounding to nearest would give 20 mm — the part would stop fitting its slot, and the cutting plan would be built from sheets nobody sells. Rounding strictly down breaks the expectation ("I typed 18 and got 10"), but the result is always physically buildable. The constraint is shown next to the field: the material's available thicknesses sit there as buttons.
/** * postprocessing sets renderer.autoClear = false inside EffectComposer.setRenderer. * drei's ContactShadows bakes its shadow with a plain gl.render() — so without the * buffer being cleared, every frame is drawn ON TOP of the previous one. */ function AutoClearGuard() { const gl = useThree((s) => s.gl) useFrame(() => { if (!gl.autoClear) gl.autoClear = true }) return null }
The symptom looked like witchcraft: dragging a unit left a wet-looking trail on the floor
that vanished the moment you let go. Neither the shadow code nor the drag code was wrong —
two libraries simply had different ideas about who owns the shared
WebGLRenderer. The guard is mounted before
<ContactShadows>: useFrame callbacks run in subscription
order, and the flag has to be restored before the shadow starts drawing. The same bug,
found slightly later, also explained the ghost imprints units left on the floor.
05 · What came out
lines of TypeScript across 71 files: domain, scene, interface, exports, both locales
parts in a ten-unit scene — the scene, the quote and the cutting plan all read one list
while dragging: positions live in a separate layer, so neither the store nor the quote is touched per frame
kinds of magnet: face, corner, wall, grid, rotation nudge, stack-on-top
part roles — from side panel and hanging rail to perforated upright, bracket and price rail
external 3D models or textures: all graphics are procedural, the build ships no assets
| What is computed | From | Goes to |
|---|---|---|
| Bill of materials lines | parts + materials | screen, CSV, print sheet |
| Mass | part volume × material density | quote, shipping |
| Cutting plan | sheet parts + sheet format | screen, CSV |
| Price | area, edge banding, hardware, price list | customer total and quote |
| Scene | the same part list | WebGL, GLB, PNG |
| Whole project | room + units | JSON file, compressed share link |
Not one row of that table recomputes geometry. All six outputs are built on a single expansion of a unit into parts.
06 · Two editions
There is deliberately no language switch inside the app. The Russian and English editions
are two addresses, two builds and two independent price lists. No exchange
rate exists anywhere in the code: a material's price is a pair
{ rub, usd }, and the dollar figure is edited for the export market rather
than derived by dividing the rouble one.
const locale = (process.env.VITE_LOCALE ?? 'ru').toLowerCase() === 'en' ? 'en' : 'ru' return { base, define: { __STANDES_LOCALE__: JSON.stringify(locale), __STANDES_CURRENCY__: JSON.stringify(currency), }, build: { outDir: locale === 'en' ? 'dist-en' : 'dist' }, }
The first attempt passed the locale through import.meta.env.VITE_LOCALE — and
the English build silently came out Russian. Vite assembles import.meta.env
from .env files by its own rules and substitutes it before the user's
define, so the environment variable never reached runtime. A name of its own,
__STANDES_LOCALE__, collides with nothing and is stripped by the minifier
together with the dead branch.
One flaw in this scheme only surfaced after publication, while this case study was being
written. Both editions live in subfolders of one domain (/standes/ and
/standes/en/), which means they share a single localStorage —
the English edition was opening with a project built on the Russian one. Storage keys are
now namespaced by locale: standes:en:project against
standes:ru:project.
07 · What is not there
This is a working tool but not a delivered product: it has no client who has checked the price list and the product range. Below is what is more honest said out loud.
materials.ts plus hardware in bom.ts.
localStorage and copied to the clipboard as
ready-to-send text — the success screen says so plainly.
Ctrl+P →
"Save as PDF". There is deliberately no PDF library in the project: it would weigh more
than the whole rest of the export layer combined.
If this went further, the order would be: a price list and product range from a real supplier, enquiries into a CRM instead of the clipboard, and a fixture set for the geometry — those one-off scripts rewritten as tests. A configurator without a real price list always looks finished on the project it was built against.