← back to Ventura Bus 3d
task #5: traffic.js — fixed-timestep bus + ambient car sim with collision avoidance
9e03e9b7726019642b2b59d1c7054567fa2ebec7 · 2026-05-10 09:39:18 -0700 · Steve
Files touched
Diff
commit 9e03e9b7726019642b2b59d1c7054567fa2ebec7
Author: Steve <steve@designerwallcoverings.com>
Date: Sun May 10 09:39:18 2026 -0700
task #5: traffic.js — fixed-timestep bus + ambient car sim with collision avoidance
---
public/js/traffic.js | 558 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 558 insertions(+)
diff --git a/public/js/traffic.js b/public/js/traffic.js
new file mode 100644
index 0000000..9fcadb2
--- /dev/null
+++ b/public/js/traffic.js
@@ -0,0 +1,558 @@
+// Ventura traffic animation system.
+//
+// - Buses follow polylines built from /api/routes (lat/lng → world XZ).
+// - Variable cruise speed 25–40 mph, 3–5 s dwell at each stop.
+// - Multiple buses per route, pre-populated at evenly-spaced offsets.
+// - Ambient cars spawn at world edges, follow lanes parallel to the
+// road, occasionally lane-change to a partner lane, despawn at the
+// far edge.
+// - Simple collision avoidance: each vehicle inspects the nearest
+// vehicle ahead on its own path; if the gap is < 4 m the target
+// speed is clamped to gap/4 × ahead-speed (and to 0 inside 1 m).
+// - Update loop uses a fixed 1/60 s timestep accumulator, decoupled
+// from the render frame rate.
+//
+// If /api/routes can't be reached the module falls back to
+// FALLBACK_ROUTE_DATA so the animation still runs in isolation.
+
+import * as THREE from 'three';
+
+const MPH_TO_MPS = 0.44704;
+const FIXED_STEP = 1 / 60;
+const MAX_STEPS_PER_FRAME = 5;
+
+const WORLD_WIDTH = 180;
+const WORLD_HEIGHT = 4;
+const DEFAULT_BOUNDS = { west: -118.67, east: -118.37, south: 34.14, north: 34.19 };
+
+const FALLBACK_ROUTE_DATA = {
+ bounds: DEFAULT_BOUNDS,
+ routes: [
+ {
+ id: 'fallback-east', line: '—', name: 'Eastbound', color: '#ffb000', laneZ: -2,
+ stops: [
+ { id: 'wh', label: 'Woodland Hills', lng: -118.6050, lat: 34.1683 },
+ { id: 'tz', label: 'Tarzana', lng: -118.5530, lat: 34.1700 },
+ { id: 'en', label: 'Encino', lng: -118.5010, lat: 34.1590 },
+ { id: 'so', label: 'Sherman Oaks', lng: -118.4490, lat: 34.1510 },
+ { id: 'sc', label: 'Studio City', lng: -118.3960, lat: 34.1430 }
+ ]
+ },
+ {
+ id: 'fallback-west', line: '—', name: 'Westbound', color: '#4ea1ff', laneZ: 2,
+ stops: [
+ { id: 'sc2', label: 'Studio City', lng: -118.3960, lat: 34.1430 },
+ { id: 'so2', label: 'Sherman Oaks', lng: -118.4490, lat: 34.1510 },
+ { id: 'en2', label: 'Encino', lng: -118.5010, lat: 34.1590 },
+ { id: 'tz2', label: 'Tarzana', lng: -118.5530, lat: 34.1700 },
+ { id: 'wh2', label: 'Woodland Hills', lng: -118.6050, lat: 34.1683 }
+ ]
+ }
+ ]
+};
+
+const DEFAULT_AMBIENT_LANES = [
+ { id: 'east-outer', polyline: [[-95, -3.4], [95, -3.4]], partnerId: 'east-inner', speedMph: { min: 30, max: 42 }, spawnSec: { min: 4, max: 9 } },
+ { id: 'east-inner', polyline: [[-95, -2.6], [95, -2.6]], partnerId: 'east-outer', speedMph: { min: 25, max: 38 }, spawnSec: { min: 5, max: 11 } },
+ { id: 'west-inner', polyline: [[ 95, 2.6], [-95, 2.6]], partnerId: 'west-outer', speedMph: { min: 25, max: 38 }, spawnSec: { min: 5, max: 11 } },
+ { id: 'west-outer', polyline: [[ 95, 3.4], [-95, 3.4]], partnerId: 'west-inner', speedMph: { min: 30, max: 42 }, spawnSec: { min: 4, max: 9 } }
+];
+
+const CAR_PALETTE = [0xc0392b, 0x16a085, 0x2c3e50, 0xf39c12, 0x8e44ad, 0xecf0f1, 0x34495e, 0xd35400, 0x2980b9, 0x27ae60];
+
+class Path {
+ constructor(coords) {
+ this.points = coords.map(([x, z]) => ({ x, z }));
+ this.segLengths = [];
+ this.cumLengths = [0];
+ this.totalLength = 0;
+ for (let i = 1; i < this.points.length; i++) {
+ const a = this.points[i - 1], b = this.points[i];
+ const len = Math.hypot(b.x - a.x, b.z - a.z);
+ this.segLengths.push(len);
+ this.totalLength += len;
+ this.cumLengths.push(this.totalLength);
+ }
+ }
+
+ distanceAtIndex(idx) {
+ const i = Math.max(0, Math.min(idx | 0, this.cumLengths.length - 1));
+ return this.cumLengths[i];
+ }
+
+ sample(d, out) {
+ const total = this.totalLength;
+ let dd = total > 0 ? ((d % total) + total) % total : 0;
+ if (this.segLengths.length === 0) {
+ out.x = this.points[0].x; out.z = this.points[0].z; out.heading = 0;
+ return out;
+ }
+ let seg = 0;
+ while (seg < this.segLengths.length - 1 && this.cumLengths[seg + 1] < dd) seg++;
+ const segLen = this.segLengths[seg] || 1;
+ const t = (dd - this.cumLengths[seg]) / segLen;
+ const a = this.points[seg], b = this.points[seg + 1];
+ const dx = b.x - a.x, dz = b.z - a.z;
+ out.x = a.x + dx * t;
+ out.z = a.z + dz * t;
+ // Mesh convention: forward = +X. atan2(-dz, dx) yields the
+ // rotation.y that aligns +X with (dx, dz).
+ out.heading = Math.atan2(-dz, dx);
+ return out;
+ }
+}
+
+const _s1 = { x: 0, z: 0, heading: 0 };
+const _s2 = { x: 0, z: 0, heading: 0 };
+
+function rand(min, max) { return min + Math.random() * (max - min); }
+function parseColor(c, fallback = 0xffb000) {
+ if (typeof c === 'number') return c;
+ if (typeof c === 'string') {
+ const n = parseInt(c.replace('#', ''), 16);
+ return Number.isFinite(n) ? n : fallback;
+ }
+ return fallback;
+}
+
+function makeBusMesh(color = 0xffb000) {
+ const group = new THREE.Group();
+ const body = new THREE.Mesh(
+ new THREE.BoxGeometry(8, 2.4, 2.6),
+ new THREE.MeshStandardMaterial({ color, metalness: 0.2, roughness: 0.5 })
+ );
+ 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 })
+ );
+ stripe.position.y = 1.2;
+ group.add(stripe);
+ const win = new THREE.Mesh(
+ new THREE.BoxGeometry(7.5, 0.9, 2.4),
+ new THREE.MeshStandardMaterial({ color: 0x101418, metalness: 0.7, roughness: 0.15, emissive: 0x0a0d10 })
+ );
+ win.position.y = 2.3;
+ group.add(win);
+ const wheelGeo = new THREE.CylinderGeometry(0.5, 0.5, 0.4, 12);
+ const wheelMat = new THREE.MeshStandardMaterial({ color: 0x0a0a0a });
+ for (const x of [-2.5, 2.5]) {
+ for (const z of [-1.1, 1.1]) {
+ const w = new THREE.Mesh(wheelGeo, wheelMat);
+ w.rotation.x = Math.PI / 2;
+ w.position.set(x, 0.5, z);
+ w.castShadow = true;
+ group.add(w);
+ }
+ }
+ return group;
+}
+
+function makeCarMesh(color) {
+ const group = new THREE.Group();
+ const body = new THREE.Mesh(
+ new THREE.BoxGeometry(4.2, 1.1, 1.8),
+ new THREE.MeshStandardMaterial({ color, metalness: 0.35, roughness: 0.4 })
+ );
+ body.position.y = 0.75; body.castShadow = true;
+ group.add(body);
+ const cabin = new THREE.Mesh(
+ new THREE.BoxGeometry(2.2, 0.7, 1.65),
+ new THREE.MeshStandardMaterial({ color: 0x0e1217, metalness: 0.6, roughness: 0.18 })
+ );
+ cabin.position.set(-0.2, 1.55, 0);
+ group.add(cabin);
+ const wheelGeo = new THREE.CylinderGeometry(0.34, 0.34, 0.3, 10);
+ const wheelMat = new THREE.MeshStandardMaterial({ color: 0x0a0a0a });
+ for (const x of [-1.4, 1.4]) {
+ for (const z of [-0.85, 0.85]) {
+ const w = new THREE.Mesh(wheelGeo, wheelMat);
+ w.rotation.x = Math.PI / 2;
+ w.position.set(x, 0.34, z);
+ group.add(w);
+ }
+ }
+ return group;
+}
+
+class Bus {
+ constructor({ route, path, stopDists, cruiseMps, dwellRange, mesh, initialDist, initialStopIdx }) {
+ this.route = route;
+ this.path = path;
+ this.stopDists = stopDists;
+ this.cruiseMps = cruiseMps;
+ this.dwellRange = dwellRange;
+ this.mesh = mesh;
+ this.dist = initialDist;
+ this.speed = 0;
+ this.state = 'cruise';
+ this.dwellTimer = 0;
+ this.nextStopIdx = initialStopIdx;
+ this.gapAhead = Infinity;
+ this.speedAhead = 0;
+ }
+
+ distToNextStop() {
+ const target = this.stopDists[this.nextStopIdx];
+ let d = target - this.dist;
+ if (d < -0.01) d += this.path.totalLength;
+ return d;
+ }
+
+ step(dt) {
+ if (this.state === 'dwell') {
+ this.dwellTimer -= dt;
+ this.speed = 0;
+ if (this.dwellTimer <= 0) {
+ this.nextStopIdx = (this.nextStopIdx + 1) % this.stopDists.length;
+ this.cruiseMps = rand(this.route.speedMpsMin, this.route.speedMpsMax);
+ this.state = 'cruise';
+ }
+ this._writeMesh();
+ return;
+ }
+
+ const distToStop = this.distToNextStop();
+ const BRAKE_DIST = 12;
+ let target = this.cruiseMps;
+ if (distToStop < BRAKE_DIST) {
+ target = Math.max(0, this.cruiseMps * (distToStop / BRAKE_DIST));
+ }
+ if (this.gapAhead < 4) {
+ if (this.gapAhead < 1) target = 0;
+ else target = Math.min(target, this.speedAhead * (this.gapAhead / 4));
+ }
+
+ const accel = 4, decel = 6;
+ if (this.speed < target) this.speed = Math.min(target, this.speed + accel * dt);
+ else if (this.speed > target) this.speed = Math.max(target, this.speed - decel * dt);
+
+ this.dist += this.speed * dt;
+ if (this.dist >= this.path.totalLength) this.dist -= this.path.totalLength;
+
+ if (distToStop < 0.5 && this.speed < 0.3) {
+ this.state = 'dwell';
+ this.dwellTimer = rand(this.dwellRange[0], this.dwellRange[1]);
+ this.dist = this.stopDists[this.nextStopIdx];
+ this.speed = 0;
+ }
+
+ this._writeMesh();
+ }
+
+ _writeMesh() {
+ this.path.sample(this.dist, _s1);
+ this.mesh.position.set(_s1.x, 0, _s1.z);
+ this.mesh.rotation.y = _s1.heading;
+ }
+}
+
+class AmbientCar {
+ constructor({ lane, path, partnerPath, cruiseMps, mesh }) {
+ this.lane = lane;
+ this.path = path;
+ this.partnerPath = partnerPath || null;
+ this.cruiseMps = cruiseMps;
+ this.mesh = mesh;
+ this.dist = 0;
+ this.speed = cruiseMps * 0.85;
+ this.alive = true;
+ this.gapAhead = Infinity;
+ this.speedAhead = 0;
+ this.laneOffset = 0;
+ this.laneChangeT = 0;
+ this.laneChangeDir = 0;
+ this.laneChangeCooldown = rand(3, 9);
+ }
+
+ step(dt) {
+ let target = this.cruiseMps;
+ if (this.gapAhead < 4) {
+ if (this.gapAhead < 1) target = 0;
+ else target = Math.min(target, this.speedAhead * (this.gapAhead / 4));
+ }
+ const accel = 5, decel = 7;
+ if (this.speed < target) this.speed = Math.min(target, this.speed + accel * dt);
+ else if (this.speed > target) this.speed = Math.max(target, this.speed - decel * dt);
+
+ this.dist += this.speed * dt;
+ if (this.dist >= this.path.totalLength) {
+ this.alive = false;
+ return;
+ }
+
+ this.laneChangeCooldown -= dt;
+ if (this.partnerPath && this.laneChangeT === 0 && this.laneChangeCooldown <= 0) {
+ if (Math.random() < 0.25 * dt) {
+ this.laneChangeT = 0.0001;
+ this.laneChangeDir = this.laneOffset === 0 ? 1 : -1;
+ } else {
+ this.laneChangeCooldown = rand(2, 4);
+ }
+ }
+ if (this.laneChangeT > 0) {
+ this.laneChangeT += dt / 1.5;
+ if (this.laneChangeT >= 1) {
+ this.laneOffset = this.laneChangeDir === 1 ? 1 : 0;
+ this.laneChangeT = 0;
+ this.laneChangeCooldown = rand(8, 18);
+ }
+ }
+
+ this._writeMesh();
+ }
+
+ _writeMesh() {
+ this.path.sample(this.dist, _s1);
+ let x = _s1.x, z = _s1.z;
+ const heading = _s1.heading;
+ if (this.partnerPath && (this.laneOffset > 0 || this.laneChangeT > 0)) {
+ this.partnerPath.sample(this.dist, _s2);
+ let blend = this.laneOffset;
+ if (this.laneChangeT > 0) {
+ const eased = 0.5 - 0.5 * Math.cos(Math.PI * Math.min(1, this.laneChangeT));
+ blend = this.laneChangeDir === 1 ? eased : (1 - eased);
+ }
+ x = x + (_s2.x - x) * blend;
+ z = z + (_s2.z - z) * blend;
+ }
+ this.mesh.position.set(x, 0, z);
+ this.mesh.rotation.y = heading;
+ }
+}
+
+class TrafficSystem {
+ constructor(scene, opts = {}) {
+ this.scene = scene;
+ this.opts = opts;
+ this.routes = [];
+ this.lanes = [];
+ this.buses = [];
+ this.ambientCars = [];
+ this.accumulator = 0;
+ this.simTime = 0;
+ this.spawnTimers = new Map();
+ this._loaded = false;
+ this._byRoute = new Map();
+ this._byLane = new Map();
+ }
+
+ async load(url = '/api/routes') {
+ let data = null;
+ try {
+ const res = await fetch(url, { cache: 'no-store' });
+ if (res.ok) data = await res.json();
+ } catch (_) { /* fall through */ }
+ if (!data || !Array.isArray(data.routes) || data.routes.length === 0) {
+ console.warn('[traffic] route fetch failed; using fallback stub');
+ data = FALLBACK_ROUTE_DATA;
+ }
+ this._build(data);
+ this._loaded = true;
+ return this;
+ }
+
+ _project(lat, lng, bounds, laneZ) {
+ const lonSpan = bounds.east - bounds.west || 1;
+ const latSpan = bounds.north - bounds.south || 1;
+ const u = (lng - bounds.west) / lonSpan;
+ const v = (lat - bounds.south) / latSpan;
+ const x = (u - 0.5) * WORLD_WIDTH;
+ const dz = -(v - 0.5) * WORLD_HEIGHT;
+ return [x, dz + (laneZ || 0)];
+ }
+
+ _build(data) {
+ const bounds = data.bounds || DEFAULT_BOUNDS;
+
+ for (const r of data.routes) {
+ const stops = Array.isArray(r.stops) ? r.stops : [];
+ if (stops.length < 2) continue;
+
+ const polyline = stops.map(s => this._project(s.lat, s.lng, bounds, r.laneZ));
+ const path = new Path(polyline);
+ const stopDists = stops.map((_, i) => path.distanceAtIndex(i));
+
+ const speedMphMin = r.speedMph?.min ?? 25;
+ const speedMphMax = r.speedMph?.max ?? 40;
+ const route = {
+ id: r.id || r.line || `route-${this.routes.length}`,
+ line: r.line || r.short_name || '—',
+ name: r.name || r.long_name || 'Route',
+ color: parseColor(r.color, 0xffb000),
+ path,
+ stops,
+ stopDists,
+ speedMpsMin: speedMphMin * MPH_TO_MPS,
+ speedMpsMax: speedMphMax * MPH_TO_MPS,
+ dwellRange: [r.dwellSec?.min ?? 3, r.dwellSec?.max ?? 5],
+ headwaySec: [r.headwaySec?.min ?? 480, r.headwaySec?.max ?? 900],
+ initialBuses: Math.max(1, r.initialBuses ?? this.opts.busesPerRoute ?? 3)
+ };
+ this.routes.push(route);
+
+ const N = route.initialBuses;
+ const spacing = path.totalLength / N;
+ for (let i = 0; i < N; i++) {
+ const initialDist = (spacing * i) % path.totalLength;
+ let nextIdx = 0;
+ while (nextIdx < stopDists.length && stopDists[nextIdx] < initialDist + 0.01) nextIdx++;
+ if (nextIdx >= stopDists.length) nextIdx = 0;
+
+ const mesh = makeBusMesh(route.color);
+ this.scene.add(mesh);
+ const bus = new Bus({
+ route, path, stopDists,
+ cruiseMps: rand(route.speedMpsMin, route.speedMpsMax),
+ dwellRange: route.dwellRange,
+ mesh,
+ initialDist,
+ initialStopIdx: nextIdx
+ });
+ bus.speed = bus.cruiseMps;
+ this.buses.push(bus);
+ }
+ }
+
+ const laneDefs = (data.ambientLanes && data.ambientLanes.length) ? data.ambientLanes : DEFAULT_AMBIENT_LANES;
+ for (const l of laneDefs) {
+ this.lanes.push({
+ id: l.id,
+ partnerId: l.partnerId || null,
+ path: new Path(l.polyline),
+ speedMps: { min: (l.speedMph?.min ?? 25) * MPH_TO_MPS, max: (l.speedMph?.max ?? 40) * MPH_TO_MPS },
+ spawnSec: { min: l.spawnSec?.min ?? 4, max: l.spawnSec?.max ?? 10 }
+ });
+ }
+ for (const lane of this.lanes) {
+ lane.partner = lane.partnerId ? this.lanes.find(l => l.id === lane.partnerId) : null;
+ this.spawnTimers.set(lane.id, rand(0, lane.spawnSec.max));
+ }
+ }
+
+ _spawnAmbientCar(lane) {
+ const color = CAR_PALETTE[(Math.random() * CAR_PALETTE.length) | 0];
+ const mesh = makeCarMesh(color);
+ this.scene.add(mesh);
+ const cruiseMps = rand(lane.speedMps.min, lane.speedMps.max);
+ const car = new AmbientCar({
+ lane,
+ path: lane.path,
+ partnerPath: lane.partner ? lane.partner.path : null,
+ cruiseMps,
+ mesh
+ });
+ car.speed = cruiseMps * 0.9;
+ this.ambientCars.push(car);
+ }
+
+ _stepCollisions() {
+ this._byRoute.clear();
+ for (const b of this.buses) {
+ let arr = this._byRoute.get(b.route.id);
+ if (!arr) { arr = []; this._byRoute.set(b.route.id, arr); }
+ arr.push(b);
+ }
+ for (const [, arr] of this._byRoute) {
+ arr.sort((a, b) => a.dist - b.dist);
+ for (let i = 0; i < arr.length; i++) {
+ const me = arr[i];
+ const nx = arr[(i + 1) % arr.length];
+ if (nx === me) { me.gapAhead = Infinity; me.speedAhead = 0; continue; }
+ let gap = nx.dist - me.dist;
+ if (gap <= 0) gap += me.path.totalLength;
+ me.gapAhead = gap;
+ me.speedAhead = nx.speed;
+ }
+ }
+
+ this._byLane.clear();
+ for (const c of this.ambientCars) {
+ let arr = this._byLane.get(c.lane.id);
+ if (!arr) { arr = []; this._byLane.set(c.lane.id, arr); }
+ arr.push(c);
+ }
+ for (const [, arr] of this._byLane) {
+ arr.sort((a, b) => a.dist - b.dist);
+ for (let i = 0; i < arr.length; i++) {
+ const me = arr[i];
+ const nx = arr[i + 1];
+ if (nx) { me.gapAhead = nx.dist - me.dist; me.speedAhead = nx.speed; }
+ else { me.gapAhead = Infinity; me.speedAhead = 0; }
+ }
+ }
+ }
+
+ _step(dt) {
+ this.simTime += dt;
+
+ for (const lane of this.lanes) {
+ let t = (this.spawnTimers.get(lane.id) || 0) - dt;
+ if (t <= 0) {
+ const headOK = !this.ambientCars.some(c => c.lane === lane && c.dist < 6);
+ if (headOK) {
+ this._spawnAmbientCar(lane);
+ t = rand(lane.spawnSec.min, lane.spawnSec.max);
+ } else {
+ t = 0.5;
+ }
+ }
+ this.spawnTimers.set(lane.id, t);
+ }
+
+ this._stepCollisions();
+ for (const b of this.buses) b.step(dt);
+ for (const c of this.ambientCars) c.step(dt);
+
+ for (let i = this.ambientCars.length - 1; i >= 0; i--) {
+ if (!this.ambientCars[i].alive) {
+ this.scene.remove(this.ambientCars[i].mesh);
+ this.ambientCars.splice(i, 1);
+ }
+ }
+ }
+
+ // Called once per render frame with the wall-clock delta. Steps the
+ // simulation in fixed 1/60 s ticks regardless of render rate.
+ update(dt) {
+ if (!this._loaded) return;
+ this.accumulator += Math.min(dt, 0.25);
+ let steps = 0;
+ while (this.accumulator >= FIXED_STEP && steps < MAX_STEPS_PER_FRAME) {
+ this._step(FIXED_STEP);
+ this.accumulator -= FIXED_STEP;
+ steps++;
+ }
+ if (this.accumulator > FIXED_STEP * MAX_STEPS_PER_FRAME) {
+ this.accumulator = FIXED_STEP;
+ }
+ }
+
+ dispose() {
+ for (const b of this.buses) this.scene.remove(b.mesh);
+ for (const c of this.ambientCars) this.scene.remove(c.mesh);
+ this.buses = [];
+ this.ambientCars = [];
+ this.routes = [];
+ this.lanes = [];
+ this.spawnTimers.clear();
+ }
+
+ stats() {
+ return {
+ routes: this.routes.length,
+ buses: this.buses.length,
+ ambientCars: this.ambientCars.length,
+ simTime: this.simTime
+ };
+ }
+}
+
+export async function createTraffic(scene, opts = {}) {
+ const sys = new TrafficSystem(scene, opts);
+ await sys.load(opts.dataUrl || '/api/routes');
+ return sys;
+}
+
+export default createTraffic;
← 6a8f84b add /models-preview.html: orbit-camera grid of all 8 sourced
·
back to Ventura Bus 3d
·
add /streetview.html: animated Street View drive down Ventur e0acce7 →