Forge is an interactive, photorealistic 3D configurator for three very different products — a custom mechanical keyboard, a ceramic coffee mug, and a desk lamp — all driven by one data-defined product abstraction. Orbit a studio-lit model, recolour and re-material it, swap its parts, watch the price update live, and export a PNG of your build — rendered in real time in the browser with React Three Fiber.
Switch products from the top of the panel and the whole app re-skins itself: the model, the control set, the camera framings, the pricing and the curated presets all come from that product's single definition — adding a product is data, not a new code path.
One session across all three products: configure the keyboard, switch to the mug and change its glaze, then switch to the lamp and toggle the light + warm the bulb. Colours and materials ease into place, the camera re-frames per part, the lamp's light fades, and the price tweens — all captured headlessly (see
scripts/record.mjs).
- One product abstraction, three products — each product is a single typed
ProductDefinition(option catalogue, 3D model, camera framings, pricing, presets). The store, scene and UI are generic over it, so the whole configurator re-skins from data when you switch products. - Real-time studio render — physical-material models under a Lightformer softbox environment, with contact shadows and a reflective floor; the lamp adds its own emissive bulb and point light.
- Material systems that read — keyboard case finishes and mug glazes (matte / glossy / metallic) with distinct PBR response (clearcoat, metalness, HDRI-style reflections); per-instance keycap colours.
- Camera choreography — the camera smoothly re-frames the relevant part of the current model as you move between sections, with damping and idle auto-rotation, staying inside the OrbitControls limits (a regression test enforces this for every framing of every product).
- Considered UX — animated pricing, curated preset builds with eased colour transitions, a responsive desktop panel / mobile bottom sheet, light & dark themes, and a graceful WebGL fallback.
- Accessible & motion-aware — full keyboard operation (product switcher + every control are
proper radiogroups / switches with arrow-key navigation, roving tabindex and visible focus),
labelled controls, and complete
prefers-reduced-motionsupport (auto-rotate, camera easing, colour/material easing, the lamp's light fade, and the price tween all snap).
![]() |
![]() |
| Light theme — the DOM UI themes light/dark; the studio scene is unaffected. | The lamp is the lighting showcase: a real bulb + point light that fade on and off. |
On mobile, the panel becomes a draggable bottom sheet; the model stays orbit- and pinch-able above it.
React 19 + TypeScript (strict) · Vite · React Three Fiber + @react-three/drei (three.js) ·
Zustand · Tailwind CSS · Framer Motion · Vitest (unit) + Playwright (e2e).
This project uses pnpm.
pnpm install
pnpm dev # dev server on http://localhost:5174| Command | What it does |
|---|---|
pnpm dev |
Start the Vite dev server on port 5174 |
pnpm build |
Type-check (tsc -b) and build for production |
pnpm preview |
Preview the production build |
pnpm lint |
ESLint (zero warnings expected) |
pnpm test |
Vitest unit tests |
pnpm test:e2e |
Playwright smoke + multi-product e2e (boots the dev server automatically) |
pnpm shot -- --out shot.png --query "?product=mug" |
Headless screenshot of a scene state (for visual QA) |
pnpm record |
Re-render the multi-product demo GIF into docs/media/ |
The headline architecture. Each product is one ProductDefinition<S> —
a fully typed description of a configurable product:
- an option catalogue (
categories) that drives the data-driven panel — each category names a control kind (color/swatch/radio/material/toggle) and the selection field it writes; - a
Modelcomponent, itsframings(camera shots keyed by category), apricefunction, and its curatedpresets.
The registry PRODUCTS lists them ([keyboard, mug, lamp]), erasing
each product's selection type so one array can hold products of different shapes (the standard
existential-type encoding, confined to one eraseProduct boundary). The Zustand
store is generic over these: it holds the active product id, a
per-product selection map, and delegates pricing/framings/model to the active product. The
ConfigPanel and CategoryControl render
whatever categories the active product declares — so adding a product is a one-line registry
change plus its definition; no bespoke UI. Concrete render params are never stored: each product
derives them on demand from its selection (e.g. the keyboard's
deriveKeyboardConfig, the lamp's on/off light folding),
keeping the id → params mapping in exactly one place.
The Scene mounts a single R3F <Canvas> containing:
- a
StudioEnvironmentbuilt entirely from drei<Lightformer>softboxes — no runtime HDRI fetch, so lighting is deterministic and works offline while still producing premium reflections; - three-point
Lightswith a shadow-casting key light; - the active product's
Model, resolved from the registry and rendered viacreateElement, so a selection change flows straight to it and an unrelated state change (theme, category) never re-renders it; ContactShadowsand a glossy reflective floor plane;- the
CameraRig(OrbitControls + the framing driver) and aRendererBridgethat publishes a{ gl, scene, camera }handle so the DOM-side export button can read the canvas.
The keyboard draws every keycap of a layout as one instanced mesh
(Keycaps), allocated once for the largest layout; switching size only
rewrites instance matrices and the draw count. The mug is two revolved LatheGeometry shells
(a body-coloured exterior + rim and an interior-coloured cavity) plus a swept TubeGeometry handle,
memoised per size/handle. The lamp is a revolved weighted base, a two-segment arm linkage
(base joint → elbow → wrist, each an eased rotation), and a shade with a real emissive bulb.
Case finishes and mug glazes are meshPhysicalMaterial presets chosen to read very differently
under the softboxes: matte scatters light (high roughness, no clearcoat), glossy adds a
sharp clearcoat highlight, and metallic turns the surface into a tinted mirror
(metalness: 1). Colours and PBR scalars are eased toward their target each frame via the
shared useEasedConfig — applying a preset melts into place, while
discrete geometry (layout, size, handle) swaps instantly.
The lamp is the lighting showcase: a real emissive bulb + co-located pointLight plus an
additive floor light-pool, all gated by the on/off toggle. Turning the light off doesn't snap —
the bulb emissive, point-light intensity, shade underglow and floor pool all fade together
(they ease through the same hook as scalar fields), and the arm re-poses smoothly between
postures.
Each product ships its own framings — the keyboard's hero / keycaps / switch shots, the mug's
handle and interior close-ups, the lamp's bulb and base. When the active category changes,
CameraRig damps the camera position and OrbitControls target toward
that framing (per-component MathUtils.damp, zero per-frame allocations), yielding instantly
to an active drag so auto-framing never fights the user. Idle auto-rotation pauses on interaction
and resumes after a grace period. Every framing is validated against the shared
ORBIT_LIMITS (distance + polar angle) by
framings.test.ts, so a framing can never silently fight the
clamp. Under prefers-reduced-motion every tween — camera, colour, the lamp's light fade, and the
price roll — snaps instead.
- Only two
useFrameloops run (camera framing, colour/material/light easing); both mutate pre-allocated scratch state and flat number goals — noVector3/Color/Matrix4churn per frame. Every product shares that one easing hook. - Keycaps are instanced; mug and lamp geometry is memoised and disposed on change.
- The build splits the (inherently ~1 MB) three.js stack into a long-cached
three-vendorchunk, keeping the app entry chunk ~350 kB. frameloopis intentionally left continuous (not"demand"): auto-rotate, camera easing, the eased-colour transitions and the lamp's light fade all need a running clock.
- Unit (Vitest) — pricing, config derivation, per-product definitions, theme/store logic, WebGL detection, and the camera-framing limit regression across every product.
- E2E (Playwright) — the canvas renders, presets mutate price + selected state, export enables, plus the multi-product flows: switching product swaps model + price + categories, the lamp light switch flips, and the desktop panel collapses/expands.




