Command Palette

Search for a command to run...

Hectal
Course outline
Topic 8.6Intermediate ~37 min · step 6 of 6

Build: Pick a Part

In one line

The showroom learns to answer. Click or tap a part, or Tab to it in a list: it glows and a panel shows its name and price. The bike gains its handlebar and stem.

0/6 · 0%

Before you start

You will learn

  • Pick a part with the mouse, a finger or the keyboard
  • Show a part's name and price in a panel that a screen reader can follow
  • Add the handlebar and stem to the bike

You write code in

In your lumen-3d project: add or replace these files, then run npm run dev

Files you touch

  • index.html
  • src/main.js
  • src/objects/bike.js
  • src/systems/picking.js
  • src/ui/panel.js

Think of it like this

A shop assistant stands next to the bike. Point at a part and they tell you its name and price. If you cannot point, they read you the list of parts. The showroom gets that assistant today: a panel that answers a click, a tap or a key.

Words you'll meet

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

picker
The module that turns pointer events into "which part is under the pointer" and keeps the hover and chosen state.
part
A direct child of the bike: a tube, a group of tubes, the handlebar. Clicking any mesh inside climbs to its part.
stem
The short tube that joins the handlebar to the fork. This phase's bike part, together with the bar.
live line
The panel's text line marked aria-live, which a screen reader speaks when it changes.

Step by step

01Add the picker and the panel

Create src/systems/picking.js and src/ui/panel.js and paste the files below. The picker has no knowledge of HTML; the panel has no knowledge of three.js. main.js is the only place that knows both.

02Add the handlebar to bike.js

Add the createHandlebar function and the line createHandlebar(headTop) inside bike.add(...). Open the scene tree and look under bike for handlebar, with stem, bar and two grip meshes inside.

03Update index.html and main.js

The page gets the #panel and its styles, and #app becomes focusable. main.js creates the picker and the panel, adds the picker to the loop and handles the keys.

04Try all three ways in

Click the handlebar. Tap it on a phone. Press Tab: the viewer takes focus first (try the arrows), then each button, and focus on a button lights its part. Enter chooses, Escape clears.

Try it yourself

  1. 1

    Choose another part

    In the live example change picker.select("handlebar") in the self-test to picker.select("chain-stays"). The last line becomes panel says: Chain stays · €55, and the chain stays glow blue in the picture.

  2. 2

    Look at a part by hand

    Hover the mouse over the seat tube: it turns lighter and the cursor becomes a hand. Click it: it glows blue and the button for it in the panel turns orange. Click empty space: it resets.

Code & diagrams

Build: the showroom after Phase 8 (all files in one example) New tab

Local imports cannot run in the preview, so this example has the code of every project file inline, each under a comment with its path (the Phase 3 modules are shortened). The text output is a self-test: a ray at the middle of each part, then `select("handlebar")`. Try the panel and the canvas by hand.

idle

Console

Run the scene to see its console.

Expected console output

ray at the middle of handlebar   -> handlebar
ray at the middle of seat-tube   -> seat-tube
ray at the middle of down-tube   -> down-tube
ray at an empty corner                   -> null
panel says: Handlebar and stem · €60

Your project files

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

