[object Object]

← back to Ventura Bus 3d

task #3: base Three.js scene — curved corridor, mountains, day/night

b343de6f7a6b2fd7eabb9f8924da14dd4040f05e · 2026-05-10 01:06:45 -0700 · Steve

scene.js now builds a CatmullRomCurve3-driven road ribbon (~12 mi stub bezier
through Woodland Hills → Tarzana → Encino → Sherman Oaks → Studio City) with
canvas asphalt + dashed lane stripes; flanking concrete sidewalks share the
same curve. Adds three layered low-poly Hollywood Hills ridges on the north
side, a textured ground plane, atmospheric Sky dome, hemisphere + ambient +
shadow-casting directional lights, and OrbitControls defaulted to (0, 100,
173) — exactly 100 m up at 30° pitch — looking at the road centreline.

setTimeOfDay(minutes) lerps between 10 anchors (deep-night → dawn → midday →
golden hour → dusk) and drives sun position, dirLight intensity/colour,
hemisphere tint, ambient, sky turbidity/rayleigh, and tone-mapping exposure.
setRouteWaypoints(points) swaps the stub bezier for the real corridor when
/tmp/ventura-bus-routes.json lands.

scene-demo.html upgraded with TOD slider + dawn/noon/dusk/night presets,
auto-cycle toggle, /api/route loader, and live FPS readout. Headless smoke
test confirms 8 draw calls, ~5k triangles, OrbitControls attached at the
spec'd camera pose, and TOD anchor interpolation (night 0.08 → noon 1.65 →
dusk 0.95 dirLight intensity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b343de6f7a6b2fd7eabb9f8924da14dd4040f05e
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun May 10 01:06:45 2026 -0700

    task #3: base Three.js scene — curved corridor, mountains, day/night
    
    scene.js now builds a CatmullRomCurve3-driven road ribbon (~12 mi stub bezier
    through Woodland Hills → Tarzana → Encino → Sherman Oaks → Studio City) with
    canvas asphalt + dashed lane stripes; flanking concrete sidewalks share the
    same curve. Adds three layered low-poly Hollywood Hills ridges on the north
    side, a textured ground plane, atmospheric Sky dome, hemisphere + ambient +
    shadow-casting directional lights, and OrbitControls defaulted to (0, 100,
    173) — exactly 100 m up at 30° pitch — looking at the road centreline.
    
    setTimeOfDay(minutes) lerps between 10 anchors (deep-night → dawn → midday →
    golden hour → dusk) and drives sun position, dirLight intensity/colour,
    hemisphere tint, ambient, sky turbidity/rayleigh, and tone-mapping exposure.
    setRouteWaypoints(points) swaps the stub bezier for the real corridor when
    /tmp/ventura-bus-routes.json lands.
    
    scene-demo.html upgraded with TOD slider + dawn/noon/dusk/night presets,
    auto-cycle toggle, /api/route loader, and live FPS readout. Headless smoke
    test confirms 8 draw calls, ~5k triangles, OrbitControls attached at the
    spec'd camera pose, and TOD anchor interpolation (night 0.08 → noon 1.65 →
    dusk 0.95 dirLight intensity).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/js/scene.js     | 603 ++++++++++++++++++++++++++++++++++++++-----------
 public/scene-demo.html | 145 +++++++++---
 2 files changed, 584 insertions(+), 164 deletions(-)

diff --git a/public/js/scene.js b/public/js/scene.js
index eb6ea94..d3cc2f2 100644
--- a/public/js/scene.js
+++ b/public/js/scene.js
@@ -1,38 +1,94 @@
-// ventura-bus-3d — Task #3: base scene (road + sky)
+// ventura-bus-3d — Task #3: base Three.js scene
 //
-// Exports createBaseScene({ container, ... }) which builds a reusable Three.js
-// scaffold the bus, stops, and UI tasks can hang their content off of.
+// Builds the foundational corridor: curved Ventura Blvd road geometry,
+// asphalt + sidewalk textures, low-poly Hollywood Hills backdrop on the
+// north side, atmospheric sky dome, day/night-cycle directional lighting,
+// and OrbitControls camera defaulted to a 100m / 30°-pitch overhead shot.
 //
-// Returned object:
-//   { scene, camera, renderer, road, roadway, sidewalkN, sidewalkS,
-//     sky, sun, dirLight, hemiLight, ambientLight,
-//     start, stop, render, onResize, dispose, setSun }
+// The road follows a CatmullRomCurve3. Until /tmp/ventura-bus-routes.json
+// lands, a stub set of waypoints is used (Sherman Oaks → Encino → Tarzana →
+// Woodland Hills, ~12 mi). Swap them later via setRouteWaypoints(points).
 //
 // Coordinate convention:
 //   +X = east  (Studio City direction)
 //   -X = west  (Woodland Hills direction)
 //   +Y = up
 //   +Z = south
-//   -Z = north
-//   The road runs along the X axis, centered on origin.
+//   -Z = north  (Hollywood Hills live here)
+//
+// Public API (return object):
+//   scene, camera, renderer, controls
+//   road            (THREE.Group: roadway + sidewalks)
+//   roadway, sidewalkN, sidewalkS
+//   mountains       (THREE.Group: 3 ridge layers)
+//   sky, sun        (sky mesh + sun direction Vector3)
+//   dirLight, hemiLight, ambientLight
+//   routeCurve      (CatmullRomCurve3)
+//   constants       ({ ROAD_LENGTH, ROADWAY_WIDTH, SIDEWALK_WIDTH, LANE_COUNT })
+//   setSun(elDeg, azDeg)
+//   setTimeOfDay(minutes)         // 0..1440, drives sun + lights + sky uniforms
+//   setRouteWaypoints(points)     // replace stub bezier with real route
+//   onTick(fn) / start() / stop() / render() / onResize() / dispose()
 
 import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 import { Sky } from 'three/addons/objects/Sky.js';
 
