Command Palette

Search for a command to run...

Hectal
Course outline
Topic 9.6Intermediate ~27 min · step 6 of 6

Build: the Turntable and the Pedalling Crank

In one line

The bike pretends to ride on its stand: both wheels and the new crankset turn together at a real speed, the loop caps its delta, and a pause button plus the reduced-motion rule decide whether anything moves.

0/6 · 0%

Before you start

You will learn

  • Make the wheels and crankset turn at a real speed
  • Cap the delta in the loop
  • Add a pause button that respects reduced motion

You write code in

In your lumen-3d project: replace or add the six files (panel.js only gains the crankset's price), then run npm run dev

Files you touch

  • src/world/loop.js
  • src/ui/a11y.js
  • src/objects/bike.js
  • src/main.js
  • index.html
  • src/ui/panel.js

Think of it like this

A bike on a shop's display stand with its back wheel spinning slowly. It looks ready to go. The shop keeps a switch within reach, so a customer who finds it distracting can stop it.

Words you'll meet

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

crankset
The pedal arms and the big front gear (chainring) that turn together on the bottom bracket.
chainring
The big toothed front gear. Ours has 44 teeth.
gear ratio
Teeth on the chainring divided by teeth on the rear gear: 44 / 16 = 2.75 wheel turns for each pedal turn.
updatable
Any object with a tick(delta) method, added to the loop.

Step by step

01loop.js: cap the delta

One new line: Math.min(timer.getDelta(), MAX_DELTA) with MAX_DELTA = 0.1. The timer already gives 0 while the tab is hidden.

02bike.js: the crankset

createCrankset() builds the chainring from a cylinder with 44 sides and a radius of 44 × 12.7 mm ÷ 2π = 88.9 mm, a spindle along z and two 170 mm arms 180° apart, just outside the chain stays.

03bike.js: tick

bike.tick(delta) adds speed / WHEEL_RADIUS * delta radians to each wheel (minus: clockwise) and the same times 16 / 44 to the crankset.

04a11y.js and main.js

createMotion(button) returns { mayMove }. main.js adds the updatable that calls bike.tick when the rule allows, and shows the button when WebGL 2 works.

05Run it

The example is the changed files inlined. It steps the bike 60 times by 1/60 s, so the printed turns never depend on the clock.

Try it yourself

  1. 1

    Change the speed

    In the build example set bike.userData.speed = 5 (metres a second, 18 km/h) before the 60 steps. After 1 s the wheel has turned 5 / 0.352 / 2π = 2.26 turns.

  2. 2

    Press the button

    Press Pause: the wheels and the crank stop together and the button reads "Resume animation". The page's camera controls keep working.

Code & diagrams

Build: the showroom with its pedalling crank (the real project files, joined) New tab

This is the exact code of loop.js, a11y.js and bike.js below (import and export words removed, because local imports cannot run in the preview), plus short stand-ins for the other files. The button is the page's own. Open the scene tree: the bike now holds a `crankset`.

idle

Console

Run the scene to see its console.

Expected console output

bike parts: down-tube, top-tube, seat-tube, chain-stays, seat-stays, fork, front-wheel, rear-wheel, handlebar, crankset
speed: 1.667 m/s, after 1 s: wheel 0.754 turns, crankset 0.274 turns
may move on its own: true | button says: Pause animation

Your project files

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

src/world/loop.jsjs

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

import * as THREE from "three";

const MAX_DELTA = 0.1; // seconds: one slow frame (a debugger stop, a heavy page) must not throw the world forward

// The render loop. Anything with a tick(delta) method can be added; delta is in seconds.
export function createLoop(renderer, scene, camera) {
  const timer = new THREE.Timer();
  timer.connect(document); // zero delta while the tab is hidden, so nothing jumps when it comes back
  const updatables = [];

  function frame(time) {
    timer.update(time);
    const delta = Math.min(timer.getDelta(), MAX_DELTA); // Phase 9: never more than 0.1 s per frame
    for (const item of updatables) item.tick(delta);
    renderer.render(scene, camera);
  }

  return {
    start() { renderer.setAnimationLoop(frame); },
    stop() { renderer.setAnimationLoop(null); },
    add(updatable) { updatables.push(updatable); },
  };
}

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

Update the crankset by frame

In bike.tick, replace the crankset line with crankset.rotation.z -= 0.05;.

terminal
$ Run
── what you'll see ──
(no console error)
At 60 fps the crank turns 3 rad/s whatever the wheel does, and 7.2 rad/s at 144 fps.

Myth vs fact

Myth

The pedals and the wheel turn at the same speed.

Fact

With a 44-tooth chainring and a 16-tooth rear gear, the wheel turns 2.75 times for one pedal turn.

Pro corner

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

  • ▸

    A real crank turns the wheel only while you push. On a free-wheel hub the wheel can spin on while the pedals stop. Our pretend ride links them to keep the picture simple.

  • ▸

    The wheel's rotation grows without limit. After an hour at 4.7 rad/s it is about 17,000 radians. Float64 holds that without visible error, but keep it small (% (2 * Math.PI)) if the angle is also sent to a shader.

  • ▸

    createMotion reads matchMedia each time the rule is asked (query.matches), so a changed setting takes effect on the next frame without any extra code.

Remember this

  1. 1

    The problem this solves: the story's two complaints. Lena's phone and Ama's monitor now show the same speed, because everything uses delta time. And there is a pause button, plus a still bike for visitors who ask the system to reduce motion.

  2. 2

    What it adds. A new bike part, the crankset (a group named crankset at the bottom bracket: a 44-sided chainring, a spindle and two arms). bike.tick(delta) turns both wheels at v / r with v = 1.667 m/s (6 km/h), and turns the crankset slower by 16/44 (the rear gear has 16 teeth, the chainring 44), so the pedals go round 2.75 times slower than the wheel. src/world/loop.js caps the delta at 0.1 s. src/ui/a11y.js is new: createMotion(button) keeps the rule from Topic 9.5. index.html gets the button, and main.js wires them.

  3. 3

    How it plugs in. createLoop keeps its API. main.js adds one updatable that calls bike.tick(delta) only when motion.mayMove(). The visitor's own camera moves (controls from Phase 7) are another updatable and are never blocked. Phase 8's picking and panel keep working: the crankset is a new child of the bike, so it is pickable at once, and panel.js gains its label and price.

  4. 4

    What the showroom can do now that it could not before: pretend to ride at a real, frame-rate-independent speed, show a turning crankset, survive a stalled frame, pause on request and stay still for a visitor who reduced motion.

  5. 5

    What is still missing: the chain that joins the chainring to the rear gear (Phase 10), and an idle camera turntable. The camera orbit has no automatic turn yet, so the only automatic motion is the pedalling. Pedals (Phase 15), brakes (Phase 16) and the rest of the bike come later.

Explain it without notes

01

Why does main.js call bike.tick only when motion.mayMove(), but call the controls always?

02

How is the crank's angle related to the wheel's angle, and where in the code does the 2.75 come from?

Practice

01

Add a bike.userData.speed = 0 branch so the bike stands still when speed is 0, without special code.

02

Make the loop log a warning when a frame needed clamping.

Done when you can

  • Turn wheels at v / r with delta time

  • Add a part that follows another with a ratio

  • Add a pause button that obeys reduced motion

  • Say what the showroom still lacks (the chain, Phase 10)