Command Palette

Search for a command to run...

Hectal
Course outline
Topic 19.5Advanced ~118 min · step 5 of 5

Capstone: Ship the Showroom

In one line

The showroom gets its last part, the Lumen badge, then goes through a launch check: a budget, a priced part list, a text alternative, a fallback picture, named buttons. The same checks live on as the project's own tests, and a README records what you measured, for your portfolio.

0/5 · 0%

Before you start

You will learn

  • Add the last part and see it in the side view
  • Turn the launch list into tests you run with npm test
  • Measure the build and write a portfolio README with real numbers

You write code in

In your lumen-3d project: npm install --save-dev --save-exact vite@8.3.4 vitest@5.0.5 happy-dom@21.0.3, add tests/scene.test.js and README.md, and change five files

Files you touch

  • src/objects/bike.js
  • src/ui/panel.js
  • src/main.js
  • index.html
  • package.json
  • tests/scene.test.js
  • README.md

Think of it like this

A pilot's pre-flight checklist. The plane was built and inspected long ago, and the pilot has flown it a hundred times. Still, before every take-off they read a written list aloud and tick each line, because memory skips steps when it is busy. A launch check does the same for the showroom: the same written list, run every time, by a machine that never gets bored.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

launch check
A written list of conditions the product must meet before release, run the same way every time.
happy-dom
A fake browser document that runs in Node, so tests can use document without a real browser. It has no 2D canvas and no WebGL.
Vitest
A test runner that uses Vite's setup; npm test runs it here.
pinned version
An exact version number in package.json (8.3.4), not a range (^8.3.0), so every install gets what you tested.
portfolio README
The project's front page on GitHub: what it does, how to run it, and what you measured.

Step by step

01Add the badge

Append createBadge() to bike.js, price it at €8 in panel.js (badge: { label: "Lumen badge", price: 8 }), and add it in main.js after the bell and the bottle cage. The panel now lists 19 parts.

02Add the tests

npm install --save-dev --save-exact vite@8.3.4 vitest@5.0.5 happy-dom@21.0.3, add "test": "vitest run" to the scripts in package.json, and create tests/scene.test.js from the file list. Run npm test: 7 passed, and the budget test prints triangles 10764, draw calls 44.

03Fix the layout and check both widths

Add the short CSS block from the index.html file in the list (before </style>). Then npm run build and npm run preview, and look at the page at 1,360 px and 390 px wide (devtools' device toolbar): no button sits on the panel or on another button (at 390 px the pause, view and effects buttons form three rows).

04Run the launch check and write the README

The build example below runs the check against the real loaded bike: every line must say PASS. Then copy the README from the file list and replace its numbers with your build's output: npm run build prints every file's size and its gzip size.

Try it yourself

  1. 1

    Break the budget on purpose

    In your project, give the badge new THREE.CircleGeometry(0.022, 100000) instead of 32 segments and run npm test. The budget test fails with AssertionError: expected 110732 to be less than or equal to 30000: 10,764 - 32 + 100,000 triangles. One careless argument and the bike costs ten times more, and the test says so before a phone does.

  2. 2

    Forget a price

    Delete the badge line from PARTS in panel.js and run npm test. It fails with AssertionError: badge: expected 0 to be greater than 0. The ?? 0 in the test is what makes this readable: without it, Vitest stops at TypeError: actual value must be number or bigint, received "undefined" and never says which part. Passing name as the second argument to expect puts it in front of the message.

  3. 3

    Look at the badge from behind

    In "The badge, measured", move the camera to camera.position.set(0.4, 0.64, -0.6) (the far side of the bike). The badge is gone: you see the tube's unlit side in dark orange, and the badge is behind it. The renderer still sends all 128 triangles, because three.js only skips whole objects outside the view (Topic 17.3), not hidden ones. A badge for the far side would be a second plate at z = -0.025 turned with rotation.y = Math.PI, since a CircleGeometry faces +z and has one side (side, Topic 5.1).

Code & diagrams

The badge, measured New tab

The badge from bike.js on a piece of the down tube, seen from the side. The dark disc with the orange "L" sits on the tube, tilted with it. 128 triangles: the tube's 96 plus the badge's 32.

idle

Console

Run the scene to see its console.

Expected console output

turned 40.4 degrees; the tube climbs at 40.4
off the centre line: 0.3 mm; in front of the tube's surface: 3 mm
triangles: 128, draw calls: 2
The finished showroom and its launch check New tab

Every project file joined into one (local imports can't run in the preview); boot.js is left out because it only waits and then loads main.js. The badge is on the down tube, below the handlebar. Each check line must say PASS.

idle

Console

Run the scene to see its console.

Expected console output

backend: WebGL 2
PASS budget: 11032 triangles, 46 draw calls, 21 textures, 48 geometries (limits 70, 30000, 24, 50)
PASS parts: 19, every one priced
PASS text alternative names 19 parts
PASS the fallback picture has alt text
PASS all 33 buttons have a name
PASS the canvas takes focus and the pause button is shown

Your project files

Your lumen-3d project after this topic. Click a file to see it in full.

src/objects/bike.jsjs

The whole file after this topic. If yours differs, you can copy this one and carry on.

import * as THREE from "three";

const frameMaterial = new THREE.MeshStandardMaterial({ color: "#ff6b2c", metalness: 0.4, roughness: 0.35 });
const metal = new THREE.MeshStandardMaterial({ color: "#b8c0cc", metalness: 0.9, roughness: 0.3 });
const UP = new THREE.Vector3(0, 1, 0);
const WHEEL_RADIUS = 0.352; // metres: rim 0.33 plus the tyre's 0.022
const CHAINRING_TEETH = 44;
const COG_TEETH = 16; // the small gear on the rear wheel: it turns 44 / 16 = 2.75 times for each turn of the pedals
const SHOWROOM_SPEED = 1.667; // m/s, 6 km/h: a gentle pretend ride on the stand

// Phase 6: the tyre's tread picture, wrapped round the tyre. The rear wheel uses the same material.
const tread = new THREE.TextureLoader().load("/data/3d/textures/rubber_tiles_diff_1k.jpg");
tread.colorSpace = THREE.SRGBColorSpace; // a colour picture
tread.wrapS = tread.wrapT = THREE.RepeatWrapping;
tread.repeat.set(30, 1);
const tyreMaterial = new THREE.MeshStandardMaterial({ map: tread, roughness: 0.9 });

// A cylinder between two points (from Phase 2).
export function tubeBetween(from, to, radius, name) {
  const direction = new THREE.Vector3().subVectors(to, from);
  const tube = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, direction.length(), 16), frameMaterial);
  tube.name = name;
  tube.position.copy(from).add(to).multiplyScalar(0.5);
  tube.quaternion.setFromUnitVectors(UP, direction.normalize());
  return tube;
}

// Phase 4: a left and a right tube in one group, at z = -0.055 and +0.055.
function pair(name, from, to, radius) {
  const group = new THREE.Group();
  group.name = name;
  for (const z of [-0.055, 0.055]) group.add(tubeBetween(from.clone().setZ(z), to.clone().setZ(z), radius, `${name}-${z < 0 ? "left" : "right"}`));
  return group;
}

// Phase 5: two legs from the head tube down to the front axle.
function createFork() {
  const fork = new THREE.Group();
  fork.name = "fork";
  for (const z of [-0.05, 0.05]) fork.add(tubeBetween(new THREE.Vector3(0.47, 0.72, z), new THREE.Vector3(0.58, 0.35, z), 0.012, `fork-leg-${z < 0 ? "left" : "right"}`));
  return fork;
}

// Phases 6 and 7: one wheel function for both wheels. Radius 0.33 m, axle 0.35 m up, turns about z.
export function createWheel(name, x) {
  const wheel = new THREE.Group();
  wheel.name = name;
  wheel.position.set(x, 0.35, 0);
  wheel.add(new THREE.Mesh(new THREE.TorusGeometry(0.33, 0.022, 16, 64), tyreMaterial)); // the tyre
  wheel.add(new THREE.Mesh(new THREE.TorusGeometry(0.3, 0.008, 8, 64), metal)); // the rim
  const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.1, 16), metal);
  hub.rotation.x = Math.PI / 2; // a cylinder stands along y: lay it along the axle (z)
  wheel.add(hub);

  const spokes = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.003, 0.003, 0.27, 6), metal, 18); // 18 spokes, one draw call
  const matrix = new THREE.Matrix4();
  for (let i = 0; i < 18; i++) {
    const angle = (i / 18) * Math.PI * 2;
    const position = new THREE.Vector3(Math.cos(angle) * 0.165, Math.sin(angle) * 0.165, 0); // halfway between hub and rim
    const turn = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), angle - Math.PI / 2);
    spokes.setMatrixAt(i, matrix.compose(position, turn, new THREE.Vector3(1, 1, 1)));
  }
  wheel.add(spokes);
  return wheel;
}

// New in Phase 8: the stem leaves the top of the head tube, the bar crosses the bike, a grip sits on each end.
// All of it is one group named "handlebar", so a click on any piece picks the whole part.
const dark = new THREE.MeshStandardMaterial({ color: "#2b3038", roughness: 0.5 });
const rubber = new THREE.MeshStandardMaterial({ color: "#22262c", roughness: 0.9 });

function createHandlebar(headTop) {
  const handlebar = new THREE.Group();
  handlebar.name = "handlebar";
  const stemEnd = new THREE.Vector3(0.5, 0.9, 0);
  const stem = tubeBetween(headTop, stemEnd, 0.014, "stem");
  stem.material = dark; // not the frame's orange
  handlebar.add(stem);

  const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.011, 0.011, 0.56, 12), dark);
  bar.name = "bar";
  bar.rotation.x = Math.PI / 2; // a cylinder stands along y; turn it to lie along z, across the bike
  bar.position.copy(stemEnd);
  handlebar.add(bar);

  for (const z of [-0.23, 0.23]) {
    const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.016, 0.016, 0.1, 12), rubber);
    grip.name = "grip";
    grip.rotation.x = Math.PI / 2;
    grip.position.set(stemEnd.x, stemEnd.y, z);
    handlebar.add(grip);
  }
  return handlebar;
}

// New in Phase 9: the crankset. A 44-tooth chainring (a 44-sided disc), a spindle through the bottom bracket and two arms.
function createCrankset() {
  const crankset = new THREE.Group();
  crankset.name = "crankset";
  crankset.position.set(0, 0.3, 0); // the bottom bracket
  const ringRadius = (CHAINRING_TEETH * 0.0127) / (2 * Math.PI); // a chain link is 12.7 mm: 44 links round the ring
  const ring = new THREE.Mesh(new THREE.CylinderGeometry(ringRadius, ringRadius, 0.004, CHAINRING_TEETH), metal);
  ring.rotation.x = Math.PI / 2; // lay the disc flat against the bike's side
  ring.position.z = 0.075; // outside the right chain stay
  const spindle = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.19, 12), metal);
  spindle.rotation.x = Math.PI / 2;
  crankset.add(ring, spindle);
  for (const [z, angle] of [[0.085, 0], [-0.085, Math.PI]]) { // the arms point in opposite directions
    const arm = new THREE.Mesh(new THREE.BoxGeometry(0.17, 0.022, 0.012).translate(0.085, 0, 0), metal);
    arm.position.z = z;
    arm.rotation.z = angle;
    crankset.add(arm);
  }
  return crankset;
}

export function createBike() {
  const BB = new THREE.Vector3(0, 0.3, 0); // bottom bracket
  const seatCluster = new THREE.Vector3(-0.1, 0.82, 0);
  const headTop = new THREE.Vector3(0.44, 0.84, 0);
  const headBottom = new THREE.Vector3(0.47, 0.7, 0);
  const rearAxle = new THREE.Vector3(-0.55, 0.35, 0);

  const frontWheel = createWheel("front-wheel", 0.58);
  const rearWheel = createWheel("rear-wheel", -0.55);
  const crankset = createCrankset();

  const bike = new THREE.Group();
  bike.name = "bike";
  bike.add(
    tubeBetween(BB, headBottom, 0.022, "down-tube"),
    tubeBetween(seatCluster, headTop, 0.018, "top-tube"),
    tubeBetween(BB, seatCluster, 0.02, "seat-tube"),
    pair("chain-stays", BB, rearAxle, 0.013),
    pair("seat-stays", seatCluster, rearAxle, 0.012),
    createFork(),
    frontWheel,
    rearWheel,
    createHandlebar(headTop), // new in Phase 8
    crankset, // new in Phase 9
  );

  addRide(bike); // Phase 10: the ride now finds its parts by name, so it also works on a loaded model
  return bike;
}

/** Phase 9's pretend ride: the wheels turn at omega = v / r, and the crank turns slower by the gear ratio. */
export function addRide(bike) {
  const frontWheel = bike.getObjectByName("front-wheel");
  const rearWheel = bike.getObjectByName("rear-wheel");
  const crankset = bike.getObjectByName("crankset");
  bike.userData.speed = SHOWROOM_SPEED; // m/s: set it to 0 to stand still
  bike.tick = (delta) => {
    const wheelTurn = (bike.userData.speed / WHEEL_RADIUS) * delta; // radians this frame
    frontWheel.rotation.z -= wheelTurn; // minus: the top of a wheel rolling towards +x turns clockwise
    rearWheel.rotation.z -= wheelTurn;
    crankset.rotation.z -= wheelTurn * (COG_TEETH / CHAINRING_TEETH);
    bike.getObjectByName("pedals")?.userData.follow(crankset.rotation.z); // Phase 15: the pedals ride on the crank arms
  };
}

/** Phase 10: the chain, two thin steel runs from the chainring to the rear sprocket, on the right side (z = 0.07). */
export function createChain() {
  const chain = new THREE.Group();
  chain.name = "chain";
  const steel = new THREE.MeshStandardMaterial({ color: "#3a3f47", metalness: 0.8, roughness: 0.4 });
  const runs = [
    [[-0.55, 0.38], [0, 0.39]], // the top run, which pulls the wheel
    [[-0.55, 0.32], [0, 0.21]], // the slack bottom run
  ];
  for (const [a, b] of runs) {
    const from = new THREE.Vector3(a[0], a[1], 0.07);
    const to = new THREE.Vector3(b[0], b[1], 0.07);
    const run = new THREE.Mesh(new THREE.CylinderGeometry(0.005, 0.005, from.distanceTo(to), 8), steel);
    run.position.copy(from).add(to).multiplyScalar(0.5);
    run.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), to.clone().sub(from).normalize());
    chain.add(run);
  }
  return chain;
}

/** Phase 12: puts one paint material on every frame tube (the stays and fork legs too). Wheels, chain and the rest keep theirs. */
const FRAME_PARTS = ["down-tube", "top-tube", "seat-tube", "chain-stays", "seat-stays", "fork"];
export function paintFrame(bike, paint) {
  const replaced = new Set();
  let count = 0;
  for (const name of FRAME_PARTS) {
    const part = bike.getObjectByName(name);
    if (!part) {
      console.warn("paintFrame: no part named " + name);
      continue;
    }
    part.traverse((object) => {
      if (!object.isMesh) return;
      replaced.add(object.material);
      object.material = paint;
      count++;
    });
  }
  for (const material of replaced) material.dispose(); // the old paint is no longer used by anything
  return count;
}

