HELIOS LAMP WORKS

COLOPHON · BUILT BY CLAUDE FABLE 5

How this was built

This site is one HTML page, one stylesheet, and one 950-line module. There is no model file and no photograph anywhere on it: the HL·1 is assembled at load time from Three.js primitives, lit by an arithmetic studio, and every number on the spec sheet is derived from the same constants that build the mesh. This page is the shop manual.

01 · THE BRIEF, AS RECEIVEDA one-product company

Brief 03 of the fable-25 set: Helios Lamp Works, a fictional company that makes exactly one task lamp. The mandate, condensed:

  • CONCEPTThe entire lamp built from Three.js primitives (lathe, cylinders, torus) — a real configurable object, not a model file.
  • PALETTEstudio white #f5f4f1 · graphite #2b2b2e · brass #b08d57 · enamels #a03a2a #7a8b6f #2d4a8a
  • TYPESora for display and UI · DM Mono for spec tables. Vendored woff2, latin subsets, nothing from a CDN at runtime.
  • TECHNIQUESEnamel + shade configurator with morphing lathe profiles · two draggable joints with spring-lerp constraints · emissive bulb, warm point light, shadow-catcher plane · live spec sheet · idle orbit with drag override.
  • MUST PROVEThat “product photography” can be computed — the lamp has to look desirable, under studio-quality three-point lighting.

02 · THE LAMP, FROM SOLIDSThirty-one parts, zero vertices by hand

Everything is a solid of revolution or a tube. The base plate is a lathe with a chamfered lip; arms are cylinders; knuckles are stacked cylinders with a steel groove torus; the shade is one lathe curve. The articulation is a plain parent–child chain — shoulder group, upper arm, elbow group, forearm — so posing the lamp is just two rotations.

/* base plate — a lathe with a chamfered lip, like the real blanchard part */
const basePts = [
  new THREE.Vector2(0.01, 0), new THREE.Vector2(82, 0), new THREE.Vector2(85, 2.5),
  new THREE.Vector2(85, 9), new THREE.Vector2(80, 12), new THREE.Vector2(23, 12),
  new THREE.Vector2(23, 12.01),
];
const base = new THREE.Mesh(new THREE.LatheGeometry(basePts, 96), mats.steel);

/* articulation chain */
lamp.jointA = new THREE.Group();
lamp.jointA.position.y = DIM.shoulderY;              // 100 mm — pivot A
root.add(lamp.jointA);
lamp.knuckA = knuckle(mats.brassA); lamp.jointA.add(lamp.knuckA);

lamp.armA = new THREE.Group(); lamp.jointA.add(lamp.armA);
const tubeA = new THREE.Mesh(
  new THREE.CylinderGeometry(DIM.armR, DIM.armR, DIM.L1 - 18, 28), mats.enamel);
tubeA.position.y = DIM.L1 / 2; tubeA.castShadow = true; lamp.armA.add(tubeA);

lamp.jointB = new THREE.Group(); lamp.jointB.position.y = DIM.L1;
lamp.armA.add(lamp.jointB);                          // elbow rides the upper arm

JS/MAIN.JS · BUILDLAMP() · UNITS ARE MILLIMETRES

The same DIM constants drive the SVG elevation drawing and the spec sheet, which is the site’s honesty mechanism: if the render and the drawing ever disagreed, one of them would be lying.

03 · MORPHING SHADESThree profiles, one parameterisation

Dome, cone and bell are all sampled at the same 26 points from apex to rim, so any shade can lerp into any other. Swapping shades tweens the blended curve and re-lathes it every frame for 0.6 s — a morph of the profile, never a crossfade of meshes.

} else {            // bell: waisted neck, swept flare
  const neck  = 0.30 * smooth(0, 0.5, t);   // wide enough to clear the socket
  const flare = 0.70 * Math.pow(smooth(0.48, 1, t), 1.6);
  r = APEX_R + (R - APEX_R) * (neck + flare);
  y = -D * (0.12 * t + 0.88 * Math.pow(t, 1.12));
}

/* in the main loop */
if (morph) {
  morph.t = Math.min(1, morph.t + dt / 0.6);
  const e = morph.t < .5 ? 4 * morph.t ** 3
                         : 1 - Math.pow(-2 * morph.t + 2, 3) / 2;
  curProf = morph.from.map((p, i) => ({
    r: lerp(p.r, morph.to[i].r, e),
    y: lerp(p.y, morph.to[i].y, e),
  }));
  updateShadeGeom();  // dispose + re-lathe outer, liner, rim, bulb drop
}

JS/MAIN.JS · SHADEPROFILE() + TICK()

04 · THE LIGHTING RIGA studio you could invoice

Three-point rig: warm key with soft VSM shadows, cool fill, warm rim strip — plus a PMREM environment generated from a fake room of softboxes, so the brass has something believable to reflect. The practical is real: an emissive bulb, a warm point light for spill, and a spot whose cone angle is the shade’s actual cut-off. Switch the lamp off and the whole studio drops 18% with it.

/* a PMREM'd room of softboxes — fake HDRI, no textures */
box(1500, 1100, 5.2, 5.0, 4.6, -1200, 1350,   950); // key softbox, warm
box(2600,  340, 6.0, 6.0, 6.3,   250, 1500, -1600); // rim strip, cool
box(1000, 1400, 2.1, 2.3, 2.6,  1500,  650,   850); // fill card
scene.environment = pmrem.fromScene(env, 0.03).texture;

