<!-- SOLUV · part 450 -->
), qY = new THREE.Quaternion(), qX = new THREE.Quaternion(), vTmp = new THREE.Vector3();
  const Z = new THREE.Vector3(0, 0, 1), X = new THREE.Vector3(1, 0, 0);
  const camL = new THREE.Vector3(); // camera position in layout units
  const SWS = new THREE.Matrix4().makeScale(WS, WS, WS);
  function applyCamera() {
    qY.setFromAxisAngle(Z, st.yaw); qX.setFromAxisAngle(X, st.pitch); qTmp.copy(qY).multiply(qX);
    vTmp.set(0, 0, st.dist).applyQuaternion(qTmp);
    camera.position.set((st.tx + vTmp.x) * WS, (st.ty + vTmp.y) * WS, (st.tz + vTmp.z) * WS); camL.set(st.tx + vTmp.x, st.ty + vTmp.y, st.tz + vTmp.z); camera.quaternion.copy(qTmp);
    const n = Math.max(0.5, st.dist * 0.004) * WS, f = Math.max(60000, st.dist * 40) * WS; // depth range follows altitude (unlimited sky)
    if (Math.abs(camera.near - n) > n * 0.2 || camera.far < f * 0.5 || camera.far > f * 2) { camera.near = n; camera.far = f; camera.updateProjectionMatrix(); }
    camera.updateMatrixWorld(); dirty = true;
  }
  function homeState() { // the whole castle from above, tilted so pillar caps and courts read
    if (castle.single) { // whole column + its headline cap between the search bar and the badge
      const p = sel[0], t = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)), usable = Math.max(0.5, (vh - 230) / vh), H = p.h + p.capH;
      const dist = (H + 70) / 2 / t / usable + p.d, ppu = vh / (2 * t * dist); // px per unit at the pillar
      return { tx: p.bx, ty: p.by, tz: H / 2 + (170 - 40) / 2 / ppu, dist, pitch: Math.PI / 2 - 0.04, yaw: Math.atan2(p.nx, -p.ny) }; }
    const t = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * Math.min(1, camera.aspect);
    return { tx: castle.cx, ty: castle.cy - castle.radius * 0.18, tz: 0, dist: (castle.radius + 160) / t * 1.02, yaw: 0, pitch: 0.62 };
  }
  const ndc = new THREE.Vector3(), fwd = new THREE.Vector3();
  function screenToPlane(sx, sy) { // plane through the pivot, facing the camera: pan/zoom work from any angle
    ndc.set(sx / vw * 2 - 1, -(sy / vh) * 2 + 1, 0.5).unproject(camera);
    const dir = ndc.sub(camera.position).normalize(); fwd.set(0, 0, -1).applyQuaternion(camera.quaternion);
    const den = dir.dot(fwd); if (den < 1e-3) return null;
    const s = st.dist / den; return { x: camL.x + dir.x * s, y: camL.y + dir.y * s, z: camL.z + dir.z * s };
  }
  const DMIN = 4, DMAX = 1e8; // no ceiling: the camera can rise as high as the wall grows
  function zoomAt(sx, sy, f) {
    const a = screenToPlane(sx, sy); st.dist = clamp(st.dist * f, DMIN, DMAX); applyCamera();
    const b = screenToPlane(sx, sy); if (a && b) { st.tx += a.x - b.x; st.ty += a.y - b.y; st.tz = Math.max(0, st.tz + a.z - b.z); applyCamera(); }
  }
  function panBy(px0, py0, px1, py1) {
    const a = screenToPlane(px0, py0), b = screenToPlane(px1, py1);
    if (a && b) { st.tx += a.x - b.x; st.ty += a.y - b.y; st.tz = Math.max(0, st.tz + a.z - b.z); applyCamera(); }
  }
  const pointers = new Map(); let gesture = null;
  // momentum: pan velocity (world units/s) and zoom velocity (log-dist/s) keep gliding after release and decay smoothly
  const mom = { vx: 0, vy: 0, vz: 0, zx: 0, zy: 0, lastT: 0, lastX: 0, lastY: 0 };
  let drift = null; // continuous camera motion of the Glide / Orbit views
  function stopMotion() { anim = null; drift = null; mom.vx = mom.vy = mom.vz = 0; }
  function trackPan() {
    const now = performance.now(), dt = Math.max(1, now - mom.lastT) / 1000;
    if (mom.lastT && dt < 0.1) { const k = 0.35; mom.vx = mom.vx * (1 - k) + k * (st.tx - mom.lastX) / dt; mom.vy = mom.vy * (1 - k) + k * (st.ty - mom.lastY) / dt; }
    mom.lastT = now; mom.lastX = st.tx; mom.lastY = st.ty;
  }
  canvas.addEventListener('pointerdown', e => {
    canvas.setPointerCapture(e.pointerId); pointers.set(e.pointerId, { x: e.offsetX, y: e.offsetY, b: e.button, shift: e.shiftKey });
    stopMotion(); mom.lastT = 0; gesture = null; if (document.activeElement === qEl) qEl.blur();
    if (pointers.size === 1) tap = { x: e.offsetX, y: e.offsetY, t: performance.now() }; else tap = null;
  });
  let tap = null;
  canvas.addEventListener('pointermove', e => {
    const p = pointers.get(e.pointerId); if (!p) return;
    const nx = e.offsetX, ny = e.offsetY;
    if (pointers.size === 1) {
      if (p.b === 2 || p.shift) { st.yaw -= (nx - p.x) * 0.006; st.pitch = clamp(st.pitch + (ny - p.y) * 0.006, 0, 1.75); applyCamera(); }
      else { panBy(p.x, p.y, nx, ny); trackPan(); }
      p.x = nx; p.y = ny;
    } else if (pointers.size === 2) {
      const [a, b] = [...pointers.values()];
      const before = { mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2, d: Math.hypot(a.x - b.x, a.y - b.y), ang: Math.atan2(b.y - a.y, b.x - a.x) };
      p.x = nx; p.y = ny;
      const after = { mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2, d: Math.hypot(a.x - b.x, a.y - b.y), ang: Math.atan2(b.y - a.y, b.x - a.x) };
      panBy(before.mx, before.my, after.mx, after.my);
      if (before.d > 10 && after.d > 10) {
    