ORBIT
Build notes · The Halcyon

A whole store
from one photo.

ORBIT is a real-time 3D product configurator for a chair that never existed. It began as a single generated product shot, became a textured 3D mesh, and ended as a store you can spin, re-upholster, and measure — all in the browser, no build step. Here is exactly how it was made, including the parts that didn't work.

Brief & concept

The assignment was site 23 of 25: "one chair, configurable." A Three.js viewer with orbit controls, studio lighting, a soft shadow catcher, material swatches that morph live, an updating price, and a dimension overlay — the flex being real-time 3D that feels like a premium DTC store, the kind of page where a furniture brand sells a $3,000 lounge chair.

So the first job wasn't code — it was a fiction worth building. The chair became The Halcyon, a bent-plywood lounge in a fictional "Edition 03." The copy commits to it: nine plies of steam-bent walnut, pressed over a two-tonne form, boucle woven in Kortrijk. A configurator only feels premium if the words around it believe the product is real.

Assets & provenance — the pipeline star

There is no photographer, no 3D modeller, and no product. The entire chair came from a two-step generative pipeline:

StepToolOutput
Product photoHiggsfield nano_banana_2 (text → image)A studio shot of a mid-century walnut & boucle lounge chair — chair.webp
Photo → meshHiggsfield image_to_3dA textured glTF 2.0 mesh, ~6 MB — chair.glb

That's the whole story, and it's a good one: a photograph of a thing that was never photographed, lifted into a mesh that was never modelled. The generated .webp pulls double duty — it's also the loading poster and the WebGL-failure fallback, so the shot is never a void while the 6 MB GLB streams.

Honesty about the scan: AI photo-to-mesh is spectacular on rigid, grained surfaces — the walnut shell is genuinely beautiful — and weak on soft, high-frequency ones. The boucle cushions scanned as faceted, slightly papery geometry. That single fact drove most of the design decisions below.

Design decisions

Split-screen, not scroll

A configurator is a workbench, not an article. The layout is a fixed two-pane grid — 3D stage left, controls right — so the product never leaves your eye while you change it. On mobile it stacks to a 56vh viewport over a scrollable panel. No page ever scrolls the body horizontally.

Material variants on a single baked texture (the honest version)

Here's the constraint that shaped everything: the GLB is one mesh with one material and one baked colour texture covering the walnut and the boucle together. There are no separate sub-materials to swap. Real per-part material picking is impossible on this asset — so I didn't pretend to. Instead I inject a small shader that reads each pixel's luminance and saturation to tell wood from cloth, then re-hues the wood range and multiply-tints the cushion range. It's an approximation, documented as one in the code. It reads convincingly and it updates instantly, which is the actual point of a configurator.

Warm gallery, not the default studio white

The backdrop is a warm radial paper gradient with a grounding floor vignette and a film-grain overlay — committing to a #e9e5dd paper palette with a single amber accent, rather than the reflexive cool-grey product-shot look. Type is Space Grotesk throughout, with tight display tracking on the headline.

Signature techniques

GLB streaming with a real progress state

loader.load('./assets/chair.glb',
  (gltf) => { /* recenter, sit on floor, patch material, reveal */ },
  (evt) => {
    // total is unknown when gzipped, so fall back to the known 6.1MB
    const p = evt.total ? evt.loaded / evt.total
                        : evt.loaded / 6100816;
    setProgress(Math.min(0.99, p));   // drives the ring + bar
  },
  (err) => { // GLB failed: keep the generated poster as the fallback
    loader.classList.add('done');
  });

Material variants — the shader that separates wood from boucle

mat.onBeforeCompile = (shader) => {
  // One baked texture holds BOTH materials, so we can't swap meshes.
  // Classify each texel by luminance + saturation, then treat it.
  shader.fragmentShader = shader.fragmentShader.replace(
    '#include <color_fragment>',
    `#include <color_fragment>
     float lum = ...;  float sat = ...;
     // boucle = light + low-saturation → MULTIPLY tint (keeps woven shading)
     float fabMask = smoothstep(0.44, 0.66, lum)
                   * (1.0 - smoothstep(0.36, 0.62, sat));
     diffuseColor.rgb = mix(wooded, uFabTint * soft, fabMask);`
  );
};
Multiply-tinting instead of replacing is the whole trick for the cushions: the baked cloth's highlights and shadows survive, so a terracotta boucle reads as dyed wool, not flat paint.

Damped orbit controls that never dip below the floor

const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;   controls.dampingFactor = 0.06;
controls.enablePan = false;
controls.minDistance = 2.6;   controls.maxDistance = 6.5;
controls.maxPolarAngle = Math.PI / 2 - 0.02; // never under the seat
controls.autoRotate = true;   controls.autoRotateSpeed = 0.85;
// auto-spin stops the instant the user grabs the model
controls.addEventListener('start', () => controls.autoRotate = false);

Dimension overlay drawn in 3D space

// Dashed amber leader lines + canvas-sprite labels, built from the
  // chair's bounding box, toggled by the "dimensions" chip (or the "d" key).
const dashMat = new THREE.LineDashedMaterial({ color: 0xa8672d,
  dashSize: 0.05, gapSize: 0.03 });
const line = new THREE.Line(geo, dashMat);
line.computeLineDistances();          // required for dashes to show
dimGroup.add(makeLabel('82 cm', new THREE.Vector3(xR + 0.18, midY, z)));

The three iteration passes

Every site is rendered in headless Chrome, screenshotted at desktop and mobile, and the pictures are read back and critiqued — code review can't see a papery cushion, only pixels can. Each pass fixes what it finds and adds one deliberate upgrade.

Pass 1 — get it on screen

First render threw a shader error: the injected uniforms were added to the uniform map but never declared in the GLSL, so the fragment shader wouldn't compile. Fixed by prepending the uniform declarations. With that resolved the chair appeared — but the cushions read as crumpled paper, because the first tint replaced the baked shading instead of multiplying it.

Pass 2 — the fork in the road

I tried the tempting hybrid: keep the scanned shell, cover the bad cushions with clean procedural rounded-box boucle. It looked worse — because the cushions are baked into the same mesh as the shell, they can't be removed, only darkened, and any darkened remnant peeking out reads as a defect. The two cushion systems fought each other. So I deleted the procedural cushions entirely and instead softened the scanned cushions in-shader (flatten harsh highlights, gentle even tone). The brief explicitly blessed a full-primitive fallback; the honest middle path — keep the gorgeous shell, treat the flawed cushions — beat both extremes. I also tightened the camera framing and moved the marginalia off the cluttered bottom edge.

Pass 3 — make the variants unmistakable, then add delight

Verifying variants live (ebony shell, clay boucle) showed the colourways were too subtle, so I boosted the fabric mask and tint strength until each reads clearly. The deliberate upgrades: a tactile field-of-view "settle" nudge when a material changes, and an easter egg — press R to roll a random configuration, D to toggle dimensions. Final pass: zero console errors at 1440×900 and 390×844, no horizontal scroll.

Reproduce this

  • Generate a clean studio product photo of your object (nano_banana_2), then lift it to a textured GLB (image_to_3d). Keep the photo — it's your poster and fallback.
  • Inspect the GLB before trusting it. Parse the header: how many meshes, materials, textures? A single baked texture means you can't swap sub-materials — plan variants accordingly.
  • Load with GLTFLoader and a real progress callback; fall back to evt.loaded / knownBytes when evt.total is 0 under gzip.
  • Set up the scene like a photographer: ACESFilmicToneMapping, a three-point rig, RoomEnvironment for soft image-based reflections, and a ShadowMaterial plane as the floor.
  • Damp your OrbitControls, clamp the polar angle above the floor, and stop auto-rotate on first interaction.
  • For variants on a baked texture, patch the material with onBeforeCompile and classify texels by luminance/saturation — multiply-tint fabric so its shading survives; be honest in comments that it's an approximation.
  • Render headless, read the screenshots yourself, and iterate at least three times. Judge the flawed asset honestly — sometimes the fix is to treat it, not to hide it.
  • Ship with zero console errors and no horizontal body scroll at 1440 and 390.