Skip to content

Repository files navigation

Forge — 3D Product Configurator

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.

Forge — configuring a keyboard, a mug and a lamp

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).

The three products

Keyboard Mug Lamp
Keyboard — Midnight build, metallic case Mug — cobalt metallic glaze Lamp — warm light, brass shade
Case colour + matte / glossy / metallic finish, keycap colourway, switch type, and 60% / TKL / full-size layout — every keycap drawn as one instanced mesh. Body + interior colour, matte / glossy / metallic glaze, size, and a loop / D / no handle — a revolved ceramic shell with a swept-tube handle. Body finish, shade colour, warmth, posture, and a live on/off light — a real emissive bulb + point light that pools warm light on the floor.

Highlights

  • 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-motion support (auto-rotate, camera easing, colour/material easing, the lamp's light fade, and the price tween all snap).
Light theme — Cream keyboard build Lamp lit, warm brass shade
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.

Mobile bottom sheet
On mobile, the panel becomes a draggable bottom sheet; the model stays orbit- and pinch-able above it.

Tech stack

React 19 + TypeScript (strict) · Vite · React Three Fiber + @react-three/drei (three.js) · Zustand · Tailwind CSS · Framer Motion · Vitest (unit) + Playwright (e2e).

Getting started

This project uses pnpm.

pnpm install
pnpm dev        # dev server on http://localhost:5174

Scripts

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/

How the build works

Product abstraction

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 Model component, its framings (camera shots keyed by category), a price function, and its curated presets.

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.

Scene graph

The Scene mounts a single R3F <Canvas> containing:

  • a StudioEnvironment built entirely from drei <Lightformer> softboxes — no runtime HDRI fetch, so lighting is deterministic and works offline while still producing premium reflections;
  • three-point Lights with a shadow-casting key light;
  • the active product's Model, resolved from the registry and rendered via createElement, so a selection change flows straight to it and an unrelated state change (theme, category) never re-renders it;
  • ContactShadows and a glossy reflective floor plane;
  • the CameraRig (OrbitControls + the framing driver) and a RendererBridge that 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.

Material & lighting systems

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.

Camera choreography

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.

Performance notes

  • Only two useFrame loops run (camera framing, colour/material/light easing); both mutate pre-allocated scratch state and flat number goals — no Vector3/Color/Matrix4 churn 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-vendor chunk, keeping the app entry chunk ~350 kB.
  • frameloop is intentionally left continuous (not "demand"): auto-rotate, camera easing, the eased-colour transitions and the lamp's light fade all need a running clock.

Testing

  • 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.

Design & planning docs

About

Interactive 3D mechanical-keyboard product configurator built with React Three Fiber — orbit, recolour, swap switches/layout, live pricing, and PNG export.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages