Course outline
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.
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
Watch the error state
In the live example, change
MODEL_URLto"/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
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
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.
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, chainYour 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.
<!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.
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.jsdoes): its DRACOLoader keeps its workers alive between loads. A new loader per model would start new decoder workers each time.
Remember this
- 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
What it adds. A new
src/systems/loader.jswithloadModel(url, { onProgress, timeout })(one GLTFLoader with the Draco decoder, a deadline withPromise.race) andcreateStatus(element, onRetry)(the four states in#status).index.htmlgains the#statuselement, withrole="status"andaria-live="polite".bike.jsgainscreateChain(), and Phase 9's ride moves intoaddRide(bike), which finds the wheels and the crankset by name, so it works on the loaded model too.panel.jsgains the chain's price, and#panelstarts hidden.main.jsstarts the loop before the bike arrives, then loadslumen-bike-draco.glband 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
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) inpublic/data/3d/models/, and the Draco decoder files fromnode_modules/three/examples/jsm/libs/draco/gltf/inpublic/data/3d/draco/. - 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
Why does main.js create the controls only after the model has loaded?
What does the visitor see in each of the four states, and what does the developer see when the load fails?
Practice
Add a timeout test: set timeout: 1 in the loadModel() call and describe what the visitor sees.
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