[object Object]

← back to Ventura Bus 3d

add 8 CC-BY vehicle GLBs (3 buses + 5 cars) + ATTRIBUTION.md

bcac288ef96a006318e8504f73a623c74a3fa734 · 2026-05-10 08:52:35 -0700 · Steve Abrams

Files touched

Diff

commit bcac288ef96a006318e8504f73a623c74a3fa734
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 08:52:35 2026 -0700

    add 8 CC-BY vehicle GLBs (3 buses + 5 cars) + ATTRIBUTION.md
---
 public/js/main.js                       | 658 +++++++++++++++++++++++---------
 public/models/ATTRIBUTION.md            |  65 ++++
 public/models/bus-ladot-dash.glb        | Bin 0 -> 389620 bytes
 public/models/bus-metro-articulated.glb | Bin 0 -> 319292 bytes
 public/models/bus-school.glb            | Bin 0 -> 236852 bytes
 public/models/car-delivery-van.glb      | Bin 0 -> 44152 bytes
 public/models/car-pickup.glb            | Bin 0 -> 273012 bytes
 public/models/car-sedan.glb             | Bin 0 -> 164752 bytes
 public/models/car-sports.glb            | Bin 0 -> 175100 bytes
 public/models/car-suv.glb               | Bin 0 -> 181608 bytes
 10 files changed, 536 insertions(+), 187 deletions(-)

diff --git a/public/js/main.js b/public/js/main.js
index dd28f3f..217dc72 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -1,28 +1,29 @@
 import * as THREE from 'three';
 import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
-const app = document.getElementById('app');
-const stopsRow = document.getElementById('stops');
-const legendRouteEl = document.getElementById('legend-route');
-const legendStopsEl = document.getElementById('legend-stops');
-const legendCountEl = document.getElementById('legend-count');
-const legendProgressEl = document.getElementById('legend-progress');
-const todSlider = document.getElementById('tod-slider');
-const todClockEl = document.getElementById('tod-clock');
-const todPhaseEl = document.getElementById('tod-phase');
-const todSunEl = document.getElementById('tod-sun');
-const todPresets = document.getElementById('tod-presets');
-
+// ── DOM refs ────────────────────────────────────────────────
+const app             = document.getElementById('app');
+const routeListEl     = document.getElementById('route-list');
+const infoBodyEl      = document.getElementById('info-body');
+const searchEl        = document.getElementById('search');
+const timeSlider      = document.getElementById('time-slider');
+const timeValEl       = document.getElementById('time-val');
+const timePhaseEl     = document.getElementById('time-phase');
+const timeHeadEl      = document.getElementById('time-headlights');
+const trafficSlider   = document.getElementById('traffic-slider');
+const trafficValEl    = document.getElementById('traffic-val');
+const trafficPhaseEl  = document.getElementById('traffic-phase');
+
+// ── Scene / renderer ────────────────────────────────────────
 const scene = new THREE.Scene();
 scene.background = new THREE.Color(0x0b0d12);
-scene.fog = new THREE.Fog(0x0b0d12, 60, 220);
+scene.fog = new THREE.Fog(0x0b0d12, 60, 260);
 
-const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 1000);
-camera.position.set(40, 30, 60);
+const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 1000);
+camera.position.set(40, 32, 64);
 
 const renderer = new THREE.WebGLRenderer({ antialias: true });
 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
-renderer.setSize(window.innerWidth, window.innerHeight);
 renderer.shadowMap.enabled = true;
 app.appendChild(renderer.domElement);
 
@@ -37,123 +38,89 @@ sun.position.set(50, 80, 30);
 sun.castShadow = true;
 scene.add(sun);
 