/** Phase 13: the headlight, a housing and a lens just ahead of the head tube. main.js passes the lens material:
    the TSL glow on the WebGPU path, the bright classic emissive below on WebGL 2. */
export function createHeadlight(lensMaterial = createLens()) {
  const headlight = new THREE.Group();
  headlight.name = "headlight";
  headlight.position.set(0.5, 0.86, 0);
  const housing = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.045, 0.06, 24), new THREE.MeshStandardMaterial({ color: "#22262c", roughness: 0.5 }));
  housing.rotation.z = -Math.PI / 2; // a cylinder stands along y: lay it along x, pointing forward
  const lens = new THREE.Mesh(new THREE.SphereGeometry(0.04, 24, 16), lensMaterial);
  lens.scale.x = 0.5; // a flattened bulb
  lens.position.x = 0.04;
  lens.userData.keepLook = true; // a hover highlight must not replace the lens's glow
  headlight.add(housing, lens);
  return headlight;
}

/** The WebGL 2 lens: emissive far brighter than white (linear 6), the light a real lamp gives. */
export function createLens() {
  return new THREE.MeshStandardMaterial({ color: "#1a1d22", roughness: 0.2, emissive: "#fff2c0", emissiveIntensity: 6 });
}

/** Phase 14: the rear light, a small red lamp on the seat tube that faces backwards (-x). Its glow is drawn by src/systems/rear-glow.js. */
export function createRearLight() {
  const lamp = new THREE.Mesh(
    new THREE.BoxGeometry(0.03, 0.05, 0.035),
    new THREE.MeshStandardMaterial({ color: "#5a0d0a", emissive: "#ff2a1a", emissiveIntensity: 1.5 }),
  );
  lamp.name = "rear-light";
  lamp.position.set(-0.12, 0.74, 0); // just behind the seat tube, below the seat cluster
  return lamp;
}

// New in Phase 15: the pedals. Each one hangs on the end of a crank arm and stays level as the crankset turns.
const PEDAL_ARM = 0.17; // metres from the bottom bracket to the pedal axle: the crank arm's length

export function createPedals() {
  const pedals = new THREE.Group();
  pedals.name = "pedals";
  const grip = new THREE.MeshStandardMaterial({ color: "#2b3038", roughness: 0.6 });
  const sides = [];
  for (const side of [1, -1]) { // +1: the right pedal (z > 0), -1: the left one
    const pedal = new THREE.Group();
    pedal.name = side > 0 ? "pedal-right" : "pedal-left";
    const platform = new THREE.Mesh(new THREE.BoxGeometry(0.095, 0.016, 0.07), grip); // the flat the foot stands on
    platform.position.z = side * 0.041;
    const axle = new THREE.Mesh(new THREE.CylinderGeometry(0.007, 0.007, 0.05, 8), metal); // a cylinder stands along y: turn it along z
    axle.rotation.x = Math.PI / 2;
    axle.position.z = side * 0.016;
    pedal.add(platform, axle);
    pedals.add(pedal);
    sides.push([pedal, side]);
  }
  // Put the pedals where the crank arms end. angle is the crankset's turn; the left arm points the opposite way.
  pedals.userData.follow = (angle) => {
    for (const [pedal, side] of sides) {
      pedal.position.set(side * PEDAL_ARM * Math.cos(angle), 0.3 + side * PEDAL_ARM * Math.sin(angle), side * 0.085);
    }
  };
  pedals.userData.follow(0);
  return pedals;
}

