<!-- SOLUV · part 661 -->
ing++; } const a = Math.PI + (k / (ring * 8)) * Math.PI * 2, r = 9 * BLOCK * ring; pos = [Math.round(Math.sin(a) * r / BLOCK) * BLOCK, 0, Math.round(Math.cos(a) * r / BLOCK) * BLOCK]; rot = Math.atan2(-pos[0], -pos[2]); }
    const o = { THREE, room: n, position: pos, rotationY: rot, avatar: true };
    const cap = parseFloat(qs.get('cap')); if (n === 1 && cap > 0) Object.assign(o, { minHeight: cap, maxHeight: cap });   // test: a small glass to see the archive
    if (n === 1 && test) Object.assign(o, { source: { type: 'static', url: new URL('test-lines.json', import.meta.url).href, repeat: Math.max(1, parseInt(test, 10) || 1), live: ['Playa', 'Solid'] }, testLabel: 'TEST TEXT (sample lines, not the live lobby)', meta: { mode: 'video', access: 'video', maxCams: 20, cams: 2 } });
    const w = createChatWall(o);
    w.addEventListener('change', () => { need = true; });
    w.addEventListener('status', () => refreshStatus());
    scene.add(w); walls.push(w);
  }
  let current = walls[(deep || 1) - 1];

  /* ---------- camera: target + distance + yaw + pitch; no ceiling ---------- */
  const st = { tx: 0, ty: 4, tz: 0, dist: 60, yaw: 0, pitch: 0.05 };
  const CAM_RADIUS = 0.35; const lastCam = new THREE.Vector3(0, 30, 400);
  let need = true, anim = null, glide = null;
  const cssH = () => canvas.clientHeight || innerHeight, cssW = () => canvas.clientWidth || innerWidth;
  const tanH = () => Math.tan(camera.fov * Math.PI / 360);
  const hTan = () => tanH() * Math.max(0.2, (cssW() - 72)) / cssH();
  function applyCamera() {
    st.pitch = Math.max(-0.2, Math.min(1.45, st.pitch)); st.dist = Math.max(0.35, Math.min(9000, st.dist));
    st.tx = Math.max(-MAP / 2, Math.min(MAP / 2, st.tx)); st.tz = Math.max(-MAP / 2, Math.min(MAP / 2, st.tz)); st.ty = Math.max(0.05, st.ty); // up: unlimited
    const cp = Math.cos(st.pitch);
    camera.position.set(st.tx + Math.sin(st.yaw) * cp * st.dist, st.ty + Math.sin(st.pitch) * st.dist, st.tz + Math.cos(st.yaw) * cp * st.dist);
    if (camera.position.y < 0.15) camera.position.y = 0.15;
    // SOLID walls: the camera stops 0.35 in front of a face, it never clips inside or flies through
    for (const w of walls) { const r = w.wall.resolve(camera.position, CAM_RADIUS, lastCam); camera.position.copy(r); }
    lastCam.copy(camera.position);
    camera.lookAt(st.tx, st.ty, st.tz);
    sky.position.copy(camera.position);
    need = true;
  }
  const distForPpe = ppe => EM * cssH() / (2 * tanH() * ppe);   // camera distance that shows one em at ppe CSS px
  function frameOf(w) { const ry = w.rotation.y; return { o: w.position.clone(), ry, right: new THREE.Vector3(Math.cos(ry), 0, -Math.sin(ry)), info: w.wall.info() }; }
  function at(w, localX, localY, dist, dyaw = 0, pitch = 0.02) { const f = frameOf(w); const p = f.o.clone().addScaledVector(f.right, localX); return { tx: p.x, ty: localY, tz: p.z, dist, yaw: f.ry + dyaw, pitch }; }
  const views = {
    map: () => ({ tx: 0, ty: 0, tz: 0, dist: 4600, pitch: 1.2, yaw: 0.2 }),
    wide: (w = current) => { const i = w.wall.info(); return at(w, 0, i.heightWorld * 0.4, i.widthWorld * 3.0, 0.65, 0.3); },
    far: (w = current) => { const i = w.wall.info(); const half = i.widthWorld / 2 + 2; /* + the avatar beside it */ const d = Math.max((half * 1.04) / hTan(), ((i.heightWorld + 1) / 2 * 1.08) / tanH()); return at(w, -1, (i.heightWorld + 0.4) * 0.5, d); },
    mid: (w = current) => { const t = w.wall.textRect(); const d = distForPpe(3.6); const vis = 2 * d * tanH(); return at(w, w.wall.colCenterX(1) + 1.5, Math.max(vis * 0.5, t.y1 - vis * 0.42), d); },
    near: (w = current) => { const i = w.wall.info(); const left = -i.widthWorld / 2 - 2.2, right = w.wall.colCenterX(0) + L.COL_W * EM / 2 + 0.2; const d = Math.max(distForPpe(10.5), ((right - left) / 2) / hTan()); const vis = 2 * d * tanH(); return at(w, (left + right) / 2, vis * 0.43, d); },
    live: (w = current) => { const a = w.wall.lineAnchor(); if (!a) return views.far(w); const d = distForPpe(12); const vis = 2 * d * tanH(); return at(w, a.local.x, Math.max(a.local.y - vis * 0.08, vis * 0.45), d); },
  };
  let userMoved = false;   // once someone moves, the start view never re-aims over them
  function flyTo(v, ms = 1400, auto = false) { if (!auto) userMoved = true; glide = null; tyGoal = null; flingTy = 0; const a = { ...st }; anim = { a, b: v, t0: performance.now(), ms }; need = true; }
  function stopMotion() { anim = null; glide = null; tyGoal = null; flingTy = 0; }

  /* RIDE: wheel / trackpad / keys / soluvKeys.scroll move up and down the tower smoothly (eased to a goal), a swipe keeps
     its momentum (fling); both are clamped to the current wall: from its foot to its top. */
  let tyGoal = null, flingTy = 0, panV = 0, panT = 0;
  const pxWorld = () => 2 * st.dist * tanH() / cssH();                 // world units per CSS px at the target (screen speed)
  const wallTop = (w = current) => { const i = w.wall.info(); return i.heightWorld + i.plin