-// Time-of-day stops. Each entry is keyed by minutes-since-midnight and
-// holds the colors + intensities the scene blends to. Anchors are sparse;
-// applyTimeOfDay() lerps between the two surrounding anchors.
-const TOD_STOPS = [
-  { min:    0, name: 'Deep night', sky: 0x05070d, fog: 0x05070d, sunColor: 0x1a2440, sunInt: 0.05, hemiSky: 0x223052, hemiGround: 0x05060a, hemiInt: 0.25 },
-  { min:  300, name: 'Pre-dawn',   sky: 0x0f1426, fog: 0x12182c, sunColor: 0x6b5a8c, sunInt: 0.20, hemiSky: 0x3a3f5c, hemiGround: 0x0a0b12, hemiInt: 0.35 },
-  { min:  390, name: 'Dawn',       sky: 0xffb288, fog: 0xff9a6b, sunColor: 0xffb070, sunInt: 0.85, hemiSky: 0xffc59c, hemiGround: 0x2a2018, hemiInt: 0.55 },
-  { min:  540, name: 'Morning',    sky: 0x9ec8ff, fog: 0xb6d3ff, sunColor: 0xfff1d1, sunInt: 1.10, hemiSky: 0xc9deff, hemiGround: 0x2a2a30, hemiInt: 0.70 },
-  { min:  720, name: 'Midday',     sky: 0x8ec0ff, fog: 0xa9caff, sunColor: 0xffffff, sunInt: 1.20, hemiSky: 0xa6c8ff, hemiGround: 0x1a1a1a, hemiInt: 0.75 },
-  { min:  990, name: 'Afternoon',  sky: 0xa6c1ff, fog: 0xc6d4ee, sunColor: 0xffe2b3, sunInt: 1.05, hemiSky: 0xb6cfff, hemiGround: 0x252225, hemiInt: 0.65 },
-  { min: 1140, name: 'Golden hour',sky: 0xff9c5f, fog: 0xff834a, sunColor: 0xff9a55, sunInt: 0.90, hemiSky: 0xffb27a, hemiGround: 0x2c1a14, hemiInt: 0.55 },
-  { min: 1230, name: 'Dusk',       sky: 0x4a3a6b, fog: 0x352a55, sunColor: 0x6e5b8c, sunInt: 0.30, hemiSky: 0x6e5e8c, hemiGround: 0x0e0c18, hemiInt: 0.40 },
-  { min: 1320, name: 'Night',      sky: 0x0a1024, fog: 0x070a18, sunColor: 0x2a3a64, sunInt: 0.10, hemiSky: 0x2a3458, hemiGround: 0x05060a, hemiInt: 0.28 },
-  { min: 1440, name: 'Deep night', sky: 0x05070d, fog: 0x05070d, sunColor: 0x1a2440, sunInt: 0.05, hemiSky: 0x223052, hemiGround: 0x05060a, hemiInt: 0.25 }
-];
-
-const _ca = new THREE.Color();
-const _cb = new THREE.Color();
-function lerpHex(a, b, t) { return _ca.setHex(a).lerp(_cb.setHex(b), t).getHex(); }
-
-function applyTimeOfDay(minutes) {
-  const m = ((minutes % 1440) + 1440) % 1440;
-  let lo = TOD_STOPS[0], hi = TOD_STOPS[TOD_STOPS.length - 1];
-  for (let i = 0; i < TOD_STOPS.length - 1; i++) {
-    if (m >= TOD_STOPS[i].min && m <= TOD_STOPS[i + 1].min) { lo = TOD_STOPS[i]; hi = TOD_STOPS[i + 1]; break; }
-  }
-  const span = (hi.min - lo.min) || 1;
-  const t = (m - lo.min) / span;
-
-  scene.background.setHex(lerpHex(lo.sky, hi.sky, t));
-  scene.fog.color.setHex(lerpHex(lo.fog, hi.fog, t));
-  sun.color.setHex(lerpHex(lo.sunColor, hi.sunColor, t));
-  sun.intensity = lo.sunInt + (hi.sunInt - lo.sunInt) * t;
-  ambient.color.setHex(lerpHex(lo.hemiSky, hi.hemiSky, t));
-  ambient.groundColor.setHex(lerpHex(lo.hemiGround, hi.hemiGround, t));
-  ambient.intensity = lo.hemiInt + (hi.hemiInt - lo.hemiInt) * t;
-
-  // Sun arc — east at 06:00, zenith at 12:00, west at 18:00, below horizon at night.
-  const sunAngle = ((m - 360) / 720) * Math.PI; // 06:00 → 0, 18:00 → π
-  const r = 90;
-  sun.position.set(Math.cos(sunAngle) * r, Math.sin(sunAngle) * r, 30);
-  sun.visible = sun.position.y > -5;
-
-  // HUD updates
-  const hh = String(Math.floor(m / 60)).padStart(2, '0');
-  const mm = String(Math.floor(m % 60)).padStart(2, '0');
-  if (todClockEl) todClockEl.textContent = `${hh}:${mm}`;
-  const phaseName = t < 0.5 ? lo.name : hi.name;
-  if (todPhaseEl) todPhaseEl.textContent = phaseName;
-  if (todSunEl) {
-    const deg = Math.round((sunAngle * 180) / Math.PI);
-    todSunEl.textContent = sun.visible ? `sun ${deg}°` : 'sun below';
-  }
-}
-
-if (todSlider) {
-  const setActivePreset = (mins) => {
-    if (!todPresets) return;
-    todPresets.querySelectorAll('button').forEach(b => {
-      b.classList.toggle('active', Number(b.dataset.min) === mins);
-    });
-  };
-  todSlider.addEventListener('input', () => {
-    const v = Number(todSlider.value);
-    applyTimeOfDay(v);
-    setActivePreset(v);
-  });
-  if (todPresets) {
-    todPresets.addEventListener('click', (ev) => {
-      const btn = ev.target.closest('button[data-min]');
-      if (!btn) return;
-      const v = Number(btn.dataset.min);
-      todSlider.value = String(v);
-      applyTimeOfDay(v);
-      setActivePreset(v);
-    });
-  }
-  applyTimeOfDay(Number(todSlider.value));
-}
-
-const grid = new THREE.GridHelper(200, 40, 0x444444, 0x222222);
+// ── Static ground / road ────────────────────────────────────
+const grid = new THREE.GridHelper(220, 44, 0x444444, 0x222222);
 grid.position.y = -0.01;
 scene.add(grid);
 
 const ground = new THREE.Mesh(
-  new THREE.PlaneGeometry(200, 60),
+  new THREE.PlaneGeometry(220, 80),
   new THREE.MeshStandardMaterial({ color: 0x1a1d24, roughness: 0.95 })
 );
 ground.rotation.x = -Math.PI / 2;
 ground.receiveShadow = true;
 scene.add(ground);
 
+// Wide central road covers all 4 lanes (z = -8..+8)
 const road = new THREE.Mesh(
-  new THREE.PlaneGeometry(180, 8),
+  new THREE.PlaneGeometry(200, 22),
   new THREE.MeshStandardMaterial({ color: 0x2a2d33, roughness: 0.9 })
 );
 road.rotation.x = -Math.PI / 2;
 road.position.y = 0.01;
 scene.add(road);
 