/** Phase 16: the brakes, two calipers that grip the rims: one on the fork above the front wheel, one on the seat stays. */
export function createBrakes() {
  const brakes = new THREE.Group();
  brakes.name = "brakes";
  const pad = new THREE.MeshStandardMaterial({ color: "#2b3038", roughness: 0.5, emissive: "#ff2a1a", emissiveIntensity: 0 });
  for (const [x, y] of [[0.56, 0.69], [-0.5, 0.68]]) { // just above each rim (wheel centre y 0.35, rim radius 0.33)
    const caliper = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.03, 0.08), pad);
    caliper.position.set(x, y, 0);
    brakes.add(caliper);
  }
  // the pads glow red while the visitor brakes: you see the input reach the bike
  brakes.userData.show = (on) => { pad.emissiveIntensity = on ? 1.5 : 0; };
  return brakes;
}

/** Phase 17: the mudguards, two thin arcs a little above the tyres. One geometry serves both wheels. */
export function createMudguards() {
  const mudguards = new THREE.Group();
  mudguards.name = "mudguards";
  const rad = (degrees) => (degrees * Math.PI) / 180;
  // An open slice of a cylinder 0.375 m from the axle (the tyre ends at 0.352 m), 0.06 m wide.
  // Angles are measured from straight down, so PI is the top: it starts 8 degrees in front of the top and
  // runs 108 degrees round to the back (for the rear wheel).
  const arc = new THREE.CylinderGeometry(0.375, 0.375, 0.06, 32, 1, true, Math.PI - rad(8), rad(108));
  arc.rotateX(Math.PI / 2); // a cylinder stands along y: lay its axis along the axle (z)
  const paint = new THREE.MeshStandardMaterial({ color: "#5b6575", roughness: 0.6, metalness: 0.3, side: THREE.DoubleSide });
  for (const [x, turn] of [[-0.55, 0], [0.58, Math.PI]]) {
    const guard = new THREE.Mesh(arc, paint);
    guard.position.set(x, 0.35, 0);
    guard.rotation.y = turn; // the front guard is the rear one turned half a circle about the vertical axis
    mudguards.add(guard);
  }
  return mudguards;
}

/**
 * Phase 17: a new frame colour. The frame's paint material is shared, so changing its colour makes nothing new:
 * no new texture, no new shader, nothing to dispose. (Making a new material per colour is how configurators leak.)
 */
export function setFrameColour(bike, colour) {
  for (const name of FRAME_PARTS) {
    bike.getObjectByName(name)?.traverse((object) => {
      if (!object.isMesh) return;
      (object.userData.base ?? object.material).color.set(colour); // the picker keeps the real paint in userData.base
      object.material.color.set(colour); // and a hovered or chosen part shows a glowing copy of it
    });
  }
}

/** Phase 18: the bell, a chrome dome clamped to the handlebar just right of the stem. */
export function createBell() {
  const bell = new THREE.Group();
  bell.name = "bell";
  const dome = new THREE.Mesh(new THREE.SphereGeometry(0.03, 24, 12, 0, Math.PI * 2, 0, Math.PI / 2), metal); // the top half of a sphere
  const clamp = new THREE.Mesh(new THREE.CylinderGeometry(0.014, 0.014, 0.025, 12), dark);
  clamp.rotation.x = Math.PI / 2; // a ring round the bar, which runs along z
  bell.add(dome, clamp);
  bell.position.set(0.5, 0.911, 0.09); // the bar is at (0.5, 0.9, z); the dome sits on top of it
  return bell;
}

