Command Palette

Search for a command to run...

Hectal
Course outline
Topic 10.7Intermediate ~54 min · step 7 of 7

Build: Load the Bike Model

In one line

The showroom now loads the bike from a 34 KB Draco-compressed file, says what it is doing while it loads, offers Try again when it fails, and gives up on a load that hangs. The chain, this phase's part, is added in code to the loaded model.

0/7 · 0%

Before you start

You will learn

  • Load the bike from a compressed file
  • Show loading, error and retry states
  • Give up on a load that hangs

You write code in

In your lumen-3d project: add src/systems/loader.js, change index.html, bike.js, panel.js and main.js, and copy the model and the Draco decoder into public/data/3d/

Files you touch

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

Think of it like this

A shop that used to build its display bike from parts every morning now has it delivered assembled, in a box small enough to carry. The shop opens with the stand ready, puts up a sign while the delivery is on its way, and calls again if it doesn't arrive.

Words you'll meet

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

loader module
One file that owns how the app loads models, so every part of the app loads the same way, with the same decoders and errors.
progressive start
Showing what is ready (the stand, the lights) while slower parts (the model) are still loading.
deadline
The longest time you wait for something before treating it as failed.
fitted controls
Camera controls whose distances and views are computed from the bike's size (Topic 7.4), so they need the bike before they can be made.

Step by step

01Start without the bike

startShowroom() now makes the renderer, scene, camera, lights and stand, and starts the loop at once. The visitor sees the lit stand immediately instead of a black page.

02Load with a status

showBike() shows "Loading the bike…", calls loadModel() with a progress callback, and on success finds the bike node inside the model, adds the chain and the ride, adds the model to the scene and hides the status. Then it creates the controls, the picker, the panel and the keyboard twins, and shows the panel and the buttons.

03Fail politely

On failure it logs the error for you and shows the plain message with Try again. The button calls showBike() again, so a retry runs the same steps. The controls are only made after a successful load, because they are fitted to the bike's size.

04Run it

The live example joins every project file into one module (local imports cannot run here) and prints the loaded file and the parts it found by name: the same names as the code-built bike, plus the chain.

Try it yourself

  1. 1

    Watch the error state

    In the live example, change MODEL_URL to "/data/3d/models/lumen-bike-dracoo.glb". The stand appears, the status shows "The bike could not be loaded. Check your connection, then try again." with a Try again button, and the console shows the real 404. Change it back and press Run.

  2. 2

    Throttle your own network

    In your project, open DevTools, Network tab, and choose "Slow 4G" (or "3G") in the throttling menu. Reload: you now see "Loading the bike… 40%" and the other percentages, which a fast local server never shows.

Code & diagrams

The showroom after Phase 10 New tab

Every project file of this phase, joined into one (local imports cannot run in the preview). The status appears briefly while the 34 KB file loads.

idle

Console

Run the scene to see its console.

Expected console output

loaded: lumen-bike-draco.glb | parts: down-tube, top-tube, seat-tube, chain-stays, seat-stays, fork, front-wheel, rear-wheel, handlebar, crankset, chain

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], #pause[hidden] { display: none; }
      #views button, #pause { min-height: 44px; padding: 0 18px; border: 1px solid #3b4757; border-radius: 22px; background: #121b26; color: inherit; font: inherit; cursor: pointer; }
      #views button:hover, #pause:hover { border-color: #ff6b2c; }
      #views button:focus-visible, #pause:focus-visible { outline: 3px solid #2a9df4; outline-offset: 2px; }
      #pause { position: fixed; left: 16px; bottom: 16px; }
      @media (max-width: 760px) { #pause { bottom: 70px; } } /* on a phone the camera buttons fill the bottom row */
      #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; }
      #status { position: fixed; left: 50%; top: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 12px; max-width: calc(100% - 32px); padding: 10px 16px; border: 1px solid #3b4757; border-radius: 10px; background: #121b26; }
      #status[hidden] { display: none; }
      #status[data-state="error"] { border-color: #e5484d; }
      #status p { margin: 0; }
      #status button { min-height: 44px; padding: 0 16px; border: 1px solid #3b4757; border-radius: 22px; background: #1b2633; color: inherit; font: inherit; cursor: pointer; }
      #status button:focus-visible { outline: 3px solid #2a9df4; outline-offset: 2px; }
    </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" hidden></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>
    <button id="pause" type="button" hidden>Pause animation</button>
    <div id="status" role="status" aria-live="polite" hidden><p></p><button type="button" hidden>Try again</button></div>
    <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

Forget the decoder files

In your project, delete public/data/3d/draco/ and reload.

terminal
$ Reload and read the console
── what you'll see ──
Error: fetch for "http://localhost:5173/data/3d/draco/draco_wasm_wrapper.js" responded with 404: Not Found
On screen: the error message and Try again; pressing it fails the same way. (A dev server that answers unknown paths with index.html would give a SyntaxError instead, as in Topic 10.3.)

Myth vs fact

Myth

A model file always loads faster than building the model in code.

Fact

For a few simple shapes, code is smaller than any file. Files win when the model is detailed, made by designers, or shared with other tools, and compression keeps them small.

Pro corner

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

  • ▸

    Preload the decoder while the page starts: draco.preload() begins fetching the WebAssembly before the first model is requested, so the first load does not wait for it.

  • ▸

    For many models, keep one GLTFLoader (as loader.js does): its DRACOLoader keeps its workers alive between loads. A new loader per model would start new decoder workers each time.

Remember this

  1. 1

    The problem this solves: Lena's black screen. The stand and lights now appear at once; a message says the bike is loading (with a percentage when the size is known); a failed load says so in plain words with a Try again button; a load that hangs gives up after 15 seconds. And the bike itself is a 34 KB file instead of code.

  2. 2

    What it adds. A new src/systems/loader.js with loadModel(url, { onProgress, timeout }) (one GLTFLoader with the Draco decoder, a deadline with Promise.race) and createStatus(element, onRetry) (the four states in #status). index.html gains the #status element, with role="status" and aria-live="polite". bike.js gains createChain(), and Phase 9's ride moves into addRide(bike), which finds the wheels and the crankset by name, so it works on the loaded model too. panel.js gains the chain's price, and #panel starts hidden. main.js starts the loop before the bike arrives, then loads lumen-bike-draco.glb and only then creates what needs the bike: the controls (fitted to its size), the picker and panel (Phase 8, which pick its parts), the keyboard twins and the ride.

  3. 3

    The files to copy. Put lumen-bike-draco.glb (download it from /data/3d/models/ on this site, or make your own as in Topic 10.5) in public/data/3d/models/, and the Draco decoder files from node_modules/three/examples/jsm/libs/draco/gltf/ in public/data/3d/draco/.

  4. 4

    What the showroom can do now: load a compressed model in a fraction of a second on a good connection, show loading and error states to every visitor (and screen-reader users), recover with Try again, and give up on a hung load. Picking, the panel, the pause button and the pedalling crank all still work, because the file keeps every part's name. Still missing: the saddle and the React version (Phase 11), the paint shader (Phase 12), the lights (Phases 13 and 14).

Explain it without notes

01

Why does main.js create the controls only after the model has loaded?

02

What does the visitor see in each of the four states, and what does the developer see when the load fails?

Practice

01

Add a timeout test: set timeout: 1 in the loadModel() call and describe what the visitor sees.

02

Change loader.js so it can also load Meshopt-compressed files.

Done when you can

  • Your showroom loads lumen-bike-draco.glb and shows the chain

  • The status shows loading, then disappears; a wrong URL shows the error and Try again

  • The Draco decoder files are in public/data/3d/draco/

  • Your files match the five files of this topic