-const ROAD_LENGTH   = 8000;   // metres along +/- X (long enough to disappear in fog)
-const ROADWAY_WIDTH = 24;     // four lanes + median, in metres
-const SIDEWALK_WIDTH = 6;     // each side
-const LANE_COUNT    = 4;      // for stripe rendering only
-
+// ---- Corridor constants -----------------------------------------------------
+const MILE = 1609.344;
+const CORRIDOR_MILES = 12;
+const CORRIDOR_LENGTH = CORRIDOR_MILES * MILE;     // ≈ 19,312 m
+const ROAD_LENGTH    = CORRIDOR_LENGTH;            // alias used externally
+const ROADWAY_WIDTH  = 24;                         // 4 lanes + median, metres
+const SIDEWALK_WIDTH = 6;
+const LANE_COUNT     = 4;
+const ROAD_SEGMENTS  = 600;                        // curve sample count
+const TILE_LEN_M     = 24;                         // one asphalt-texture tile = 24 m
+
+// Stub bezier waypoints used until the real route file is loaded. Spaced ~2.4 mi
+// apart, slight north/south undulation so curvature is visible at zoom.
+const STUB_WAYPOINTS = (() => {
+  const half = CORRIDOR_LENGTH / 2;
+  return [
+    new THREE.Vector3(-half,             0,  120),  // Woodland Hills
+    new THREE.Vector3(-half * 0.55,      0,  -40),
+    new THREE.Vector3(-half * 0.18,      0, -110),  // Tarzana
+    new THREE.Vector3( half * 0.18,      0,  -30),  // Encino
+    new THREE.Vector3( half * 0.55,      0,   60),  // Sherman Oaks
+    new THREE.Vector3( half,             0,  140),  // Studio City
+  ];
+})();
+
+// ---- Day-night anchors ------------------------------------------------------
+// Each anchor is (minutes since midnight) → sky/sun/light parameters. The
+// renderer lerps between the two surrounding anchors on every setTimeOfDay()
+// call. Sky uniforms (turbidity / rayleigh) shift slightly so dawn / dusk
+// look warmer than midday. dirLight intensity drops at night so headlights /
+// streetlamps later in the project actually read.
+const TOD_ANCHORS = [
+  { min:    0, name: 'Deep night',  el:-25, az: 280, sunColor: 0x223052, sunInt: 0.05, hemiSky: 0x1d2742, hemiGround: 0x05060a, hemiInt: 0.18, amb: 0.04, turb: 12, ray: 0.6, expo: 0.35 },
+  { min:  300, name: 'Pre-dawn',    el: -6, az:  85, sunColor: 0x6b5a8c, sunInt: 0.20, hemiSky: 0x3a3f5c, hemiGround: 0x0a0b12, hemiInt: 0.32, amb: 0.07, turb: 10, ray: 1.4, expo: 0.55 },
+  { min:  390, name: 'Dawn',        el:  4, az:  88, sunColor: 0xffb070, sunInt: 0.85, hemiSky: 0xffc59c, hemiGround: 0x2a2018, hemiInt: 0.48, amb: 0.10, turb:  8, ray: 2.4, expo: 0.85 },
+  { min:  540, name: 'Morning',     el: 28, az: 105, sunColor: 0xfff1d1, sunInt: 1.40, hemiSky: 0xc9deff, hemiGround: 0x2a2a30, hemiInt: 0.60, amb: 0.14, turb:  6, ray: 1.8, expo: 0.95 },
+  { min:  720, name: 'Midday',      el: 70, az: 180, sunColor: 0xffffff, sunInt: 1.65, hemiSky: 0xa6c8ff, hemiGround: 0x1a1a1a, hemiInt: 0.70, amb: 0.18, turb:  5, ray: 1.4, expo: 1.00 },
+  { min:  990, name: 'Afternoon',   el: 35, az: 240, sunColor: 0xffe2b3, sunInt: 1.30, hemiSky: 0xb6cfff, hemiGround: 0x252225, hemiInt: 0.58, amb: 0.13, turb:  6, ray: 1.7, expo: 0.92 },
+  { min: 1140, name: 'Golden hour', el:  6, az: 270, sunColor: 0xff9a55, sunInt: 0.95, hemiSky: 0xffb27a, hemiGround: 0x2c1a14, hemiInt: 0.46, amb: 0.10, turb:  9, ray: 2.8, expo: 0.85 },
+  { min: 1230, name: 'Dusk',        el: -3, az: 275, sunColor: 0x6e5b8c, sunInt: 0.30, hemiSky: 0x6e5e8c, hemiGround: 0x0e0c18, hemiInt: 0.34, amb: 0.07, turb: 11, ray: 1.6, expo: 0.55 },
+  { min: 1320, name: 'Night',       el:-15, az: 280, sunColor: 0x2a3a64, sunInt: 0.10, hemiSky: 0x2a3458, hemiGround: 0x05060a, hemiInt: 0.22, amb: 0.05, turb: 12, ray: 0.8, expo: 0.40 },
+  { min: 1440, name: 'Deep night',  el:-25, az: 280, sunColor: 0x223052, sunInt: 0.05, hemiSky: 0x1d2742, hemiGround: 0x05060a, hemiInt: 0.18, amb: 0.04, turb: 12, ray: 0.6, expo: 0.35 },
+];
+
+// ---- Public factory ---------------------------------------------------------
 export function createBaseScene({
   container,
-  useFog = true,
-  fogColor = 0xc8d4e0,
-  fogNear = 60,
-  fogFar  = 1800,
-  sunElevation = 22,           // degrees above horizon
-  sunAzimuth   = 205,          // degrees, 180 = south, 270 = west
+  useFog        = true,
+  fogColor      = 0xb6c2d4,
+  fogNear       = 200,
+  fogFar        = 6000,
   pixelRatioCap = 2,
+  initialMinutes = 720,            // Midday
+  cameraHeight   = 100,            // metres above road
+  cameraPitchDeg = 30,              // degrees from horizontal
 } = {}) {
   if (!container) throw new Error('createBaseScene: container element is required');
 
@@ -42,7 +98,7 @@ export function createBaseScene({
   renderer.setSize(container.clientWidth || window.innerWidth, container.clientHeight || window.innerHeight);
   renderer.outputColorSpace = THREE.SRGBColorSpace;
   renderer.toneMapping = THREE.ACESFilmicToneMapping;
-  renderer.toneMappingExposure = 0.85;
+  renderer.toneMappingExposure = 1.0;
   renderer.shadowMap.enabled = true;
   renderer.shadowMap.type = THREE.PCFSoftShadowMap;
   container.appendChild(renderer.domElement);
@@ -51,43 +107,55 @@ export function createBaseScene({
   const scene = new THREE.Scene();
   if (useFog) scene.fog = new THREE.Fog(fogColor, fogNear, fogFar);
 
-  // ---- Camera (default driver-eye-ish; controls task can move it) --------
-  const camera = new THREE.PerspectiveCamera(
-    55,
-    (container.clientWidth || window.innerWidth) / (container.clientHeight || window.innerHeight),
-    0.1,
-    5000
-  );
-  camera.position.set(-30, 6, 14);
-  camera.lookAt(40, 2, 0);
+  // ---- Camera + OrbitControls --------------------------------------------
+  const w0 = container.clientWidth || window.innerWidth;
+  const h0 = container.clientHeight || window.innerHeight;
+  const camera = new THREE.PerspectiveCamera(50, w0 / h0, 1, 30000);
+
+  // 30° pitch from horizontal at 100 m up → horizontal stand-off = h / tan(pitch).
+  const pitchRad = THREE.MathUtils.degToRad(cameraPitchDeg);
+  const horiz = cameraHeight / Math.tan(pitchRad);
+  camera.position.set(0, cameraHeight, horiz);
+
+  const controls = new OrbitControls(camera, renderer.domElement);
+  controls.target.set(0, 0, 0);
+  controls.enableDamping = true;
+  controls.dampingFactor = 0.08;
+  controls.minDistance = 30;
+  controls.maxDistance = 8000;
+  controls.maxPolarAngle = Math.PI * 0.49;   // never below ground
+  controls.minPolarAngle = 0.05;
+  controls.zoomSpeed = 1.1;
+  controls.panSpeed  = 0.9;
+  controls.rotateSpeed = 0.7;
+  controls.update();
 
   // ---- Sky (Three.js atmospheric shader) ---------------------------------
   const sky = new Sky();
-  sky.scale.setScalar(10000);
+  sky.scale.setScalar(20000);
   scene.add(sky);
-
-  const skyUniforms = sky.material.uniforms;
-  skyUniforms['turbidity'].value       = 6;
-  skyUniforms['rayleigh'].value        = 1.6;
-  skyUniforms['mieCoefficient'].value  = 0.005;
-  skyUniforms['mieDirectionalG'].value = 0.85;
+  const skyU = sky.material.uniforms;
+  skyU['turbidity'].value       = 5;
+  skyU['rayleigh'].value        = 1.4;
+  skyU['mieCoefficient'].value  = 0.005;
+  skyU['mieDirectionalG'].value = 0.85;
 
   const sun = new THREE.Vector3();
 
   // ---- Lights -------------------------------------------------------------
-  const hemiLight = new THREE.HemisphereLight(0xddeaff, 0x55452a, 0.55);
-  hemiLight.position.set(0, 50, 0);
+  const hemiLight = new THREE.HemisphereLight(0xddeaff, 0x55452a, 0.7);
+  hemiLight.position.set(0, 80, 0);
   scene.add(hemiLight);
 
-  const ambientLight = new THREE.AmbientLight(0xffffff, 0.15);
+  const ambientLight = new THREE.AmbientLight(0xffffff, 0.18);
   scene.add(ambientLight);
 
   const dirLight = new THREE.DirectionalLight(0xfff4d6, 1.6);
   dirLight.castShadow = true;
   dirLight.shadow.mapSize.set(2048, 2048);
   dirLight.shadow.camera.near = 1;
-  dirLight.shadow.camera.far  = 600;
-  const SHADOW_HALF = 120;
+  dirLight.shadow.camera.far  = 800;
+  const SHADOW_HALF = 250;
   dirLight.shadow.camera.left   = -SHADOW_HALF;
   dirLight.shadow.camera.right  =  SHADOW_HALF;
   dirLight.shadow.camera.top    =  SHADOW_HALF;
@@ -96,56 +164,132 @@ export function createBaseScene({
   scene.add(dirLight);
   scene.add(dirLight.target);
 
-  // ---- Road (asphalt + dashed centre + edge lines via canvas texture) ----
-  const roadway = new THREE.Mesh(
-    new THREE.PlaneGeometry(ROAD_LENGTH, ROADWAY_WIDTH, 1, 1),
-    new THREE.MeshStandardMaterial({
-      map: makeRoadTexture(),
-      roughness: 0.95,
-      metalness: 0.0,
-      color: 0xffffff,           // tint applied via texture
-    })
+  // ---- Ground plane (under-corridor terrain) -----------------------------
+  // Wide low-saturation plane; texture is procedural to avoid an asset round-trip.
+  const groundTex = makeGroundTexture();
+  groundTex.wrapS = groundTex.wrapT = THREE.RepeatWrapping;
+  groundTex.repeat.set(80, 24);
+  groundTex.anisotropy = 8;
+  const ground = new THREE.Mesh(
+    new THREE.PlaneGeometry(CORRIDOR_LENGTH * 1.4, 6000, 1, 1),
+    new THREE.MeshStandardMaterial({ map: groundTex, roughness: 1.0, metalness: 0.0, color: 0xffffff })
   );
-  roadway.rotation.x = -Math.PI / 2;
-  roadway.position.y = 0;
-  roadway.receiveShadow = true;
-  scene.add(roadway);
-
-  // ---- Sidewalks (slightly raised concrete strips) -----------------------
-  const sidewalkMat = new THREE.MeshStandardMaterial({ color: 0xb8b3a8, roughness: 1.0 });
-  const sidewalkGeom = new THREE.BoxGeometry(ROAD_LENGTH, 0.15, SIDEWALK_WIDTH);
-
-  const sidewalkN = new THREE.Mesh(sidewalkGeom, sidewalkMat);
-  sidewalkN.position.set(0, 0.075, -(ROADWAY_WIDTH / 2 + SIDEWALK_WIDTH / 2));
-  sidewalkN.receiveShadow = true;
-  scene.add(sidewalkN);
-
-  const sidewalkS = new THREE.Mesh(sidewalkGeom, sidewalkMat);
-  sidewalkS.position.set(0, 0.075,  (ROADWAY_WIDTH / 2 + SIDEWALK_WIDTH / 2));
-  sidewalkS.receiveShadow = true;
-  scene.add(sidewalkS);
-
-  // Group exposed as `road` so callers can move/scale the whole strip.
-  const road = new THREE.Group();
-  road.name = 'ventura-road';
-  road.add(roadway, sidewalkN, sidewalkS);
-  // (Already added to scene individually above; keep group as a logical handle.)
-
-  // ---- Sun position helper (drives Sky uniforms + dirLight direction) ----
+  ground.rotation.x = -Math.PI / 2;
+  ground.position.y = -0.02;
+  ground.receiveShadow = true;
+  scene.add(ground);
+
+  // ---- Road (curved ribbon along CatmullRomCurve3) -----------------------
+  let routeCurve = new THREE.CatmullRomCurve3(STUB_WAYPOINTS, false, 'catmullrom', 0.5);
+  const roadGroup = new THREE.Group();
+  roadGroup.name = 'ventura-road';
+  scene.add(roadGroup);
+
+  const roadwayMat = new THREE.MeshStandardMaterial({
+    map: makeRoadTexture(), roughness: 0.95, metalness: 0.0, color: 0xffffff,
+  });
+  const sidewalkMat = new THREE.MeshStandardMaterial({
+    map: makeSidewalkTexture(), roughness: 1.0, metalness: 0.0, color: 0xffffff,
+  });
+
+  let roadway   = null;
+  let sidewalkN = null;
+  let sidewalkS = null;
+
+  function buildRoadGeometry() {
+    // Tear down prior meshes.
+    for (const m of [roadway, sidewalkN, sidewalkS]) {
+      if (!m) continue;
+      roadGroup.remove(m);
+      m.geometry?.dispose?.();
+    }
+
+    const segs = ROAD_SEGMENTS;
+    const points   = new Array(segs + 1);
+    const tangents = new Array(segs + 1);
+    let cumDist = 0;
+    const cum = new Array(segs + 1);
+    for (let i = 0; i <= segs; i++) {
+      const t = i / segs;
+      points[i] = routeCurve.getPointAt(t);
+      tangents[i] = routeCurve.getTangentAt(t);
+      tangents[i].y = 0;
+      tangents[i].normalize();
+      if (i === 0) cum[i] = 0;
+      else { cumDist += points[i].distanceTo(points[i - 1]); cum[i] = cumDist; }
+    }
+
+    roadway   = buildRibbon(points, tangents, cum,  ROADWAY_WIDTH / 2, 0.00, roadwayMat,  TILE_LEN_M);
+    sidewalkN = buildRibbon(points, tangents, cum, -(ROADWAY_WIDTH / 2 + SIDEWALK_WIDTH / 2), 0.15, sidewalkMat, 6, SIDEWALK_WIDTH);
+    sidewalkS = buildRibbon(points, tangents, cum,  (ROADWAY_WIDTH / 2 + SIDEWALK_WIDTH / 2), 0.15, sidewalkMat, 6, SIDEWALK_WIDTH);
+
+    roadway.receiveShadow   = true;
+    sidewalkN.receiveShadow = true;
+    sidewalkS.receiveShadow = true;
+    roadGroup.add(roadway, sidewalkN, sidewalkS);
+  }
+  buildRoadGeometry();
+
+  // ---- Mountains (low-poly Hollywood Hills, north side) ------------------
+  const mountains = makeMountains();
+  scene.add(mountains);
+
+  // ---- Time-of-day driver ------------------------------------------------
+  let _todMinutes = initialMinutes;
   function setSun(elevationDeg, azimuthDeg) {
     const phi   = THREE.MathUtils.degToRad(90 - elevationDeg);
     const theta = THREE.MathUtils.degToRad(azimuthDeg);
     sun.setFromSphericalCoords(1, phi, theta);
-    skyUniforms['sunPosition'].value.copy(sun);
-
-    const dist = 200;
+    skyU['sunPosition'].value.copy(sun);
+    const dist = 1500;
     dirLight.position.copy(sun).multiplyScalar(dist);
     dirLight.target.position.set(0, 0, 0);
     dirLight.target.updateMatrixWorld();
+    // Sun below horizon → kill direct shadows so nothing pops black.
+    dirLight.visible = elevationDeg > -2;
   }
-  setSun(sunElevation, sunAzimuth);
 
-  // ---- Resize -------------------------------------------------------------
+  const _ca = new THREE.Color();
+  const _cb = new THREE.Color();
+  const _hexLerp = (a, b, t) => _ca.setHex(a).lerp(_cb.setHex(b), t).getHex();
+  function setTimeOfDay(minutes) {
+    _todMinutes = ((minutes % 1440) + 1440) % 1440;
+    let lo = TOD_ANCHORS[0], hi = TOD_ANCHORS[TOD_ANCHORS.length - 1];
+    for (let i = 0; i < TOD_ANCHORS.length - 1; i++) {
+      if (_todMinutes >= TOD_ANCHORS[i].min && _todMinutes <= TOD_ANCHORS[i + 1].min) {
+        lo = TOD_ANCHORS[i]; hi = TOD_ANCHORS[i + 1]; break;
+      }
+    }
+    const span = (hi.min - lo.min) || 1;
+    const t = (_todMinutes - lo.min) / span;
+    const lerp = (a, b) => a + (b - a) * t;
+
+    const el  = lerp(lo.el, hi.el);
+    const az  = lerp(lo.az, hi.az);
+    setSun(el, az);
+
+    dirLight.color.setHex(_hexLerp(lo.sunColor, hi.sunColor, t));
+    dirLight.intensity = lerp(lo.sunInt, hi.sunInt);
+    hemiLight.color.setHex(_hexLerp(lo.hemiSky, hi.hemiSky, t));
+    hemiLight.groundColor.setHex(_hexLerp(lo.hemiGround, hi.hemiGround, t));
+    hemiLight.intensity = lerp(lo.hemiInt, hi.hemiInt);
+    ambientLight.intensity = lerp(lo.amb, hi.amb);
+
+    skyU['turbidity'].value = lerp(lo.turb, hi.turb);
+    skyU['rayleigh'].value  = lerp(lo.ray,  hi.ray);
+    renderer.toneMappingExposure = lerp(lo.expo, hi.expo);
+
+    // Fog tracks sky horizon colour so the corridor blends into the haze.
+    if (scene.fog) {
+      const horizon = sky.material.uniforms.sunPosition.value;
+      // approximate horizon tint from sun colour at low elevations
+      scene.fog.color.setHex(_hexLerp(lo.hemiSky, hi.hemiSky, t));
+      void horizon;
+    }
+  }
+  setTimeOfDay(initialMinutes);
+
+  // ---- Resize handling ---------------------------------------------------
   function onResize() {
     const w = container.clientWidth  || window.innerWidth;
     const h = container.clientHeight || window.innerHeight;
@@ -155,38 +299,33 @@ export function createBaseScene({
   }
   window.addEventListener('resize', onResize);
 
-  // ---- Render loop --------------------------------------------------------
+  // ---- Render loop -------------------------------------------------------
   let rafId = 0;
   let running = false;
   const tickListeners = new Set();
 
-  function render() {
-    renderer.render(scene, camera);
-  }
-
+  function render() { renderer.render(scene, camera); }
   function loop(t) {
     rafId = requestAnimationFrame(loop);
+    controls.update();
     for (const fn of tickListeners) fn(t);
-    render();
-  }
-
-  function start() {
-    if (running) return;
-    running = true;
-    rafId = requestAnimationFrame(loop);
+    renderer.render(scene, camera);
   }
+  function start() { if (running) return; running = true; rafId = requestAnimationFrame(loop); }
+  function stop()  { running = false; if (rafId) cancelAnimationFrame(rafId); rafId = 0; }
+  function onTick(fn) { tickListeners.add(fn); return () => tickListeners.delete(fn); }
 
-  function stop() {
-    running = false;
-    if (rafId) cancelAnimationFrame(rafId);
-    rafId = 0;
+  function setRouteWaypoints(points) {
+    if (!Array.isArray(points) || points.length < 2) return;
+    const v3 = points.map(p => p.isVector3 ? p : new THREE.Vector3(p.x ?? p[0], p.y ?? p[1] ?? 0, p.z ?? p[2]));
+    routeCurve = new THREE.CatmullRomCurve3(v3, false, 'catmullrom', 0.5);
+    buildRoadGeometry();
   }
 
-  function onTick(fn) { tickListeners.add(fn); return () => tickListeners.delete(fn); }
-
   function dispose() {
     stop();
     window.removeEventListener('resize', onResize);
+    controls.dispose?.();
     renderer.dispose();
     if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
     scene.traverse(obj => {
@@ -194,10 +333,7 @@ export function createBaseScene({
       if (obj.material) {
         const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
         mats.forEach(m => {
-          for (const key of Object.keys(m)) {
-            const v = m[key];
-            if (v && v.isTexture) v.dispose();
-          }
+          for (const k of Object.keys(m)) { const v = m[k]; if (v && v.isTexture) v.dispose(); }
           m.dispose?.();
         });
       }
@@ -205,27 +341,170 @@ export function createBaseScene({
   }
 
   return {
-    scene, camera, renderer,
-    road, roadway, sidewalkN, sidewalkS,
-    sky, sun, dirLight, hemiLight, ambientLight,
-    setSun, start, stop, render, onResize, onTick, dispose,
-    constants: { ROAD_LENGTH, ROADWAY_WIDTH, SIDEWALK_WIDTH, LANE_COUNT },
+    scene, camera, renderer, controls,
+    road: roadGroup, get roadway() { return roadway; },
+    get sidewalkN() { return sidewalkN; }, get sidewalkS() { return sidewalkS; },
+    mountains, sky, sun, dirLight, hemiLight, ambientLight,
+    get routeCurve() { return routeCurve; },
+    setSun, setTimeOfDay, setRouteWaypoints,
+    start, stop, render, onResize, onTick, dispose,
+    constants: { ROAD_LENGTH, ROADWAY_WIDTH, SIDEWALK_WIDTH, LANE_COUNT, CORRIDOR_MILES },
   };
 }
 
-// -------------------------------------------------------------------------
-// Procedural road texture: asphalt base + dashed yellow centre line + solid
-// white edge lines + faint lane dividers. Tiled along the X (length) axis.
-// -------------------------------------------------------------------------
-function makeRoadTexture() {
-  const TILE_LEN = 1024;   // pixels along road length (= one repeat unit)
-  const TILE_W   = 256;    // pixels across road width
+// ---------------------------------------------------------------------------
+// Helpers — geometry builders and procedural textures
+// ---------------------------------------------------------------------------
+
+// Build a flat ribbon mesh that hugs the curve. `halfWidth` is the +/-
+// offset (in metres) from the curve centreline; pass a positive value with
+// a non-zero `offsetCenterY` for raised sidewalks. `texLenMetres` controls
+// how far one V-tile spans along the road length.
+function buildRibbon(points, tangents, cum, halfWidthOrCenter, y, mat, texLenMetres, fixedWidth) {
+  const segs = points.length - 1;
+  const positions = new Float32Array((segs + 1) * 2 * 3);
+  const uvs       = new Float32Array((segs + 1) * 2 * 2);
+  const indices   = new Uint32Array(segs * 6);
+
+  // If fixedWidth is provided, halfWidthOrCenter is the lateral centre offset
+  // and the ribbon spans (centre - W/2) to (centre + W/2).
+  const isOffset = typeof fixedWidth === 'number';
+  const halfW    = isOffset ? fixedWidth / 2 : Math.abs(halfWidthOrCenter);
+  const centre   = isOffset ? halfWidthOrCenter : 0;
+
+  const _n = new THREE.Vector3();
+  for (let i = 0; i <= segs; i++) {
+    const p = points[i];
+    const t = tangents[i];
+    // perpendicular in XZ plane (right-hand rule with up = +Y)
+    _n.set(-t.z, 0, t.x);          // unit normal pointing +Z when t = +X
+    const cx = p.x + _n.x * centre;
+    const cz = p.z + _n.z * centre;
+
+    const lx = cx + _n.x * halfW;
+    const lz = cz + _n.z * halfW;
+    const rx = cx - _n.x * halfW;
+    const rz = cz - _n.z * halfW;
+
+    const o = i * 6;
+    positions[o + 0] = lx; positions[o + 1] = y; positions[o + 2] = lz;
+    positions[o + 3] = rx; positions[o + 4] = y; positions[o + 5] = rz;
+
+    const u = cum[i] / texLenMetres;
+    const o2 = i * 4;
+    uvs[o2 + 0] = u;  uvs[o2 + 1] = 0;
+    uvs[o2 + 2] = u;  uvs[o2 + 3] = 1;
+  }
 
+  for (let i = 0; i < segs; i++) {
+    const a = i * 2, b = a + 1, c = a + 2, d = a + 3;
+    const o = i * 6;
+    indices[o + 0] = a; indices[o + 1] = c; indices[o + 2] = b;
+    indices[o + 3] = b; indices[o + 4] = c; indices[o + 5] = d;
+  }
+
+  const geom = new THREE.BufferGeometry();
+  geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+  geom.setAttribute('uv',       new THREE.BufferAttribute(uvs, 2));
+  geom.setIndex(new THREE.BufferAttribute(indices, 1));
+  geom.computeVertexNormals();
+  geom.computeBoundingSphere();
+
+  return new THREE.Mesh(geom, mat);
+}
+
+// Three layered low-poly ridges on the north side. Front layer ~1 km out,
+// back layer ~3.5 km — gives parallax depth without spending tris.
+function makeMountains() {
+  const group = new THREE.Group();
+  group.name = 'hollywood-hills';
+
+  const layers = [
+    { z: -1100, baseHeight: 220, span: CORRIDOR_LENGTH * 1.6, peaks: 36, color: 0x8a96a6, jitter: 70  },
+    { z: -2200, baseHeight: 360, span: CORRIDOR_LENGTH * 1.8, peaks: 28, color: 0x6e7d92, jitter: 110 },
+    { z: -3600, baseHeight: 520, span: CORRIDOR_LENGTH * 2.0, peaks: 22, color: 0x55657c, jitter: 160 },
+  ];
+
+  for (let li = 0; li < layers.length; li++) {
+    const L = layers[li];
+    const rand = mulberry32(0xC0FFEE + li * 9973);
+
+    // 2D ridge silhouette in the XY plane at z = L.z. We give it a tiny
+    // forward/back extrusion so it isn't a flat decal but still cheap.
+    const peakCount = L.peaks;
+    const segCount  = peakCount * 4;       // sub-divide for smoother silhouette
+    const verts = [];
+    const idx   = [];
+    const half  = L.span / 2;
+
+    // Pre-generate peak heights and lateral offsets.
+    const peakYs = new Array(peakCount + 1);
+    for (let p = 0; p <= peakCount; p++) {
+      const noise = (rand() - 0.5) * 2;
+      const ridge = Math.pow(Math.abs(Math.sin(p * 0.7 + li * 1.3)), 0.6);
+      peakYs[p] = L.baseHeight * (0.55 + ridge * 0.6) + noise * L.jitter;
+      if (peakYs[p] < 60) peakYs[p] = 60;
+    }
+    function heightAt(t) {
+      const f = t * peakCount;
+      const i = Math.floor(f);
+      const u = f - i;
+      const a = peakYs[Math.max(0, Math.min(peakCount, i))];
+      const b = peakYs[Math.max(0, Math.min(peakCount, i + 1))];
+      // smoothstep
+      const s = u * u * (3 - 2 * u);
+      return a + (b - a) * s;
+    }
+
+    for (let i = 0; i <= segCount; i++) {
+      const t = i / segCount;
+      const x = -half + t * L.span;
+      const y = heightAt(t);
+      const zFront = L.z + 60;   // base toward camera
+      const zBack  = L.z - 200;  // base away from camera, sunk slightly
+      // 4 verts per slice: base-front, peak, base-back, "ground" stitch
+      verts.push(x, 0, zFront);    // 0
+      verts.push(x, y, L.z);       // 1 (peak point)
+      verts.push(x, 0, zBack);     // 2
+    }
+
+    for (let i = 0; i < segCount; i++) {
+      const a0 = i * 3, p0 = i * 3 + 1, b0 = i * 3 + 2;
+      const a1 = (i + 1) * 3, p1 = (i + 1) * 3 + 1, b1 = (i + 1) * 3 + 2;
+      // front face (south slope): a0, p0 → a1, p1
+      idx.push(a0, p0, p1);
+      idx.push(a0, p1, a1);
+      // back face (north slope): p0, b0 → p1, b1
+      idx.push(p0, b0, b1);
+      idx.push(p0, b1, p1);
+    }
+
+    const geom = new THREE.BufferGeometry();
+    geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
+    geom.setIndex(idx);
+    geom.computeVertexNormals();
+
+    const mat = new THREE.MeshStandardMaterial({
+      color: L.color, roughness: 1.0, metalness: 0.0, flatShading: true, side: THREE.DoubleSide,
+    });
+    const mesh = new THREE.Mesh(geom, mat);
+    mesh.receiveShadow = false;
+    mesh.castShadow    = false;
+    mesh.frustumCulled = true;
+    mesh.renderOrder   = -1;       // draw before fog-shrouded foreground
+    group.add(mesh);
+  }
+  return group;
+}
+
+// Procedural asphalt + lane stripes. One tile = TILE_LEN_M metres of road.
+function makeRoadTexture() {
+  const TILE_LEN = 1024;
+  const TILE_W   = 256;
   const c = document.createElement('canvas');
   c.width = TILE_LEN; c.height = TILE_W;
   const ctx = c.getContext('2d');
 
-  // Asphalt base with subtle noise
   ctx.fillStyle = '#3a3a3d';
   ctx.fillRect(0, 0, TILE_LEN, TILE_W);
   const noise = ctx.createImageData(TILE_LEN, TILE_W);
@@ -234,21 +513,15 @@ function makeRoadTexture() {
     noise.data[i] = v; noise.data[i + 1] = v; noise.data[i + 2] = v + 2; noise.data[i + 3] = 35;
   }
   ctx.putImageData(noise, 0, 0);
-  ctx.globalCompositeOperation = 'source-over';
 
-  // Edge lines (white, solid)
   ctx.fillStyle = '#f0efe6';
   ctx.fillRect(0, 6, TILE_LEN, 3);
   ctx.fillRect(0, TILE_W - 9, TILE_LEN, 3);
 
-  // Lane dividers (white, dashed) — splitting each direction in two
   ctx.fillStyle = '#dcd8c8';
-  const laneY1 = Math.round(TILE_W * 0.30);
-  const laneY2 = Math.round(TILE_W * 0.70);
-  drawDashed(ctx, laneY1, 2, TILE_LEN, 60, 100);
-  drawDashed(ctx, laneY2, 2, TILE_LEN, 60, 100);
+  drawDashed(ctx, Math.round(TILE_W * 0.30), 2, TILE_LEN, 60, 100);
+  drawDashed(ctx, Math.round(TILE_W * 0.70), 2, TILE_LEN, 60, 100);
 
-  // Centre line (yellow, double solid)
   ctx.fillStyle = '#e9c531';
   const cy = TILE_W / 2;
   ctx.fillRect(0, cy - 4, TILE_LEN, 3);
@@ -259,15 +532,81 @@ function makeRoadTexture() {
   tex.wrapS = THREE.RepeatWrapping;
   tex.wrapT = THREE.ClampToEdgeWrapping;
   tex.anisotropy = 8;
-  // 1 metre across width = TILE_W/ROADWAY_WIDTH px; tile spans 1024 px ≈ 96 m of road.
-  // Repeat along length so 1024 px covers ~24 m (roughly one stripe cycle of real asphalt).
-  tex.repeat.set(ROAD_LENGTH / 24, 1);
-  tex.needsUpdate = true;
   return tex;
 }
 
-function drawDashed(ctx, y, height, totalLen, dash, gap) {
-  for (let x = 0; x < totalLen; x += dash + gap) {
-    ctx.fillRect(x, y, dash, height);
+// Concrete sidewalk panels with slight grout grid.
+function makeSidewalkTexture() {
+  const SIZE = 256;
+  const c = document.createElement('canvas');
+  c.width = SIZE; c.height = SIZE;
+  const ctx = c.getContext('2d');
+  ctx.fillStyle = '#bcb6a8';
+  ctx.fillRect(0, 0, SIZE, SIZE);
+
+  const noise = ctx.createImageData(SIZE, SIZE);
+  for (let i = 0; i < noise.data.length; i += 4) {
+    const v = 200 + Math.floor(Math.random() * 30);
+    noise.data[i] = v; noise.data[i + 1] = v - 6; noise.data[i + 2] = v - 14; noise.data[i + 3] = 25;
+  }
+  ctx.putImageData(noise, 0, 0);
+
+  ctx.strokeStyle = 'rgba(70,60,50,0.55)';
+  ctx.lineWidth = 2;
+  // panel grout every 64 px (≈ panel-sized)
+  for (let x = 0; x <= SIZE; x += 64) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, SIZE); ctx.stroke(); }
+  for (let y = 0; y <= SIZE; y += 64) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(SIZE, y); ctx.stroke(); }
+
+  const tex = new THREE.CanvasTexture(c);
+  tex.colorSpace = THREE.SRGBColorSpace;
+  tex.wrapS = THREE.RepeatWrapping;
+  tex.wrapT = THREE.RepeatWrapping;
+  tex.anisotropy = 8;
+  return tex;
+}
+
+// Soft, low-saturation ground (dirt + dry grass mix). Tiles aggressively.
+function makeGroundTexture() {
+  const SIZE = 256;
+  const c = document.createElement('canvas');
+  c.width = SIZE; c.height = SIZE;
+  const ctx = c.getContext('2d');
+  // base dirt
+  ctx.fillStyle = '#7a6f55';
+  ctx.fillRect(0, 0, SIZE, SIZE);
+  // grass tufts
+  for (let i = 0; i < 1500; i++) {
+    const x = Math.random() * SIZE;
+    const y = Math.random() * SIZE;
+    const r = 0.5 + Math.random() * 1.4;
+    const g = 90 + Math.floor(Math.random() * 50);
+    ctx.fillStyle = `rgba(${100 + Math.floor(Math.random() * 30)}, ${g}, 60, 0.4)`;
+    ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
   }
+  // dust noise
+  const noise = ctx.createImageData(SIZE, SIZE);
+  for (let i = 0; i < noise.data.length; i += 4) {
+    const v = 130 + Math.floor(Math.random() * 30);
+    noise.data[i] = v; noise.data[i + 1] = v - 10; noise.data[i + 2] = v - 30; noise.data[i + 3] = 28;
+  }
+  ctx.putImageData(noise, 0, 0);
+  const tex = new THREE.CanvasTexture(c);
+  tex.colorSpace = THREE.SRGBColorSpace;
+  return tex;
+}
+
+function drawDashed(ctx, y, h, totalLen, dash, gap) {
+  for (let x = 0; x < totalLen; x += dash + gap) ctx.fillRect(x, y, dash, h);
+}
+
+// Tiny seeded PRNG — keeps mountain silhouettes deterministic across reloads.
+function mulberry32(seed) {
+  let s = seed >>> 0;
+  return function () {
+    s = (s + 0x6D2B79F5) >>> 0;
+    let t = s;
+    t = Math.imul(t ^ (t >>> 15), t | 1);
+    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
 }
diff --git a/public/scene-demo.html b/public/scene-demo.html
index 6b86ce0..43d9417 100644
--- a/public/scene-demo.html
+++ b/public/scene-demo.html
@@ -8,30 +8,47 @@
   html, body { margin: 0; padding: 0; height: 100%; background: #0a0a0a; color: #eee;
                font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
   #stage { position: fixed; inset: 0; }
-  #hud {
-    position: fixed; top: 10px; left: 10px; padding: 8px 12px;
-    background: rgba(0,0,0,0.55); border-radius: 6px; backdrop-filter: blur(6px);
-    pointer-events: none;
+  .panel {
+    background: rgba(0,0,0,0.55); border-radius: 8px; backdrop-filter: blur(6px);
+    -webkit-backdrop-filter: blur(6px); padding: 10px 14px;
   }
+  #hud   { position: fixed; top: 10px; left: 10px; pointer-events: none; }
   #hud b { color: #ffd66e; }
-  .controls { position: fixed; top: 10px; right: 10px; display: flex; gap: 8px; }
-  .controls label { background: rgba(0,0,0,0.55); padding: 6px 10px; border-radius: 6px; }
-  .controls input { vertical-align: middle; }
+  #fps   { font-variant-numeric: tabular-nums; opacity: 0.85; }
+  #ctrl  { position: fixed; top: 10px; right: 10px; min-width: 230px; display: flex; flex-direction: column; gap: 8px; }
+  #ctrl label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; opacity: 0.85; }
+  #ctrl input[type=range] { width: 100%; accent-color: #ffd66e; }
+  #ctrl .row { display: flex; gap: 6px; flex-wrap: wrap; }
+  #ctrl .row button {
+    flex: 1 1 auto; background: rgba(255,255,255,0.08); color: #eee; border: 1px solid rgba(255,255,255,0.18);
+    border-radius: 6px; padding: 4px 8px; cursor: pointer; font-size: 11px;
+  }
+  #ctrl .row button:hover { border-color: #ffd66e; color: #ffd66e; }
+  #ctrl .row button.active { background: #ffd66e; color: #1a1100; border-color: #ffd66e; }
+  #clock { font-variant-numeric: tabular-nums; color: #ffd66e; }
 </style>
 </head>
 <body>
 <div id="stage"></div>
-<div id="hud">
-  <b>Ventura Bus 3D</b> · scene smoke test (Task #3)<br/>
-  road + sky + sun · drag-free, render-only
+
+<div id="hud" class="panel">
+  <b>Ventura Bus 3D</b> · base scene (Task #3)<br/>
+  drag = orbit · scroll = zoom · right-drag = pan<br/>
+  <span id="fps">— fps</span>
 </div>
-<div class="controls">
-  <label>elevation
-    <input id="el" type="range" min="0" max="80" step="1" value="22" />
-  </label>
-  <label>azimuth
-    <input id="az" type="range" min="0" max="359" step="1" value="205" />
+
+<div id="ctrl" class="panel">
+  <label>Time of day · <span id="clock">12:00</span>
+    <input id="tod" type="range" min="0" max="1440" step="5" value="720" />
   </label>
+  <div class="row" id="presets">
+    <button data-min="360">Dawn</button>
+    <button data-min="720" class="active">Noon</button>
+    <button data-min="1140">Dusk</button>
+    <button data-min="60">Night</button>
+  </div>
+  <label><input id="autocycle" type="checkbox" /> auto-cycle (×60)</label>
+  <label><input id="loadroute" type="checkbox" /> load /api/route into curve</label>
 </div>
 
 <script type="importmap">
@@ -45,30 +62,94 @@
 
 <script type="module">
 import { createBaseScene } from './js/scene.js';
+import * as THREE from 'three';
 
-const stage = document.getElementById('stage');
-const ctx = createBaseScene({ container: stage });
+const stage   = document.getElementById('stage');
+const todEl   = document.getElementById('tod');
+const clockEl = document.getElementById('clock');
+const fpsEl   = document.getElementById('fps');
+const presets = document.getElementById('presets');
+const autoEl  = document.getElementById('autocycle');
+const loadEl  = document.getElementById('loadroute');
 
-// Slow camera pan east-bound so the road is obviously a long strip.
-let t0 = performance.now();
-ctx.onTick(() => {
-  const t = (performance.now() - t0) / 1000;
-  ctx.camera.position.x = -30 + Math.sin(t * 0.05) * 8;
-  ctx.camera.position.z = 14 + Math.cos(t * 0.05) * 4;
-  ctx.camera.lookAt(40, 2, 0);
+const ctx = createBaseScene({ container: stage, initialMinutes: Number(todEl.value) });
+
+window.__scene = ctx;
+
+function setClock(min) {
+  const m = ((min % 1440) + 1440) % 1440;
+  const hh = String(Math.floor(m / 60)).padStart(2, '0');
+  const mm = String(Math.floor(m % 60)).padStart(2, '0');
+  clockEl.textContent = `${hh}:${mm}`;
+}
+setClock(Number(todEl.value));
+
+todEl.addEventListener('input', () => {
+  const v = Number(todEl.value);
+  ctx.setTimeOfDay(v);
+  setClock(v);
+  presets.querySelectorAll('button').forEach(b => b.classList.toggle('active', Number(b.dataset.min) === v));
 });
 
-document.getElementById('el').addEventListener('input', e => {
-  ctx.setSun(parseFloat(e.target.value), parseFloat(document.getElementById('az').value));
+presets.addEventListener('click', e => {
+  const btn = e.target.closest('button[data-min]');
+  if (!btn) return;
+  const v = Number(btn.dataset.min);
+  todEl.value = String(v);
+  ctx.setTimeOfDay(v);
+  setClock(v);
+  presets.querySelectorAll('button').forEach(b => b.classList.toggle('active', b === btn));
 });
-document.getElementById('az').addEventListener('input', e => {
-  ctx.setSun(parseFloat(document.getElementById('el').value), parseFloat(e.target.value));
+
+let lastT = performance.now();
+let frames = 0; let frameAcc = 0;
+
+ctx.onTick(() => {
+  const now = performance.now();
+  const dt  = (now - lastT) / 1000;
+  lastT = now;
+
+  if (autoEl.checked) {
+    const advance = dt * 60;            // 1 real-sec = 60 simulated-min
+    const next = (Number(todEl.value) + advance) % 1440;
+    todEl.value = String(next);
+    ctx.setTimeOfDay(next);
+    setClock(next);
+  }
+
+  frames += 1; frameAcc += dt;
+  if (frameAcc >= 0.5) {
+    fpsEl.textContent = `${Math.round(frames / frameAcc)} fps`;
+    frames = 0; frameAcc = 0;
+  }
 });
 
-ctx.start();
+async function loadAndApplyRoute() {
+  try {
+    const r = await fetch('/api/route').then(r => r.json());
+    if (!r?.stops?.length) return;
+    const lngs = r.stops.map(s => s.lng);
+    const lats = r.stops.map(s => s.lat);
+    const lngMin = Math.min(...lngs), lngMax = Math.max(...lngs);
+    const latMin = Math.min(...lats), latMax = Math.max(...lats);
+    const lngSpan = lngMax - lngMin || 1;
+    const latSpan = latMax - latMin || 1;
+    const targetLen = ctx.constants.ROAD_LENGTH * 0.95;
+    // +lng → +X (east), +lat → -Z (north)
+    const points = r.stops.map(s => new THREE.Vector3(
+      ((s.lng - lngMin) / lngSpan - 0.5) * targetLen,
+      0,
+      -((s.lat - latMin) / latSpan - 0.5) * (ctx.constants.ROAD_LENGTH * 0.04),
+    ));
+    ctx.setRouteWaypoints(points);
+    console.info('[scene-demo] applied /api/route waypoints', points);
+  } catch (err) {
+    console.warn('[scene-demo] failed to load /api/route', err);
+  }
+}
+loadEl.addEventListener('change', () => { if (loadEl.checked) loadAndApplyRoute(); });
 
-// Expose for console debugging
-window.__scene = ctx;
+ctx.start();
 </script>
 </body>
 </html>

← 4f29a8f switch port to 9783, add ecosystem.config.js, bake in dark/l  ·  back to Ventura Bus 3d  ·  integrate multi-route data + new top header (search/brand/th b2ea9d2 →