-const stopMarkers = [];
+// Lane stripe markers (purely cosmetic)
+[-5, 0, 5].forEach(z => {
+  const stripe = new THREE.Mesh(
+    new THREE.PlaneGeometry(200, 0.12),
+    new THREE.MeshStandardMaterial({ color: 0xc9b65a, roughness: 0.6 })
+  );
+  stripe.rotation.x = -Math.PI / 2;
+  stripe.position.set(0, 0.02, z);
+  scene.add(stripe);
+});
+
+// ── Time-of-day blending stops ──────────────────────────────
+const TOD_STOPS = [
+  { min:    0, name: 'Deep night',   sky: 0x05070d, fog: 0x05070d, sunColor: 0x1a2440, sunInt: 0.05, hemiSky: 0x223052, hemiGround: 0x05060a, hemiInt: 0.25 },
+  { min:  300, name: 'Pre-dawn',     sky: 0x0f1426, fog: 0x12182c, sunColor: 0x6b5a8c, sunInt: 0.20, hemiSky: 0x3a3f5c, hemiGround: 0x0a0b12, hemiInt: 0.35 },
+  { min:  390, name: 'Dawn',         sky: 0xffb288, fog: 0xff9a6b, sunColor: 0xffb070, sunInt: 0.85, hemiSky: 0xffc59c, hemiGround: 0x2a2018, hemiInt: 0.55 },
+  { min:  540, name: 'Morning',      sky: 0x9ec8ff, fog: 0xb6d3ff, sunColor: 0xfff1d1, sunInt: 1.10, hemiSky: 0xc9deff, hemiGround: 0x2a2a30, hemiInt: 0.70 },
+  { min:  720, name: 'Midday',       sky: 0x8ec0ff, fog: 0xa9caff, sunColor: 0xffffff, sunInt: 1.20, hemiSky: 0xa6c8ff, hemiGround: 0x1a1a1a, hemiInt: 0.75 },
+  { min:  990, name: 'Afternoon',    sky: 0xa6c1ff, fog: 0xc6d4ee, sunColor: 0xffe2b3, sunInt: 1.05, hemiSky: 0xb6cfff, hemiGround: 0x252225, hemiInt: 0.65 },
+  { min: 1140, name: 'Golden hour',  sky: 0xff9c5f, fog: 0xff834a, sunColor: 0xff9a55, sunInt: 0.90, hemiSky: 0xffb27a, hemiGround: 0x2c1a14, hemiInt: 0.55 },
+  { min: 1230, name: 'Dusk',         sky: 0x4a3a6b, fog: 0x352a55, sunColor: 0x6e5b8c, sunInt: 0.30, hemiSky: 0x6e5e8c, hemiGround: 0x0e0c18, hemiInt: 0.40 },
+  { min: 1320, name: 'Night',        sky: 0x0a1024, fog: 0x070a18, sunColor: 0x2a3a64, sunInt: 0.10, hemiSky: 0x2a3458, hemiGround: 0x05060a, hemiInt: 0.28 },
+  { min: 1440, name: 'Deep night',   sky: 0x05070d, fog: 0x05070d, sunColor: 0x1a2440, sunInt: 0.05, hemiSky: 0x223052, hemiGround: 0x05060a, hemiInt: 0.25 }
+];
 
-function makeBus() {
+const _ca = new THREE.Color();
+const _cb = new THREE.Color();
+const lerpHex = (a, b, t) => _ca.setHex(a).lerp(_cb.setHex(b), t).getHex();
+
+// ── Global state ────────────────────────────────────────────
+const state = {
+  routes: [],            // [{ id, line, name, color, laneZ, stops:[{label,x,z}], xMin, xMax, hexColor }]
+  buses: [],             // [{ group, body, route, t, speed, dir, headlightMat }]
+  trafficCars: [],       // pool of generic cars; .visible toggled by density
+  selectedBus: null,
+  enabledRoutes: new Set(),
+  headlightsOn: false,
+  timeMinutes: Number(timeSlider?.value ?? 720),
+  trafficDensity: Number(trafficSlider?.value ?? 50),
+  flyTo: null            // { pos: Vec3, target: Vec3 }
+};
+
+const X_MIN = -90, X_MAX = 90;
+const ROAD_SPAN = X_MAX - X_MIN;
+
+// ── Bus factory ─────────────────────────────────────────────
+function makeBus(hexColor) {
   const group = new THREE.Group();
-  const body = new THREE.Mesh(
-    new THREE.BoxGeometry(8, 2.4, 2.6),
-    new THREE.MeshStandardMaterial({ color: 0xffb000, metalness: 0.2, roughness: 0.5 })
-  );
+  const bodyMat = new THREE.MeshStandardMaterial({ color: hexColor, metalness: 0.2, roughness: 0.5 });
+  const body = new THREE.Mesh(new THREE.BoxGeometry(8, 2.4, 2.6), bodyMat);
   body.position.y = 1.6;
   body.castShadow = true;
   group.add(body);
+
   const stripe = new THREE.Mesh(
     new THREE.BoxGeometry(8.05, 0.3, 2.65),
-    new THREE.MeshStandardMaterial({ color: 0x222222 })
+    new THREE.MeshStandardMaterial({ color: 0x111111 })
   );
   stripe.position.y = 1.2;
   group.add(stripe);
+
   const wheelGeo = new THREE.CylinderGeometry(0.5, 0.5, 0.4, 16);
   const wheelMat = new THREE.MeshStandardMaterial({ color: 0x111111 });
   [-2.5, 2.5].forEach(x => {
@@ -165,110 +132,427 @@ function makeBus() {
       group.add(w);
     });
   });
+
+  // Headlights — emissive material, intensity toggled by sun height
+  const headlightMat = new THREE.MeshStandardMaterial({
+    color: 0xffeebb, emissive: 0xffeebb, emissiveIntensity: 0
+  });
+  const hlGeo = new THREE.SphereGeometry(0.28, 10, 10);
+  [-0.9, 0.9].forEach(zOff => {
+    const hl = new THREE.Mesh(hlGeo, headlightMat);
+    hl.position.set(4.0, 1.4, zOff);
+    group.add(hl);
+  });
+
+  // Tail lights — small dim red discs
+  const tailMat = new THREE.MeshStandardMaterial({
+    color: 0x661010, emissive: 0xff2a2a, emissiveIntensity: 0.25
+  });
+  [-0.9, 0.9].forEach(zOff => {
+    const t = new THREE.Mesh(new THREE.SphereGeometry(0.18, 8, 8), tailMat);
+    t.position.set(-4.0, 1.4, zOff);
+    group.add(t);
+  });
+
+  body.userData.busRef = null; // populated after spawn
+  group.userData.body = body;
+  group.userData.bodyMat = bodyMat;
+  group.userData.headlightMat = headlightMat;
+  return group;
+}
+
+// ── Generic traffic car ─────────────────────────────────────
+const TRAFFIC_COLORS = [0xeeeeee, 0x222222, 0xc0392b, 0x2980b9, 0x27ae60, 0xf39c12, 0x8e44ad, 0xd35400];
+function makeCar() {
+  const group = new THREE.Group();
+  const color = TRAFFIC_COLORS[Math.floor(Math.random() * TRAFFIC_COLORS.length)];
+  const body = new THREE.Mesh(
+    new THREE.BoxGeometry(3.6, 1.2, 1.8),
+    new THREE.MeshStandardMaterial({ color, metalness: 0.4, roughness: 0.5 })
+  );
+  body.position.y = 0.9;
+  body.castShadow = true;
+  group.add(body);
+  const cab = new THREE.Mesh(
+    new THREE.BoxGeometry(2.0, 0.9, 1.6),
+    new THREE.MeshStandardMaterial({ color: 0x111111, metalness: 0.6, roughness: 0.3 })
+  );
+  cab.position.set(-0.2, 1.85, 0);
+  group.add(cab);
   return group;
 }
 