/** Phase 18: the bottle cage on the down tube (BB to head tube bottom), with a bottle in it so it reads as a cage. */
export function createBottleCage() {
  const cage = new THREE.Group();
  cage.name = "bottle-cage";
  const along = new THREE.Vector3(0.47, 0.4, 0).normalize(); // the tube's direction
  const outward = new THREE.Vector3(-along.y, along.x, 0); // perpendicular, pointing up and back, away from the tube
  const centre = new THREE.Vector3(0, 0.3, 0).addScaledVector(along, 0.28).addScaledVector(outward, 0.024 + 0.03); // 28 cm up the tube, a bottle's radius off it
  const bottle = new THREE.Mesh(new THREE.CylinderGeometry(0.028, 0.028, 0.19, 16), new THREE.MeshStandardMaterial({ color: "#2a9df4", roughness: 0.4 }));
  bottle.quaternion.setFromUnitVectors(UP, along); // a cylinder stands along y: turn it along the tube
  bottle.position.copy(centre);
  cage.add(bottle);
  for (const offset of [-0.06, 0.06]) { // two thin steel loops round the bottle
    const loop = new THREE.Mesh(new THREE.TorusGeometry(0.031, 0.003, 8, 24), metal);
    loop.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), along); // a torus lies in the xy plane: its axis is z, turn it to the tube's
    loop.position.copy(centre).addScaledVector(along, offset);
    cage.add(loop);
  }
  return cage;
}

