← 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
M public/js/scene.jsM public/scene-demo.html
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 →