Course outline
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.
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
documentwithout a real browser. It has no 2D canvas and no WebGL. - Vitest
- A test runner that uses Vite's setup;
npm testruns 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
Break the budget on purpose
In your project, give the badge
new THREE.CircleGeometry(0.022, 100000)instead of 32 segments and runnpm test. The budget test fails withAssertionError: 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
Forget a price
Delete the
badgeline fromPARTSinpanel.jsand runnpm test. It fails withAssertionError: badge: expected 0 to be greater than 0. The?? 0in the test is what makes this readable: without it, Vitest stops atTypeError: actual value must be number or bigint, received "undefined"and never says which part. Passingnameas the second argument toexpectputs it in front of the message. - 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 withrotation.y = Math.PI, since aCircleGeometryfaces +z and has one side (side, Topic 5.1).
Code & diagrams
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.
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: 2Every 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.
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 shownYour project files
Your lumen-3d project after this topic. Click a file to see it in full.
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.
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 testin 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.renderafter 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.0would not even allow 0.187 under npm's rules for 0.x versions, while^8.3.0silently moves to any 8.x Vite.
Remember this
- 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
The last part: the badge.
createBadge()inbike.jsdraws the "L" of the logo on a 128 × 128 canvas and wraps it in aCanvasTexture(Phase 6), withcolorSpace = SRGBColorSpacebecause it is a picture (Topic 5.6). The plate is aCircleGeometry(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
**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")returnsnull. Without theif (ctx)guard the tests crash onctx.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
The launch check. The build example runs the whole showroom, then a short list, printing
PASSorFAILper line: the frame inside the showroom's own budget (BUDGETinbudget.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 setrenderer.info.autoReset = false, so the check callsrenderer.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
The same list as tests.
tests/scene.test.jsbuilds 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 sameBUDGET(measured: 10,764 triangles and 44 draw calls), the wheels turn at ω = v / r, every part has a price, andindex.htmlkeeps its fallback and its keyboard and screen-reader names.npm testruns 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
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.htmlnow 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
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 (
vitewas^8.3.0; now exactly8.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
Walk an interviewer through your showroom in two minutes: what it does, how it loads, what you measured, and what you would do next.
Which of the launch checks belong in unit tests, which need a real browser, and which need a person?
Why does the badge use a CanvasTexture instead of an image file, and what does that cost?
Practice
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).
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.