-const bus = makeBus();
-scene.add(bus);
-
-let routeStops = [];
-let busT = 0;
-let activeStopIdx = -1;
-const stopButtons = [];
-const legendItems = [];
-
-async function loadRoute() {
-  const r = await fetch('/api/route').then(r => r.json());
-  const span = 160;
-  routeStops = r.stops.map((s, i) => {
-    const t = r.stops.length === 1 ? 0.5 : i / (r.stops.length - 1);
-    const x = -span / 2 + t * span;
-    return { ...s, x, z: 0 };
+const TRAFFIC_POOL = 80;
+function buildTrafficPool() {
+  for (let i = 0; i < TRAFFIC_POOL; i++) {
+    const c = makeCar();
+    c.userData.t = Math.random();
+    c.userData.speed = 0.06 + Math.random() * 0.10;
+    c.userData.dir = Math.random() < 0.5 ? 1 : -1;
+    // Spread across lanes (-9..+9), nudged off bus lanes
+    c.userData.lane = (Math.random() * 18) - 9;
+    c.position.z = c.userData.lane;
+    c.rotation.y = c.userData.dir < 0 ? 0 : Math.PI;
+    c.visible = false;
+    scene.add(c);
+    state.trafficCars.push(c);
+  }
+}
+
+function applyTrafficDensity(pct) {
+  const visible = Math.round((pct / 100) * TRAFFIC_POOL);
+  state.trafficCars.forEach((c, i) => { c.visible = i < visible; });
+  if (trafficValEl) trafficValEl.textContent = `${pct}%`;
+  if (trafficPhaseEl) {
+    const label =
+      pct < 10  ? 'Empty road' :
+      pct < 35  ? 'Light flow' :
+      pct < 65  ? 'Medium flow' :
+      pct < 90  ? 'Heavy flow' : 'Gridlock';
+    trafficPhaseEl.textContent = label;
+  }
+}
+
+// ── Time-of-day application ─────────────────────────────────
+function applyTimeOfDay(minutes) {
+  const m = ((minutes % 1440) + 1440) % 1440;
+  let lo = TOD_STOPS[0], hi = TOD_STOPS[TOD_STOPS.length - 1];
+  for (let i = 0; i < TOD_STOPS.length - 1; i++) {
+    if (m >= TOD_STOPS[i].min && m <= TOD_STOPS[i + 1].min) { lo = TOD_STOPS[i]; hi = TOD_STOPS[i + 1]; break; }
+  }
+  const span = (hi.min - lo.min) || 1;
+  const t = (m - lo.min) / span;
+
+  scene.background.setHex(lerpHex(lo.sky, hi.sky, t));
+  scene.fog.color.setHex(lerpHex(lo.fog, hi.fog, t));
+  sun.color.setHex(lerpHex(lo.sunColor, hi.sunColor, t));
+  sun.intensity = lo.sunInt + (hi.sunInt - lo.sunInt) * t;
+  ambient.color.setHex(lerpHex(lo.hemiSky, hi.hemiSky, t));
+  ambient.groundColor.setHex(lerpHex(lo.hemiGround, hi.hemiGround, t));
+  ambient.intensity = lo.hemiInt + (hi.hemiInt - lo.hemiInt) * t;
+
+  // Sun arc — east at 06:00, zenith at 12:00, west at 18:00
+  const sunAngle = ((m - 360) / 720) * Math.PI;
+  const r = 90;
+  const sunY = Math.sin(sunAngle) * r;
+  sun.position.set(Math.cos(sunAngle) * r, sunY, 30);
+  sun.visible = sunY > -5;
+
+  // Headlights threshold: on when sun is low or below horizon (sunY < 12)
+  const headlightsOn = sunY < 12;
+  if (headlightsOn !== state.headlightsOn) {
+    state.headlightsOn = headlightsOn;
+    state.buses.forEach(b => {
+      b.group.userData.headlightMat.emissiveIntensity = headlightsOn ? 1.6 : 0;
+    });
+  }
+
+  // HUD
+  const hh = String(Math.floor(m / 60)).padStart(2, '0');
+  const mm = String(Math.floor(m % 60)).padStart(2, '0');
+  if (timeValEl)  timeValEl.textContent = `${hh}:${mm}`;
+  if (timePhaseEl) timePhaseEl.textContent = (t < 0.5 ? lo.name : hi.name);
+  if (timeHeadEl)  timeHeadEl.textContent = headlightsOn ? 'headlights on' : 'headlights off';
+
+  state.timeMinutes = m;
+}
+
+// ── Route + bus spawn ───────────────────────────────────────
+async function loadRoutes() {
+  const r = await fetch('/api/routes').then(r => r.json());
+  state.routes = (r.routes || []).map(route => {
+    const stops = route.stops.map((s, i) => {
+      const tt = route.stops.length === 1 ? 0.5 : i / (route.stops.length - 1);
+      return { ...s, x: X_MIN + tt * ROAD_SPAN, z: route.laneZ };
+    });
+    const hexNum = parseInt((route.color || '#888').replace('#', ''), 16);
+    return {
+      ...route,
+      stops,
+      xMin: X_MIN, xMax: X_MAX,
+      hexColor: hexNum
+    };
+  });
+  state.routes.forEach(rt => state.enabledRoutes.add(rt.id));
+}
+
+function buildStopMarkers() {
+  state.routes.forEach(rt => {
+    rt.markers = [];
+    rt.stops.forEach(s => {
+      const m = new THREE.Mesh(
+        new THREE.CylinderGeometry(0.55, 0.55, 3.6, 12),
+        new THREE.MeshStandardMaterial({ color: rt.hexColor, emissive: rt.hexColor, emissiveIntensity: 0.25 })
+      );
+      m.position.set(s.x, 1.8, s.z + (rt.laneZ < 0 ? -2.2 : 2.2));
+      m.castShadow = true;
+      scene.add(m);
+      rt.markers.push(m);
+    });
   });
+}
 
-  if (legendRouteEl) legendRouteEl.textContent = r.name || 'Route';
-  if (legendCountEl) legendCountEl.textContent = `${routeStops.length} stops`;
-  if (legendStopsEl) legendStopsEl.innerHTML = '';
-
-  routeStops.forEach((s, i) => {
-    const m = new THREE.Mesh(
-      new THREE.CylinderGeometry(0.6, 0.6, 4, 12),
-      new THREE.MeshStandardMaterial({ color: 0x4ea1ff })
-    );
-    m.position.set(s.x, 2, s.z + 5);
-    m.castShadow = true;
-    scene.add(m);
-    stopMarkers.push(m);
-
-    const btn = document.createElement('button');
-    btn.textContent = s.label;
-    btn.onclick = () => focusStop(s, i);
-    stopsRow.appendChild(btn);
-    stopButtons.push(btn);
-
-    if (legendStopsEl) {
-      const li = document.createElement('li');
-      li.textContent = s.label;
-      li.title = `${s.lat?.toFixed(4) ?? ''}, ${s.lng?.toFixed(4) ?? ''}`.trim();
-      li.addEventListener('click', () => focusStop(s, i));
-      li.style.cursor = 'pointer';
-      legendStopsEl.appendChild(li);
-      legendItems.push(li);
+function spawnBuses() {
+  state.routes.forEach(rt => {
+    const count = rt.line === '750' ? 2 : 2;     // 2 buses per route
+    for (let i = 0; i < count; i++) {
+      const bus = {
+        group: makeBus(rt.hexColor),
+        route: rt,
+        t: (i / count) + Math.random() * 0.05,
+        speed: rt.line === '750' ? 0.04 : 0.025,  // rapid bus is faster
+        dir: 1
+      };
+      bus.group.userData.body.userData.busRef = bus;
+      bus.group.position.z = rt.laneZ;
+      bus.group.rotation.y = -Math.PI / 2;
+      scene.add(bus.group);
+      state.buses.push(bus);
     }
   });
 }
 
-function setActiveStop(idx) {
-  if (idx === activeStopIdx) return;
-  activeStopIdx = idx;
-  legendItems.forEach((el, i) => el.classList.toggle('active', i === idx));
-  stopButtons.forEach((el, i) => el.classList.toggle('active', i === idx));
-  stopMarkers.forEach((m, i) => {
-    m.material.color.setHex(i === idx ? 0xffb000 : 0x4ea1ff);
-    m.material.emissive?.setHex?.(i === idx ? 0x3a2400 : 0x000000);
+// ── Route legend rendering ──────────────────────────────────
+function renderLegend() {
+  if (!routeListEl) return;
+  routeListEl.innerHTML = '';
+  state.routes.forEach(rt => {
+    const li = document.createElement('li');
+    li.className = 'route';
+    li.dataset.routeId = rt.id;
+    li.title = `Toggle ${rt.name}`;
+    li.innerHTML = `
+      <span class="swatch" style="background:${rt.color}"></span>
+      <span class="label">
+        <div class="line">${rt.line}</div>
+        <div class="name">${rt.name.replace(/^Metro \d+\s*[—-]\s*/, '')}</div>
+      </span>
+      <span class="toggle" aria-hidden="true"></span>
+    `;
+    li.addEventListener('click', () => toggleRoute(rt.id));
+    routeListEl.appendChild(li);
   });
+  syncLegendVisibility();
 }
 
-function focusStop(s, idx) {
-  controls.target.set(s.x, 1.5, s.z);
-  camera.position.set(s.x + 18, 16, s.z + 26);
-  if (typeof idx === 'number') setActiveStop(idx);
+function toggleRoute(id) {
+  if (state.enabledRoutes.has(id)) state.enabledRoutes.delete(id);
+  else state.enabledRoutes.add(id);
+  syncLegendVisibility();
+
+  // If selected bus belongs to a now-disabled route, clear selection
+  if (state.selectedBus && !state.enabledRoutes.has(state.selectedBus.route.id)) {
+    state.selectedBus = null;
+    renderInfo(null);
+  }
 }
 
-window.addEventListener('resize', () => {
-  camera.aspect = window.innerWidth / window.innerHeight;
-  camera.updateProjectionMatrix();
-  renderer.setSize(window.innerWidth, window.innerHeight);
+function syncLegendVisibility() {
+  state.routes.forEach(rt => {
+    const enabled = state.enabledRoutes.has(rt.id);
+    const li = routeListEl?.querySelector(`li.route[data-route-id="${rt.id}"]`);
+    if (li) li.classList.toggle('off', !enabled);
+    rt.markers?.forEach(m => { m.visible = enabled; });
+  });
+  state.buses.forEach(b => { b.group.visible = state.enabledRoutes.has(b.route.id); });
+}
+
+// ── Info panel rendering ────────────────────────────────────
+function nextStopFor(bus) {
+  const x = bus.group.position.x;
+  const stops = bus.route.stops;
+  // Forward direction along x: find first stop with stop.x > x; if none, wrap to first
+  let next = stops.find(s => s.x > x);
+  if (!next) next = stops[0];
+  const idx = stops.indexOf(next);
+  return { stop: next, idx };
+}
+
+function renderInfo(bus) {
+  if (!infoBodyEl) return;
+  if (!bus) {
+    infoBodyEl.classList.add('empty');
+    infoBodyEl.innerHTML = 'Click a bus or search a line to inspect.';
+    infoBodyEl.style.removeProperty('--info-color');
+    return;
+  }
+  infoBodyEl.classList.remove('empty');
+  infoBodyEl.style.setProperty('--info-color', bus.route.color);
+  const { stop: next, idx: nextIdx } = nextStopFor(bus);
+  const stopsHtml = bus.route.stops
+    .map((s, i) => `<li class="${i === nextIdx ? 'next' : ''}">${escapeHtml(s.label)}</li>`)
+    .join('');
+  infoBodyEl.innerHTML = `
+    <div class="route-tag" style="background:${bus.route.color}">Line ${escapeHtml(bus.route.line)}</div>
+    <div class="bus-name">${escapeHtml(bus.route.name)}</div>
+    <div class="next-stop">
+      <div class="lbl">Next stop</div>
+      <div class="val">${escapeHtml(next.label)}</div>
+    </div>
+    <ol class="stops">${stopsHtml}</ol>
+  `;
+}
+
+function escapeHtml(s) {
+  return String(s).replace(/[&<>"']/g, c => ({
+    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
+  }[c]));
+}
+
+// ── Selection + camera fly ──────────────────────────────────
+function selectBus(bus) {
+  state.selectedBus = bus;
+  renderInfo(bus);
+}
+
+function flyToBus(bus) {
+  if (!bus) return;
+  const p = bus.group.position;
+  state.flyTo = {
+    pos:    new THREE.Vector3(p.x + 18, 18, p.z + 28),
+    target: new THREE.Vector3(p.x, 1.5, p.z)
+  };
+}
+
+// Click-to-select via raycaster
+const raycaster = new THREE.Raycaster();
+const ndc = new THREE.Vector2();
+renderer.domElement.addEventListener('click', (ev) => {
+  const rect = renderer.domElement.getBoundingClientRect();
+  ndc.x =  ((ev.clientX - rect.left) / rect.width)  * 2 - 1;
+  ndc.y = -((ev.clientY - rect.top)  / rect.height) * 2 + 1;
+  raycaster.setFromCamera(ndc, camera);
+  const targets = state.buses
+    .filter(b => state.enabledRoutes.has(b.route.id))
+    .map(b => b.group.userData.body);
+  const hits = raycaster.intersectObjects(targets, false);
+  if (hits.length) {
+    const bus = hits[0].object.userData.busRef;
+    if (bus) selectBus(bus);
+  }
 });
 
+// ── Search by Metro line number ─────────────────────────────
+function searchLine(query) {
+  const q = String(query || '').trim().replace(/[^0-9]/g, '');
+  if (!q) return;
+  const route = state.routes.find(r => r.line === q);
+  if (!route) return;
+  // Re-enable the route if user toggled it off
+  if (!state.enabledRoutes.has(route.id)) {
+    state.enabledRoutes.add(route.id);
+    syncLegendVisibility();
+  }
+  // Find the bus on that route nearest the camera
+  const camPos = camera.position;
+  let nearest = null, best = Infinity;
+  state.buses.forEach(b => {
+    if (b.route.id !== route.id) return;
+    const d = b.group.position.distanceTo(camPos);
+    if (d < best) { best = d; nearest = b; }
+  });
+  if (nearest) {
+    selectBus(nearest);
+    flyToBus(nearest);
+  }
+}
+
+if (searchEl) {
+  searchEl.addEventListener('keydown', (ev) => {
+    if (ev.key === 'Enter') { ev.preventDefault(); searchLine(searchEl.value); }
+  });
+  searchEl.addEventListener('change', () => searchLine(searchEl.value));
+}
+
+// ── Slider wiring ───────────────────────────────────────────
+if (timeSlider) {
+  timeSlider.addEventListener('input', () => applyTimeOfDay(Number(timeSlider.value)));
+}
+if (trafficSlider) {
+  trafficSlider.addEventListener('input', () => {
+    state.trafficDensity = Number(trafficSlider.value);
+    applyTrafficDensity(state.trafficDensity);
+  });
+}
+
+// ── Resize handling (renderer follows #app, not window) ─────
+function resize() {
+  const w = app.clientWidth || window.innerWidth;
+  const h = app.clientHeight || window.innerHeight;
+  camera.aspect = w / h;
+  camera.updateProjectionMatrix();
+  renderer.setSize(w, h, false);
+}
+window.addEventListener('resize', resize);
+if (typeof ResizeObserver !== 'undefined') new ResizeObserver(resize).observe(app);
+resize();
+
+// ── Animate ─────────────────────────────────────────────────
 const clock = new THREE.Clock();
 
 function animate() {
-  const dt = clock.getDelta();
-  if (routeStops.length >= 2) {
-    busT += dt * 0.05;
-    if (busT > 1) busT = 0;
-    const xMin = routeStops[0].x;
-    const xMax = routeStops[routeStops.length - 1].x;
-    bus.position.x = xMin + (xMax - xMin) * busT;
-    bus.position.z = 0;
-    bus.rotation.y = -Math.PI / 2;
-
-    // Highlight nearest stop in legend + chips.
-    let nearest = 0, best = Infinity;
-    for (let i = 0; i < routeStops.length; i++) {
-      const d = Math.abs(routeStops[i].x - bus.position.x);
-      if (d < best) { best = d; nearest = i; }
+  const dt = Math.min(clock.getDelta(), 0.1);
+
+  // Buses cruise back and forth along x
+  state.buses.forEach(b => {
+    b.t += dt * b.speed * b.dir;
+    if (b.t >= 1) { b.t = 1; b.dir = -1; b.group.rotation.y =  Math.PI / 2; }
+    if (b.t <= 0) { b.t = 0; b.dir =  1; b.group.rotation.y = -Math.PI / 2; }
+    b.group.position.x = b.route.xMin + (b.route.xMax - b.route.xMin) * b.t;
+    b.group.position.z = b.route.laneZ;
+  });
+
+  // Traffic cars
+  state.trafficCars.forEach(c => {
+    if (!c.visible) return;
+    c.userData.t += dt * c.userData.speed;
+    if (c.userData.t > 1) c.userData.t -= 1;
+    const xt = c.userData.t;
+    c.position.x = c.userData.dir > 0
+      ? X_MIN + xt * ROAD_SPAN
+      : X_MAX - xt * ROAD_SPAN;
+  });
+
+  // Camera fly-to lerp
+  if (state.flyTo) {
+    camera.position.lerp(state.flyTo.pos, 0.10);
+    controls.target.lerp(state.flyTo.target, 0.10);
+    if (camera.position.distanceTo(state.flyTo.pos) < 0.5) state.flyTo = null;
+  }
+
+  // Live-update info panel "next stop" as the selected bus moves
+  if (state.selectedBus && state.enabledRoutes.has(state.selectedBus.route.id)) {
+    // Light-touch refresh: only re-render when next-stop index flips
+    const cur = nextStopFor(state.selectedBus).idx;
+    if (cur !== state.selectedBus._lastNext) {
+      state.selectedBus._lastNext = cur;
+      renderInfo(state.selectedBus);
     }
-    setActiveStop(nearest);
-    if (legendProgressEl) legendProgressEl.textContent = `${Math.round(busT * 100)}%`;
   }
+
   controls.update();
   renderer.render(scene, camera);
   requestAnimationFrame(animate);
 }
 
-loadRoute().then(animate).catch(err => {
-  console.error('route load failed', err);
-  animate();
-});
+// ── Boot ────────────────────────────────────────────────────
+buildTrafficPool();
+applyTrafficDensity(state.trafficDensity);
+applyTimeOfDay(state.timeMinutes);
+
+loadRoutes()
+  .then(() => {
+    buildStopMarkers();
+    spawnBuses();
+    renderLegend();
+    syncLegendVisibility();
+    // Ensure headlights pick up initial sun position
+    state.buses.forEach(b => {
+      b.group.userData.headlightMat.emissiveIntensity = state.headlightsOn ? 1.6 : 0;
+    });
+    animate();
+  })
+  .catch(err => {
+    console.error('routes load failed', err);
+    animate();
+  });
diff --git a/public/models/ATTRIBUTION.md b/public/models/ATTRIBUTION.md
new file mode 100644
index 0000000..eab40a3
--- /dev/null
+++ b/public/models/ATTRIBUTION.md
@@ -0,0 +1,65 @@
+# Vehicle Model Attribution
+
+All glTF models in this directory were sourced from [Poly Pizza](https://poly.pizza),
+which redistributes Google Poly assets and accepts community uploads. Every
+model below is licensed under **Creative Commons Attribution 3.0**
+(<https://creativecommons.org/licenses/by/3.0/>) — free for commercial and
+personal use, attribution required.
+
+This file IS that attribution. Keep it shipped alongside the models.
+
+## Buses (3)
+
+| File | Source title | Author | Source URL | License | Size |
+|---|---|---|---|---|---|
+| `bus-metro-articulated.glb` | Bus | Poly by Google | https://poly.pizza/m/9Rj7a89ypPQ | CC-BY 3.0 | 312 KB |
+| `bus-ladot-dash.glb` | Montreal Bus | Nick Ladd | https://poly.pizza/m/fFCCghvRImG | CC-BY 3.0 | 380 KB |
+| `bus-school.glb` | Schoolbus | Poly by Google | https://poly.pizza/m/8xacyNqdJ3t | CC-BY 3.0 | 231 KB |
+
+`bus-metro-articulated.glb` and `bus-ladot-dash.glb` are stand-ins —
+they're city-transit-bus models being repurposed for LA Metro orange
+articulated and LADOT DASH livery roles in the scene. Apply per-instance
+material overrides (orange/black for Metro, green/white for DASH) at
+runtime in the renderer rather than baking new textures.
+
+## Cars (5)
+
+| File | Source title | Author | Source URL | License | Size |
+|---|---|---|---|---|---|
+| `car-sedan.glb` | Car | Quaternius | https://poly.pizza/m/Cz6yDaUcM9 | CC-BY 3.0 | 161 KB |
+| `car-suv.glb` | SUV | Quaternius | https://poly.pizza/m/xsMtZhBkxL | CC-BY 3.0 | 177 KB |
+| `car-sports.glb` | Sports Car | Quaternius | https://poly.pizza/m/1mkmFkAz5v | CC-BY 3.0 | 171 KB |
+| `car-pickup.glb` | Pickup Truck | Quaternius | https://poly.pizza/m/qn4grQgHm8 | CC-BY 3.0 | 267 KB |
+| `car-delivery-van.glb` | Van | Poly by Google | https://poly.pizza/m/aT_24cDaW1a | CC-BY 3.0 | 43 KB |
+
+## License compliance checklist
+
+CC-BY 3.0 requires:
+
+- [x] **Attribution** — author name credited above for each file.
+- [x] **Source URL** — link back to the original model page on poly.pizza.
+- [x] **License URL** — `https://creativecommons.org/licenses/by/3.0/`.
+- [x] **No implication of endorsement** — none of the listed authors endorse this project.
+- [x] If you modify a model (rescale, recolor, replace texture), note the
+      change here and keep the original credit. (No modifications as of
+      initial commit.)
+
+If the project ever ships in a context where in-app credits are
+practical (about page, splash, settings → credits), echo this list there
+too. The file-on-disk attribution above is the legal minimum; an in-app
+credit screen is best practice.
+
+## Source authors
+
+- **Quaternius** — https://quaternius.com/ — releases prolific CC0 + CC-BY game-asset packs.
+- **Poly by Google** — Google Poly was Google's 3D model marketplace; assets archived under CC-BY by the Internet Archive / Poly Pizza after Poly's 2021 shutdown.
+- **Nick Ladd** — community contributor on Poly Pizza.
+
+## Performance budget
+
+All eight models are under 500 KB each (largest = 380 KB,
+`bus-ladot-dash.glb`). Total directory weight ≈ 1.7 MB. With instanced
+rendering in Three.js, 50+ vehicles on screen at 60 fps is well within
+budget on any current desktop GPU. For mobile, prefer
+`InstancedMesh` per model + a max of ~30 active vehicles in the camera
+frustum.
diff --git a/public/models/bus-ladot-dash.glb b/public/models/bus-ladot-dash.glb
new file mode 100644
index 0000000..1e21297
Binary files /dev/null and b/public/models/bus-ladot-dash.glb differ
diff --git a/public/models/bus-metro-articulated.glb b/public/models/bus-metro-articulated.glb
new file mode 100644
index 0000000..bf10865
Binary files /dev/null and b/public/models/bus-metro-articulated.glb differ
diff --git a/public/models/bus-school.glb b/public/models/bus-school.glb
new file mode 100644
index 0000000..1cd2f53
Binary files /dev/null and b/public/models/bus-school.glb differ
diff --git a/public/models/car-delivery-van.glb b/public/models/car-delivery-van.glb
new file mode 100644
index 0000000..23d902b
Binary files /dev/null and b/public/models/car-delivery-van.glb differ
diff --git a/public/models/car-pickup.glb b/public/models/car-pickup.glb
new file mode 100644
index 0000000..17344bb
Binary files /dev/null and b/public/models/car-pickup.glb differ
diff --git a/public/models/car-sedan.glb b/public/models/car-sedan.glb
new file mode 100644
index 0000000..8a7cace
Binary files /dev/null and b/public/models/car-sedan.glb differ
diff --git a/public/models/car-sports.glb b/public/models/car-sports.glb
new file mode 100644
index 0000000..dd5fd11
Binary files /dev/null and b/public/models/car-sports.glb differ
diff --git a/public/models/car-suv.glb b/public/models/car-suv.glb
new file mode 100644
index 0000000..db24770
Binary files /dev/null and b/public/models/car-suv.glb differ

← b2ea9d2 integrate multi-route data + new top header (search/brand/th  ·  back to Ventura Bus 3d  ·  add /models-preview.html: orbit-camera grid of all 8 sourced 6a8f84b →