← back to Ventura Bus 3d

public/js/scene.js

613 lines

// ventura-bus-3d — Task #3: base Three.js scene
//
// 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.
//
// 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  (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';

// ---- 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      = 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');

  // ---- Renderer -----------------------------------------------------------
  const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, pixelRatioCap));
  renderer.setSize(container.clientWidth || window.innerWidth, container.clientHeight || window.innerHeight);
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.0;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  container.appendChild(renderer.domElement);

  // ---- Scene + fog --------------------------------------------------------
  const scene = new THREE.Scene();
  if (useFog) scene.fog = new THREE.Fog(fogColor, fogNear, fogFar);

  // ---- 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(20000);
  scene.add(sky);
  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.7);
  hemiLight.position.set(0, 80, 0);
  scene.add(hemiLight);

  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  = 800;
  const SHADOW_HALF = 250;
  dirLight.shadow.camera.left   = -SHADOW_HALF;
  dirLight.shadow.camera.right  =  SHADOW_HALF;
  dirLight.shadow.camera.top    =  SHADOW_HALF;
  dirLight.shadow.camera.bottom = -SHADOW_HALF;
  dirLight.shadow.bias = -0.0003;
  scene.add(dirLight);
  scene.add(dirLight.target);

  // ---- 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 })
  );
  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);
    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;
  }

  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;
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    renderer.setSize(w, h);
  }
  window.addEventListener('resize', onResize);

  // ---- Render loop -------------------------------------------------------
  let rafId = 0;
  let running = false;
  const tickListeners = new Set();

  function render() { renderer.render(scene, camera); }
  function loop(t) {
    rafId = requestAnimationFrame(loop);
    controls.update();
    for (const fn of tickListeners) fn(t);
    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 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 dispose() {
    stop();
    window.removeEventListener('resize', onResize);
    controls.dispose?.();
    renderer.dispose();
    if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
    scene.traverse(obj => {
      if (obj.geometry) obj.geometry.dispose?.();
      if (obj.material) {
        const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
        mats.forEach(m => {
          for (const k of Object.keys(m)) { const v = m[k]; if (v && v.isTexture) v.dispose(); }
          m.dispose?.();
        });
      }
    });
  }

  return {
    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 },
  };
}

// ---------------------------------------------------------------------------
// 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');

  ctx.fillStyle = '#3a3a3d';
  ctx.fillRect(0, 0, TILE_LEN, TILE_W);
  const noise = ctx.createImageData(TILE_LEN, TILE_W);
  for (let i = 0; i < noise.data.length; i += 4) {
    const v = 40 + Math.floor(Math.random() * 30);
    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.fillStyle = '#f0efe6';
  ctx.fillRect(0, 6, TILE_LEN, 3);
  ctx.fillRect(0, TILE_W - 9, TILE_LEN, 3);

  ctx.fillStyle = '#dcd8c8';
  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);

  ctx.fillStyle = '#e9c531';
  const cy = TILE_W / 2;
  ctx.fillRect(0, cy - 4, TILE_LEN, 3);
  ctx.fillRect(0, cy + 1, TILE_LEN, 3);

  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  tex.wrapS = THREE.RepeatWrapping;
  tex.wrapT = THREE.ClampToEdgeWrapping;
  tex.anisotropy = 8;
  return tex;
}

// 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;
  };
}