index.htmlhtml

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

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Lumen Bikes · 3D showroom</title>
    <style>
      html, body { margin: 0; height: 100%; background: #0b1118; color: #e6edf6; font-family: system-ui, sans-serif; }
      #app { position: fixed; inset: 0; }
      canvas { display: block; }
      #views { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; gap: 8px; }
      #views[hidden] { display: none; }
      #views button { min-height: 44px; padding: 0 18px; border: 1px solid #3b4757; border-radius: 22px; background: #121b26; color: inherit; font: inherit; cursor: pointer; }
      #views button:hover { border-color: #ff6b2c; }
      #views button:focus-visible { outline: 3px solid #2a9df4; outline-offset: 2px; }
      #app:focus-visible { outline: 3px solid #2a9df4; outline-offset: -3px; }
      #panel { position: fixed; top: 12px; right: 12px; width: min(240px, calc(100% - 24px)); padding: 12px; border-radius: 12px; background: #151d29ee; }
      #panel[hidden] { display: none; }
      #panel h2 { margin: 0 0 8px; font-size: 14px; }
      #panel ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
      #panel button { width: 100%; padding: 6px 10px; border: 2px solid #3b4658; border-radius: 8px; background: transparent; color: inherit; font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
      #panel button[aria-pressed="true"] { border-color: #ff6b2c; background: #ff6b2c; color: #0b1118; font-weight: 600; }
      #panel button:focus-visible { outline: 3px solid #2a9df4; outline-offset: 2px; }
      #panel .info { min-height: 2.6em; margin: 10px 0 0; font-size: 13px; }
      @media (max-width: 760px) {
        #panel { top: 8px; left: 8px; right: 8px; width: auto; padding: 8px; }
        #panel h2 { display: none; }
        #panel ul { display: flex; flex-wrap: wrap; }
        #panel button { width: auto; padding: 4px 8px; }
        #panel .info { min-height: 0; margin-top: 6px; }
      }
      #fallback { position: fixed; inset: 0; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 16px; text-align: center; background: #0b1118; }
      #fallback[hidden] { display: none; }
      #fallback img { max-width: min(100%, 420px); max-height: 60%; border-radius: 8px; }
    </style>
  </head>
  <body>
    <div id="app" tabindex="0" role="group" aria-label="Lumen bike viewer. Arrow keys turn the view, plus and minus zoom, Escape clears the choice."></div>
    <aside id="panel" aria-label="Bike parts"></aside>
    <nav id="views" aria-label="Camera views" hidden>
      <button type="button" data-view="side">Side</button>
      <button type="button" data-view="front">Front</button>
      <button type="button" data-view="detail">Rear wheel</button>
    </nav>
    <div id="fallback" hidden>
      <p>Your browser can't draw 3D here, so here is a picture of the Lumen bike instead.</p>
      <img src="/data/3d/showroom-poster.png" alt="The Lumen Bikes showroom: an orange e-bike on a round display stand" />
    </div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

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

Pick the mesh, not the part

In picking.js delete the line while (object.parent && object.parent !== root) object = object.parent; // climb to the child of root.

terminal
$ Run
── what you'll see ──
ray at the middle of handlebar -> null
Only the grouped part changes: the seat tube and down tube are single meshes and still work.

Myth vs fact

Myth

Picking belongs in the render loop with the drawing code.

Fact

Picking reads the scene but never draws. Keeping it in its own module means the stand, the panel and tests can use the same picker, and the loop only calls tick().

Pro corner

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

  • ▸

    The glow swaps each mesh's material for a cached copy with emissive set, instead of editing the shared paint, because every tube shares one frameMaterial. Editing it would light all parts at once. The copies are created once per original material and colour and reused.

  • ▸

    tick() re-picks under a resting mouse once per frame, using the matrices from the last render, so the hover can lag the camera by one frame. That is hard to see and avoids calling updateMatrixWorld twice.

Remember this

  1. 1

    The problem this solves: this closes the story chapter. The showroom turned, but it did not answer. Ama wanted to click a part and see what it costs. Lena's finger missed a thin tube. Ravi could reach nothing without a mouse. Now each of them has a way in: click, tap, or the list.

  2. 2

    What this phase adds. src/systems/picking.js exports createPicker: it finds the part under the pointer (Topic 8.1), tells a click from a drag (8.2), highlights the hovered part once per frame and follows the turning bike. src/ui/panel.js holds the part names and made-up prices in euros, and builds a list of real buttons and a live line that reads "Handlebar and stem · €60" (8.4). src/objects/bike.js gains the handlebar and stem: a stem from the top of the head tube, a bar 0.56 m wide across the bike and two grips, all in one group named "handlebar". index.html gets the #panel and a focusable #app.

  3. 3

    How it plugs in. The picker's root is the bike, so only the bike's children are parts and the stand is never chosen. The picker has a tick(), so loop.add(picker) re-checks the hover each frame while the camera moves under a resting mouse. main.js also carries the keyboard twins, working on Phase 7's orbit controls: arrows turn the view, + and - zoom, Escape clears.

  4. 4

    Two honest limits. The tubes are thin, so under a finger the list of buttons is the dependable way in on a phone. A larger invisible hit-box (Topic 8.1) would help and is left for later. The files below start from Phase 7's showroom (orbit controls, three views, the fork and both wheels).

  5. 5

    What the showroom can do now: answer a mouse, a finger and the Tab key; glow the part you point at; show a name and a price. Still missing: the crank, the turntable's return with reduced-motion and pause controls (Phase 9), loading the real bike model, which must keep these part names (Phase 10), colour choice (Phase 12 on) and the full accessibility pass (Phase 18).

Explain it without notes

01

Walk through what happens from a tap on the handlebar to the panel text changing, naming each function.

02

Why does the panel have buttons when the canvas can already be clicked?

Practice

01

Add a "bell" price of 15 euros to PARTS and say what else must be true for the panel to list it.

02

Make the hover highlight a different colour for the chosen part's neighbours: say where you would change the code, and why refresh is the right place.

Done when you can

  • Pick a part by mouse, by touch and by keyboard

  • Explain why the picker climbs to the part and why the root is the bike

  • Show a name and price in a live region

  • Add the handlebar and stem and find it in the scene tree