Sheet 01 · case study

← Portfolio  ·  Русская версия

3D configurator · WebGL

2026

STANDES

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.

Role
sole developer: domain model, 3D scene, interface, exports, both locales
Stack
React 19 · TypeScript 5.9 · Vite 7 · three.js · React Three Fiber · zustand + immer + zundo · Tailwind 4
Size
25,548 lines of TS/TSX across 71 files, with no external 3D model or texture
Build types
cabinet carcass (MFC, plywood) · steel frame · retail gondola
Editions
two independent builds from one source: Russian in ₽, English in $
Status
working demo, open in a browser; the price list is placeholder data

01 · The problem

Shop fittings are bought sight unseen

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.

STANDES customer mode: seven bays lined up along the wall of a 50 m² floor, running total on the right
Customer mode. Seven bays dropped one after another lined themselves up along the wall: 8,060 mm of run, 35 shelves, ₽104,682. The only controls are floor area and the bay library.

02 · How it works

One dimensional model feeds the scene, the quote and the cutting plan

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.

  1. Project → units A project is a room and a list of units. A unit is described not by geometry but by intent: columns, rows, build type, materials, cell contents. The model holds no coordinates at all.
  2. Unit → parts 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.
  3. Parts → scene The same list goes to three.js. Materials are procedural: veneer, metal, perforation and floor tile are drawn onto a canvas at startup. There is not a single external texture or model in the project — the build ships no assets.
  4. Parts → bill of materials 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.
  5. Parts → cutting plan Sheet parts go to 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

Magnets that do not argue with the user

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.

Candidates instead of thresholds

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.

Price, not priority

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.

Matching bays stick harder

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.

Rotation costs money

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.

Gravity in the same pass

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.

Support is measured by area

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

Four places where the decision is visible whole

The solver: gather candidates, drop what floats, take the cheapest src/domain/snapping.ts · solveSnap
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.

Support: 55 % of area instead of a dozen special cases src/domain/snapping.ts · supportedAt
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.

Thickness may only ever be reduced src/domain/geometry.ts · buildableThickness
/** 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.

Post-processing breaks somebody else's renderer src/three/Lighting.tsx · AutoClearGuard
/**
 * 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

Measured on the demo that is one click away

25,548

lines of TypeScript across 71 files: domain, scene, interface, exports, both locales

327

parts in a ten-unit scene — the scene, the quote and the cutting plan all read one list

60 fps

while dragging: positions live in a separate layer, so neither the store nor the quote is touched per frame

6

kinds of magnet: face, corner, wall, grid, rotation nudge, stack-on-top

26

part roles — from side panel and hanging rail to perforated upright, bracket and price rail

0

external 3D models or textures: all graphics are procedural, the build ships no assets

Sales mode: scene tree of seven bays, retail gondola inspector, 272 parts, 500 kg
Sales mode on the same scene. The retail inspector: upright width, base and shelf depth, tilt, front lip, header, price rail, perforation. At the bottom, a warning — "2,000 mm tall, fix to the wall": safety rules are checked against the model.
Bill of materials: 29 lines, 272 parts, 39.37 m², 500 kg, ₽104,682 total
The bill of materials for that layout: 29 lines, 39.37 m², 500 kg. Materials ₽98,934 plus hardware ₽5,748. Grouping switches between flat list, by material and by role, with a CSV export alongside.
Cutting plan: 12 plywood sheets 2440×1220, 47 % average yield, 25.59 m² of offcuts
The cutting plan appears once the project contains sheet parts. Twelve plywood sheets at 2440×1220×15, 47 % average yield, 25.59 m² of offcuts. Kerf and edge margin are set above and re-pack the map immediately.
What is computedFromGoes to
Bill of materials linesparts + materialsscreen, CSV, print sheet
Masspart volume × material densityquote, shipping
Cutting plansheet parts + sheet formatscreen, CSV
Pricearea, edge banding, hardware, price listcustomer total and quote
Scenethe same part listWebGL, GLB, PNG
Whole projectroom + unitsJSON 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

Russian and English are separate sites from one source

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.

The locale arrives as a global constant of its own vite.config.ts
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

The limits of a demo

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.

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.