/** Phase 19: the Lumen badge, a small round plate with the "L" of the logo, on the side of the down tube. */
export function createBadge() {
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = 128;
  const ctx = canvas.getContext("2d"); // null where there is no 2D canvas (the tests' happy-dom): then a plain disc
  if (ctx) {
    ctx.fillStyle = "#0b1118";
    ctx.beginPath();
    ctx.arc(64, 64, 62, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = "#ff6b2c";
    ctx.font = "bold 84px system-ui, sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText("L", 64, 70);
  }
  const logo = new THREE.CanvasTexture(canvas);
  logo.colorSpace = THREE.SRGBColorSpace; // a picture: its colours are sRGB

  const badge = new THREE.Mesh(
    new THREE.CircleGeometry(0.022, 32),
    new THREE.MeshStandardMaterial({ map: logo, metalness: 0.3, roughness: 0.4, transparent: true, alphaTest: 0.5 }),
  );
  badge.name = "badge";
  // on the side of the down tube near the head (the down tube runs from (0, 0.30) to (0.47, 0.70)): seen in the side view
  badge.position.set(0.4, 0.64, 0.025); // 0.022 m tube radius, plus a few millimetres
  badge.rotation.z = Math.atan2(0.4, 0.47); // turned with the tube, about 40 degrees
  return badge;
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Remove the null guard

In createBadge(), delete if (ctx) { and its closing } so the drawing always runs, then run npm test.

terminal
$ npm test
── what you'll see ──
TypeError: Cannot set properties of null (setting 'fillStyle')
Test Files 1 failed (1)

Myth vs fact

Myth

Tests for a 3D scene need a GPU.

Fact

The scene graph, geometry, matrices, budgets and prices are plain JavaScript and run in Node in about 2 seconds. Only pixels need a GPU; check those with the launch check in a real browser and with screenshots.

Myth

A portfolio project should list features.

Fact

Interviewers remember numbers and trade-offs: "the first script is 2.32 kB, three.js waits until the showroom is visible, Rapier's 4.3 MB loads only on the first ride". Say what you measured and what you did not check.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Run npm test in CI on every pull request (GitHub Actions needs no GPU for these tests). For pixels, Playwright with Chromium's software renderer gives stable images for the same browser version on the same machine, which is enough to compare screenshots (toHaveScreenshot, with a small tolerance).

  • ▸

    The launch check uses renderer.info.render after one plain render. Production budgets are usually watched over time instead, with the once-a-second check from Phase 17 (createBudget) logging when a frame goes over.

  • ▸

    Pin exact versions for a launch, then update on purpose: three.js changes APIs between releases (its migration guide lists them per r-number), and a range like ^0.186.0 would not even allow 0.187 under npm's rules for 0.x versions, while ^8.3.0 silently moves to any 8.x Vite.

Remember this

  1. 1

    The problem this solves: Tomás's launch list (fits Lena's phone, works for Ravi with a screen reader, survives without WebGPU, without WebGL, after a GPU reset) is only true today. Next month someone adds a part with a 200,000-triangle model, or forgets its price. A check in a test file catches that on their machine, before a customer does.

  2. 2

    The last part: the badge. createBadge() in bike.js draws the "L" of the logo on a 128 × 128 canvas and wraps it in a CanvasTexture (Phase 6), with colorSpace = SRGBColorSpace because it is a picture (Topic 5.6). The plate is a CircleGeometry(0.022, 32): 32 triangles, one draw call. It sits on the down tube, 2.5 cm in front of its centre line (the tube's radius is 2.2 cm, so 3 mm off the paint), turned 40.4° to follow the tube: Math.atan2(0.4, 0.47) is the tube's climb, 0.40 m up over 0.47 m across. The example below measures all three instead of trusting them.

  3. 3

    **Why getContext("2d") can return null.** The project's tests run in happy-dom, a fake browser in Node, and happy-dom has no 2D canvas: getContext("2d") returns null. Without the if (ctx) guard the tests crash on ctx.fillStyle. With it, the badge is a plain disc in the tests and the logo in the browser. Code that runs in two places must survive the poorer one.

  4. 4

    The launch check. The build example runs the whole showroom, then a short list, printing PASS or FAIL per line: the frame inside the showroom's own budget (BUDGET in budget.js, Phase 17: 70 draw calls, 30,000 triangles, 24 textures, 50 geometries), all 19 parts priced, a text alternative naming 19 parts, alt text on the fallback picture, a name on every button, the canvas in the Tab order and the pause button shown. Measured: 46 draw calls, 11,032 triangles, 21 textures and 48 of 50 geometries, so the next part needs headroom first. One trap, found the hard way: Phase 17 set renderer.info.autoReset = false, so the check calls renderer.info.reset() before its render. Without it, the same check printed 62 draw calls and failed, because it added its own render to a frame the loop had already counted.

  5. 5

    The same list as tests. tests/scene.test.js builds the bike in code with no GPU (three.js's scene graph and maths are plain JavaScript) and checks that every part exists once, the wheels sit on their axles, the bike alone fits the same BUDGET (measured: 10,764 triangles and 44 draw calls), the wheels turn at ω = v / r, every part has a price, and index.html keeps its fallback and its keyboard and screen-reader names. npm test runs all seven in about 2 seconds. What the tests cannot see (a real GPU, the loaded model, the layout) is what the build example and your own eyes check.

  6. 6

    The launch check found a real bug. At 1,360 px wide, the 19-part panel grew taller than the canvas and ran under the view buttons; at 390 px, the view buttons and the effects button sat on top of each other. index.html now caps the panel's height with its own scroll, and on narrow screens stacks the pause button, the view buttons and the effects button in three separate rows. The capstone's tests could not catch this: only a screenshot at both widths did.

  7. 7

    What the showroom can do now: everything the course built, with a last part, a written launch list, tests you can run before every change, pinned versions (vite was ^8.3.0; now exactly 8.3.4, the version measured) and a README that says what was measured, what was checked and what was not. What is still missing, and honest to say in an interview: a recovery path for a lost WebGPU device, testing with a real screen reader and on real phones, and a CDN with the cache headers from Topic 18.6.

Explain it without notes

01

Walk an interviewer through your showroom in two minutes: what it does, how it loads, what you measured, and what you would do next.

02

Which of the launch checks belong in unit tests, which need a real browser, and which need a person?

03

Why does the badge use a CanvasTexture instead of an image file, and what does that cost?

Practice

01

Add a test that fails if any part's material is transparent but has no alphaTest, which can cause sorting problems (transparent, Topic 5.1).

02

The launch check shows 48 of 50 geometries. Before a 20th part, how do you make room without raising the limit?

Done when you can

  • You can add a part that works in the browser and in the tests.

  • You can write a launch check and the same list as unit tests.

  • You can measure a build and present the numbers.

  • You can say honestly what you checked and what you did not.