const key = new THREE.DirectionalLight(0xfff1e0, 2.7);   // three-point rig
key.position.set(-620, 880, 620);
key.castShadow = true; key.shadow.radius = 14;           // VSM, soft
const fill = new THREE.DirectionalLight(0xdfe7f2, 0.75);
const rim  = new THREE.DirectionalLight(0xfff8ec, 1.9);

/* the practical answers the switch — and the studio answers the practical */
mats.bulb.emissiveIntensity = 4.5 * litK;
lamp.point.intensity = 30000 * litK;
lamp.spot.angle = Math.min(1.25, cutoffDeg(curProf) * D2R);
const sk = 0.82 + 0.18 * litK;                           // moodier when off
key.intensity = 2.7 * sk; scene.environmentIntensity = 0.5 * sk;

JS/MAIN.JS · INITGL() + APPLYLIGHT() · LITK IS THE SMOOTHED POWER STATE

05 · SPRUNG JOINTSDrag the brass, not a gizmo

Each knuckle carries an invisible 38 mm hit sphere. A grab converts cursor movement into the angle swept around the joint’s screen position; targets are clamped so the rim can never scrape the desk (the clamp knows the current shade’s depth); the displayed angles chase the targets through a slightly under-damped spring, which is what makes the arm feel like metal with mass instead of a slider with a mesh attached.

let d = screenAngle(e, drag.obj) - drag.grabAng;     // radians swept on screen
d = Math.atan2(Math.sin(d), Math.cos(d)) / D2R;      // unwrap
const sgn = camera.position.z >= 0 ? 1 : -1;         // which side we view from
if (drag.joint === 'a1') a1 = drag.grabA1 + d * sgn;
else                     b  = drag.grabB  - d * sgn;
setPose(a1, b);                                      // clamped, then sprung:

for (const [x, v, tgt] of [['a1','a1v','a1t'], ['b','bv','bt']]) {
  state[v] += (state[tgt] - state[x]) * 150 * dt;    // stiffness
  state[v] *= Math.exp(-17 * dt);                    // damping
  state[x] += state[v] * dt;                         // slight overshoot, settles
}

JS/MAIN.JS · BINDPOINTER() + TICK()

The spec sheet updates from the same pose: mass integrates the live lathe profile as a surface of revolution (1.2 mm spun aluminium plus the brass rim ring), reach and head height come from the joint trigonometry, and the lux figure is a two-term model — direct cone through the aperture plus one diffuse enamel bounce — labeled as the approximation it is.

06 · PROVENANCEEvery pixel is computed

No photography, no stock, no AI-generated imagery, no textures from disk. The render is Three.js primitives under procedural light; the base’s grinding marks are a 256-px canvas of random streaks tiled in lathe UV space; the elevation drawing is SVG built at runtime from the shared constants; the material chips are CSS gradients. Fonts are Sora (400/600/700) and DM Mono (400/400i/500), latin subsets from the Google Fonts corpus via @fontsource, vendored locally as woff2. Zero external requests at runtime. The company, its founders, its suppliers and every dimension are fiction written for this brief; the geometry and photometric arithmetic are real.

07 · CRITIQUE LOGThree passes, as they happened

  1. PASS 1 — THE HARNESS RUNS AT 1 FPS

    First renders under the headless GPU (SwiftShader) crawled, which exposed real bugs: the WebGL fallback notice was visible behind the canvas because a display rule overrode [hidden]; the pose sliders let float values escape their column; a doubled full stop in the hero deck; the cotton cable clipped through the base plate; the light pool sat off the beam axis; the live-spec rows were ordered differently from the panel labels. Fixed all of it, added a software-GL sniff (drop antialias and DPR), render-on-demand so settled frames cost nothing, and snap-to-target on any frame over 280 ms.

    Upgrade: the studio answers the switch — key, fill, environment and background all dim 18% when the lamp is off, so “off” is a mood, not a missing feature.

  2. PASS 2 — THE BELL EATS ITS BULB

    The bell profile’s waisted neck was narrower than the socket, so the bulb poked through the enamel wall — the neck term now opens 30% of the radius before the flare takes over. Deep shades could also scrape the desk after a swap, so the pose clamp learned the current shade’s depth and re-clamps on every swap. On mobile the absolutely-positioned header collided with the hero eyebrow (now static in flow), and the portrait camera framed the lamp too loose.

    Upgrade: a procedural blanchard-grinding roughness map on the steel base — random streaks drawn on a canvas, tiled 3× along the lathe’s U axis so they read as the concentric grinding rings on the real part.

  3. PASS 3 — SMALL LIES IN THE TITLE BLOCK

    Verified the configurator with clicked states: cobalt enamel and the bell morph both land, the drawing recolors, the spec sheet re-derives (52° · NARROW, ≈4,800 lx under the bell). Found the elevation drawing’s title-block text running to within 3 px of its border (type dropped 9.5→9), the mobile stage sitting the lamp low in its frame (camera target lowered 11 mm, lamp rises), and a hint line spending its third clause on “scroll,” which the page explains by existing.

    Upgrade: the brass switch knob on the base is now the real switch — its own raycast hit sphere, hover glow like the joints, a 1.6 mm press dip, wired to the same power state as the panel button. The hint now admits it: yes, the switch on the base works.

Reduced motion is honored throughout: with prefers-reduced-motion: reduce the reveals render settled, the idle orbit stops, springs and morphs snap to their targets, and the page stays fully configurable. Audited by hand in both CSS (reveal and scroll rules) and JS (the RM flag gates every lerp).