Concept & creative direction
The brief: a product configurator where the object is the interface. Most 3D configurators bury the model under UI chrome; here the rule was the opposite — a studio-grey stage (#ECEAE6), hairline borders, tiny uppercase Inter labels, and a single ember accent (#C2571B) reserved for the active choice and the order button. The stool supplies all of the colour.
The direction is honest about what it is: a concept study of an Ashanti-inspired domestic stool, its cultural meanings presented with respect and named in Twi. The process page covers the respectful-adaptation decisions; this page covers the engineering.
The toolchain
- Fable 5 as designer-engineer, directed by Hannah Kwakye — every line of markup, style, geometry and texture code authored for this site.
- Static, hand-authored HTML/CSS/JS. No framework, no build step. One ES module (
assets/js/stool.js, ~700 lines) drives the whole configurator. - Three.js, vendored. This is the one site in the 26-site collection that ships a 3D library:
three.module.min.js(plus itsthree.core.min.jscounterpart) copied fromnode_modulesintoassets/js/and imported relatively. No CDN, no external request — the site works offline. Notably, no examples modules: OrbitControls, RoomEnvironment and the texture loaders were all replaced with small hand-rolled equivalents, described below. - Self-hosted variable fonts: Sora (display, price, buttons) and Inter (body, spec labels) as single variable-weight woff2 files.
- Code-drawn assets only. The collection's constraint — no stock photos, no downloaded images — becomes the thesis here: a "product photo" that cannot exist as a photo, because the product is parametric.
The parametric stool
The stool has no modelled mesh file. It is rebuilt from scratch every time you change the size, from one parameter object (1 scene unit = 10 cm):
// everything flows from the chosen size
function stoolParams(sizeKey) {
const s = SIZES[sizeKey]; // e.g. { w: 46, d: 28, h: 32 }
const W = s.w / 10, D = s.d / 10, H = s.h / 10;
const plinthH = H * 0.13;
const curl = H * 0.17; // how far the seat tips rise
const tMid = H * 0.105; // seat thickness at centre
const tEnd = tMid * 0.62; // the seat thins toward its tips
const colH = H - plinthH - curl - tEnd;
...
}
The crescent seat — two Béziers and an extrusion
The seat's side silhouette is drawn as a THREE.Shape: a quadratic Bézier for the underside (from tip, dipping through the centre, back up to the other tip), a second quadratic for the sitting surface, and two short tip faces closing the outline. Choosing the control point is one line of algebra — a quadratic Bézier's midpoint is (P0 + 2C + P2) / 4, so to force the curve through a chosen centre height you solve for C:
const topEnd = curl + tEnd; // height of the tips
const topC = 2 * tMid - topEnd; // control point so the curve passes through tMid at x = 0
shape.moveTo(-L, curl);
shape.quadraticCurveTo(0, -curl, L, curl); // underside
shape.lineTo(L, topEnd); // tip face
shape.quadraticCurveTo(0, topC, -L, topEnd); // sitting surface
The shape is run through ExtrudeGeometry with bevelSize ≈ 14% of the seat thickness and 4 bevel segments — the bevel is what rounds every long edge and keeps the seat from reading as CG-sharp.
A bug worth learning from: the first build called geometry.center() and re-based the seat on its bounding-box minimum. But extrude bevels are mitred, and at the crescent's acute tips the mitre overshoots — the real bounding box was ~5 cm taller than the maths said, so the whole seat floated upward and silently swallowed the carved-motif layer sitting at the "correct" height. The fix: never re-base beveled geometry on its bounding box. Centre only the extrusion (depth) axis and keep the authored curve coordinates meaningful, so anything draped over the seat can use yTop(x) directly.
The carved column — a lathe profile
LatheGeometry revolves a 2D polyline of (radius, height) points around the Y axis. The column profile is composed from a tiny vocabulary of woodturning moves — coves (ease-in-out curves inward), beads (sine bumps), and a swollen drum whose carved rings are a sharpened sine:
// the drum: a gentle swell with carved rings
for (let i = 1; i <= 30; i++) {
const t = i / 30;
let r = R * (0.94 + 0.14 * Math.sin(t * Math.PI)); // swell
const groove = Math.pow(Math.abs(Math.sin(t * Math.PI * 2.5)), 18);
r -= groove * R * 0.055; // rings, cut in
push(r, drumLo + (drumHi - drumLo) * t);
}
Raising the sine to the 18th power turns a smooth wave into narrow grooves with flat land between them — exactly what a parting tool leaves on a turned drum.
Corner posts — the 4-segment lathe trick
The four supports use the same lathe machinery with segments = 4: a lathe with four radial steps produces a square-section post, complete with the profile's taper, entasis and collar rings. One rotateY(π/4) aligns the flats. Because the seat's underside is a curve, each post's height is computed by evaluating the underside Bézier at the post's x-position — the posts genuinely meet the seat, at every size.
The plinth
A rounded rectangle (THREE.Shape with quadratic corner curves) extruded with a bevel, rotated flat. Nothing clever — the restraint is the point; the plinth should read as a calm slab.
The canvas-texture pipeline
There are no texture files. Every material samples a <canvas> painted at load time and wrapped in THREE.CanvasTexture.
Wood grain
Each finish gets a 512×512 colour map and a matching grayscale bump map, built from three layers with a seeded LCG random generator (so every visitor sees the same wood):
- 170 long-grain streaks — wavy horizontal strokes, 0.6–3.8px, alpha 0.05–0.18, alternating between the finish's light and dark tones;
- cathedral arcs — five clusters of partial concentric ellipses (the heartwood figure you see on flat-sawn boards);
- ~2,600 pore flecks — 1px dots at very low alpha for tooth.
The same strokes are drawn into the bump canvas in grayscale, so the ridges of the grain physically catch the light. The finish presets then set the material's roughness: natural sese is matte (0.68), walnut satin (0.52), ebony lacquered (0.42).
The adinkra motifs — drawn, then carved
The three symbols are drawn with 2D canvas paths — arcs, Béziers, and a shared spiralPath() helper that walks an Archimedean spiral in small line segments (the curls of Dwennimmen's horns and Sankofa's lobes). The same drawing function paints the UI chips, the meaning cards, and the 3D textures, so the symbol is identical everywhere.
To "carve" a symbol into the seat, the motif becomes two textures on a decal patch: a 24×12 grid of vertices draped exactly over the seat's top surface (evaluating the same yTop(x) Bézier, plus the bevel rise, plus 2 mm of clearance). The patch's material uses the motif drawn in white as a colour map — tinted by a darker version of the wood colour — and a blurred dark copy as a bump map, which shades the edges of the strokes into a groove. polygonOffset keeps it from z-fighting with the seat.
// drape the decal over the seat's top curve
for (let ix = 0; ix <= NX; ix++) {
const x = (ix / NX - 0.5) * 2 * halfX;
pos.push(x, yTop(x) + bevel + 0.02, z); // follow the sitting surface
}
The alternative — unwrapping the extruded seat's UVs and painting the motif into the wood texture itself — was rejected: extrusion side-wall UVs follow the contour, so the motif would smear across the curve. A draped patch gives pixel-exact placement for two dozen vertices.
Studio lighting without an HDRI
The scene uses a hemisphere light plus a classic three-point rig (warm key with a 1024px shadow map, cool fill, white rim). Wood only comes alive with something to reflect, so instead of loading an HDRI file, a tiny procedural environment is built at startup: a grey room with three emissive "softbox" planes, run once through PMREMGenerator.fromScene(). That prefiltered environment gives the lacquer its sheen for the cost of a few kilobytes of code.
Grounding comes from two layers: a ShadowMaterial catcher plane for the real cast shadow, and a painted radial-gradient blob texture underneath it that hugs the plinth — the same trick product photographers use with a fill card.
The damped orbit camera
No OrbitControls import — the whole camera is ~40 lines. Pointer deltas nudge target spherical angles; every frame the actual angles ease toward the targets:
orbit.theta += (orbit.thetaT - orbit.theta) * 0.085; // exponential damping
orbit.phi += (orbit.phiT - orbit.phi) * 0.085;
camera.position.set(
Math.sin(phi) * Math.sin(theta) * d,
Math.cos(phi) * d + targetY,
Math.sin(phi) * Math.cos(theta) * d
);
Three details matter more than the maths: phi is clamped (25°–94°) so you can never go under the floor; the distance is aspect-compensated (d *= max(1, 1.25 / aspect)) so the stool fits portrait phones without a separate mobile scene; and the canvas is focusable, with arrow keys and +/− mapped to the same targets — the damping makes keyboard orbiting feel as smooth as dragging.
The spec-card snapshot
The "Save spec card" button composites a downloadable PNG entirely client-side: render one fresh frame, read it with renderer.domElement.toDataURL() (valid without preserveDrawingBuffer because the read happens in the same task as the render), then draw a 1080×1400 card on a 2D canvas — paper background, rules, the render, and the spec rows set in the site's own Sora/Inter (awaited via document.fonts.load()). The filename carries the configuration code, e.g. adinkra-stool-AS-CL-WAL-DWE.png.
Performance & accessibility
- DPR capped at 2; geometry is small (~19k vertices) and rebuilt only on size/finish/motif change, with old geometry disposed.
- Render-on-demand: the rAF loop skips
render()unless the camera is settling, auto-rotate is on, or something changed. The loop pauses when the tab is hidden (visibilitychange) and when the viewport scrolls away (IntersectionObserver). - Reduced motion: no auto-rotate, price changes without tweening, reveal animations disabled — the scene renders a still frame and only re-renders on explicit interaction.
- WebGL fallback: if the renderer can't be created, the viewport swaps to a hand-drawn SVG stool illustration that recolours with the finish choice via a CSS custom property, and the spec panel keeps working — price, dimensions and code stay live.
- Semantics: the options are real radio inputs in fieldsets (fully keyboard operable, styled as chips), the price is an
aria-liveregion, and the canvas'saria-labelre-describes the current configuration on every change.
Iteration log
Pass 1 — design critique
- First render framed the stool too tight — the seat tips collided with the headline, and on mobile the stool overflowed the viewport entirely. Widened the base camera distance and added aspect compensation so one camera fits every screen.
- The column drum read as a beehive vase: too much swell, too many grooves. Reduced the swell (20% → 14%) and cut the groove frequency for a calmer, more architectural turn; slimmed the corner-post collars.
- The carved motif was invisible — the
geometry.center()bevel-mitre bug documented above. Rewrote the seat's coordinate handling so the decal drapes on the true surface.
Pass 2 — elevation
- Deepened the material story: stronger grain contrast, anisotropy 8, per-finish carve tones so the motif reads as cut wood (not a sticker) in all three finishes.
- Raised the resting camera (φ 76° → 67°) so the seat's carved motif is part of the hero composition, not a hidden reward.
- Made the spec card a designed object — rules, tabular spec rows, config code and date — rather than a bare screenshot; added the configuration code to the live panel.
Pass 3 — ship quality
- Scripted an interaction test: every finish × motif × size combination switched programmatically, prices and codes asserted, snapshot download verified (a real ~490 KB PNG), drag-orbit exercised — zero console errors under SwiftShader.
- Silenced the two remaining Three.js warnings (PMREM blur sigma, deprecated shadow-map type) and verified reduced-motion, keyboard orbit, tab-hidden pause and the no-WebGL fallback.
- Proofread all copy; confirmed every route and cross-link resolves, fonts load from self-hosted woff2, and headers ship via
netlify.toml.
Deploy
Static files on Netlify, CI-driven from the collection's monorepo. netlify.toml publishes the site root, sets immutable caching for /assets/* (the vendored Three.js build and fonts are fingerprint-stable), and adds the standard security headers. Total first-view transfer is dominated by the ~350 KB Three.js module — the one budget exception the collection's rules grant to its 3D site.