Course outline
Build: Placing the Frame Tubes
In one line
Write tubeBetween(from, to, radius, name), a function that puts a cylinder exactly between two points, and use it to place the Lumen bike's down tube and top tube. The showroom gets a real frame, and the numbers come from vectors, not from guessing.
Before you start
You will learn
- Write a function that puts a tube between two points
- Use vectors, quaternions and the scene graph together
- Add a new file to the project and import it
You write code in
In your lumen-3d project: add src/objects/bike.js, replace src/main.js (index.html does not change), then run npm run dev
Files you touch
- src/objects/bike.js
- src/main.js
Think of it like this
A builder joining two posts with a beam does not guess angles. They measure from one post to the other, cut the beam to that length, and fix it halfway between the posts, pointing from one to the other. tubeBetween does exactly that: measure, cut, centre, aim.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- tubeBetween
- The course's helper:
tubeBetween(from, to, radius, name)returns a cylinder mesh whose two ends are exactly the pointsfromandto. - setFromUnitVectors
quaternion.setFromUnitVectors(a, b)sets the quaternion to the shortest turn that takes the unit vectoraonto the unit vectorb.- ES module
- A JavaScript file that
exports functions andimports others.src/objects/bike.jsexportscreateBikeandmain.jsimports it. - support check
supportsWebGL2()from Phase 1: it asks the browser for a WebGL 2 context and shows the still image if there is none.
Step by step
01bike.js: the points and the material
POINTS holds the four frame points as Vector3 objects, in metres. The FRAME material is Lumen orange with a little metal in it. Both live at the top of the file so every tube shares them: one material means one shader and no repeated work.
02tubeBetween: measure, cut, centre, aim
along = to.clone().sub(from) is the arrow from one end to the other (clone, so to is not changed). along.length() is the height of the cylinder. The position is the average of the ends. quaternion.setFromUnitVectors(UP, along.normalize()) turns a y-up cylinder to point along the arrow.
Notice the order: the length is read before normalize() changes along. That is the in-place trap of Topic 2.2.
03createBike
A Group named "bike" gets two tubes. The down tube joins the bracket and the head tube's bottom (radius 0.022). The top tube joins the seat cluster and the head tube's top (radius 0.018). Later phases add more add(...) lines in this same function.
04main.js: import, add, turn together
import { createBike } from "./objects/bike.js"; brings the function in. The scene gets the stand and the bike, and the loop turns both by the same angle, so the bike seems to stand on the stand. supportsWebGL2() runs first, as in Phase 1.
Try it yourself
- 1
Reverse the ends
In the example, swap the two arguments of the down tube:
tubeBetween(POINTS.headBottom, POINTS.bottomBracket, 0.022, "down-tube"). The console prints the same two lines (centre 0.235, 0.500, 0.000, length 0.6172), because the middle and the length do not care which end is which. The tube now points the other way, which only matters later, when something is attached to one end. - 2
Fatten the top tube
Change the top tube's radius from 0.018 to 0.05. It becomes a visibly thick beam, centred at the same point. A bigger radius never changes the length or position.
Code & diagrams
This is src/objects/bike.js and src/main.js from the files below, joined into one example (local imports cannot run in the preview). The two console lines are the tubes' real centres and lengths. Open the scene tree: the bike group holds down-tube and top-tube.
Console
Run the scene to see its console.Expected console output
down-tube at 0.235, 0.500, 0.000, length 0.6172
top-tube at 0.170, 0.830, 0.000, length 0.5404Your 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";
// Points of the Lumen frame, in metres (side view in the x-y plane, z across the bike, +x is the front).
export const POINTS = {
bottomBracket: new THREE.Vector3(0, 0.3, 0),
seatCluster: new THREE.Vector3(-0.1, 0.82, 0),
headTop: new THREE.Vector3(0.44, 0.84, 0),
headBottom: new THREE.Vector3(0.47, 0.7, 0),
};
const FRAME = new THREE.MeshStandardMaterial({ color: "#ff6b2c", metalness: 0.4, roughness: 0.35 });
const UP = new THREE.Vector3(0, 1, 0); // a CylinderGeometry stands along y
/** A tube (a thin cylinder) whose two ends are exactly `from` and `to`. */
export function tubeBetween(from, to, radius, name) {
const along = to.clone().sub(from); // end minus start: the arrow from one end to the other
const length = along.length();
const tube = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 16), FRAME);
tube.name = name;
tube.position.copy(from).add(to).multiplyScalar(0.5); // halfway between the ends
tube.quaternion.setFromUnitVectors(UP, along.normalize()); // turn "up" onto the tube's direction
return tube;
}
/** The Lumen bike so far: the down tube and the top tube. Wheels at y = 0, facing +x. */
export function createBike() {
const bike = new THREE.Group();
bike.name = "bike";
bike.add(tubeBetween(POINTS.bottomBracket, POINTS.headBottom, 0.022, "down-tube"));
bike.add(tubeBetween(POINTS.seatCluster, POINTS.headTop, 0.018, "top-tube"));
return bike;
}
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
Pass arrays instead of vectors
In the example's createBike, change the down tube to tubeBetween([0, 0.3, 0], [0.47, 0.7, 0], 0.022, "down-tube").
Break #2
Both ends the same
Change the down tube to tubeBetween(POINTS.bottomBracket, POINTS.bottomBracket, 0.022, "down-tube").
Myth vs fact
Myth
The tube needs its own rotation angle, worked out by hand.
Fact
Never. Give the function the two end points and it works out the position, the length and the direction itself. Later tubes (seat tube, stays, fork) are one line each.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Each tube is its own mesh and draw call (Phase 17 counts them, and Phase 4 merges what never moves). With a shared material and 16 radial segments each tube is 64 triangles: 32 for the side (16 rectangles, 2 triangles each) plus 16 for each cap.
- ▸
setFromUnitVectorscopes with two vectors that point exactly opposite ways by picking an axis at right angles to them, so a tube pointing exactly down (0, −1, 0) is still valid. A zero vector is not a direction at all: it gives no turn and no error, which is why the guard above matters.
Remember this
- 1
The problem this solves: in Phase 1 the down tube was placed by hand, with a position and
rotation.z = -0.8657that you copied. A whole frame would need an angle worked out for every tube. WithtubeBetweenyou give the two end points from the bike drawing and the function works out the rest. The showroom stops being a stand and starts being a bike. - 2
The four steps. 1. Subtract the start from the end: the arrow
along(Topic 2.2). 2. Its length is the cylinder's height. 3. Put the cylinder halfway:(from + to) / 2. 4. ACylinderGeometrystands along y, so turn "up" onto the arrow's direction withquaternion.setFromUnitVectors(up, direction)(a quaternion, Topic 2.6). Every step is something this phase taught. - 3
The numbers. The bike is measured in metres, side view in the x-y plane, front at +x. The bottom bracket is (0, 0.3, 0), the bottom of the head tube is (0.47, 0.7, 0), the seat cluster is (−0.1, 0.82, 0) and the top of the head tube is (0.44, 0.84, 0). The down tube goes from the bracket to the bottom of the head tube: 0.6172 m long, centred at (0.235, 0.5, 0), exactly the numbers Phase 1 gave you. The top tube goes from the seat cluster to the top of the head tube: 0.5404 m long, centred at (0.17, 0.83, 0).
- 4
**
createBike()returns a group.** The function builds aTHREE.Groupnamed"bike"and adds the two tubes to it, named"down-tube"and"top-tube". Later phases add more parts to the same group, and exercises and tests find parts by these names.main.jsadds the bike to the scene and turns it together with the stand. - 5
What the showroom can do now that it could not before: show a real frame shape, with tubes that meet at the points of the bike drawing. What is still missing: the seat tube (Phase 3), the rear triangle (Phase 4), the fork, wheels and everything else. The part this phase adds is the top tube; the down tube is rebuilt by
tubeBetweenin the same place.index.html, the#fallbackmarkup and the support check stay exactly as Phase 1 left them:main.jsonly gains the import and the bike, and the stand keeps its small orange rim mark from Phase 0 so you can see it turn.
Explain it without notes
Walk through how tubeBetween turns two points into a positioned, aimed cylinder. Which Phase 2 idea does each step use?
What does createBike() add to the scene, and why is it a Group?
Practice
Use tubeBetween to add the seat tube from the bottom bracket (0, 0.3, 0) to the seat cluster (−0.1, 0.82, 0), radius 0.02, named "seat-tube". Predict its length.
Add a guard to tubeBetween that throws a clear error when the two points are closer than a micrometre.
Done when you can
Explain how a tube between two points gets its position, length and direction
Add a new tube to the bike with one line
Say what src/objects/bike.js exports and what main.js does with it
Say what the showroom can do now and which phase adds the next frame tube