<!-- SOLUV · part 449 -->
aveAt) { // placeholder: marks the leave and links the whole recorded part (join -> leave) on the final line
    if (!ss || ss.leaveAt) return null; const m = ss.last; if (!m) return null;
    endTransmission(m); ss.leaveAt = leaveAt; m.leave = ss; m.recording = { recordingId: ss.recordingId, from: ss.joinAt, to: ss.leaveAt, src: null };
    return m;
  }
  function endTransmission(m) {
    if (!m || !m.live) return false;
    m.live = false; m.lastFrame = { camId: m.camId, still: `${m.camId}/frame@${m.i}`, src: null }; m.lastClip = { camId: m.camId, clip: `${m.camId}/clip@${m.i}`, seconds: 6, src: null };
    return true;
  }
  function camFor(p, spk) { // one camera slot per live speaker, up to the pillar's maxCams
    p.camMap ||= new Map(); if (!p.camMap.has(spk)) { if (p.camMap.size >= p.maxCams) return null; p.camMap.set(spk, `cam-${p.id}-${p.camMap.size}`); p.cams = p.camMap.size; p.labelV++; }
    return p.camMap.get(spk);
  }
  function ingestTile(json, t) {
    const spos = [], scol = [], ssz = [], lpos = [], rpos = [];
    for (const tp of json.pages) {
      const p = pages[tp.id]; if (!p.on) continue; let spk = 'Narrator';
      p.msgs = tp.m.map(([x, y, n, sl, g, text], i) => {
        if (sl) spk = text.slice(0, sl).trim();
        const body = text.slice(sl);
        stats.wordsLoaded += (body.match(/\S+/g) || []).length;
        const w = wp(p, x, y);
        // reserved per-message video model: a tagged line from a speaker in a video room carries a camera slot that scrolls with the text
        const onCam = p.mode === 'video' && sl > 0 && spk !== 'Narrator', camId = onCam ? camFor(p, spk) : null;
        return { i, lx: x, ly: y, x: w.x, y: w.y, z: w.z, n, sl, g, text, spk, live: !!camId, camId, lastFrame: null, lastClip: null, id: tp.id * 100000 + i };
      });
      // archived transmissions have ended: every on-camera line keeps its last-frame still + short last clip,
      // except each speaker's newest line in the room's final stretch, which is still transmitting
      if (p.mode === 'video') { const tail = new Set(); for (let j = p.msgs.length - 1, k = 0; j >= 0 && k < 3; j--) { const m = p.msgs[j]; if (m.camId && !tail.has(m.spk)) { tail.add(m.spk); k++; m.keep = true; } }
        for (const m of p.msgs) if (m.camId && !m.keep) endTransmission(m); else delete m.keep;
        // per-speaker sessions (join -> leave). The sample data has no timestamps, so join/leave are message positions.
        // A speaker who is not in the live tail has left: their final line carries the leave marker + link to the whole recording.
        for (const m of p.msgs) { if (!m.camId) continue; const ss = sessionFor(p, m.spk, m.camId, { msg: m.i }); ss.last = m; }
        for (const ss of p.sessions.values()) if (!ss.last.live && !ss.leaveAt) leaveRoom(p, ss, { msg: ss.last.i }); }
      stats.messagesLoaded += p.msgs.length;
      let prev = null;
      for (const m of p.msgs) {
        const s0 = wp(p, 0.22, m.ly + 0.62, 0.1), sx = [s0.x, s0.y, s0.z];
        if (m.g) { spos.push(...sx); scol.push(...C.goldTag); ssz.push(1.0); }
        if (m.sl && m.i) wrapRule(rpos, p, m.ly - L.spkGap * 0.5, 0.06);
        prev = sx;
      }
      p.labelV++;
    }
    const sg = new THREE.BufferGeometry(); sg.setAttribute('position', new THREE.Float32BufferAttribute(spos, 3));
    sg.setAttribute('color', new THREE.Float32BufferAttribute(scol, 3)); sg.setAttribute('size', new THREE.Float32BufferAttribute(ssz, 1));
    const pts = new THREE.Points(sg, ptsMat); pts.frustumCulled = false;
    const lg = new THREE.BufferGeometry(); lg.setAttribute('position', new THREE.Float32BufferAttribute(lpos, 3));
    const ln = new THREE.LineSegments(lg, linkMat); ln.frustumCulled = false;
    const rg = new THREE.BufferGeometry(); rg.setAttribute('position', new THREE.Float32BufferAttribute(rpos, 3));
    const ru = new THREE.LineSegments(rg, ruleMat); ru.frustumCulled = false;
    pts.renderOrder = ln.renderOrder = ru.renderOrder = 3;
    if (t.obj) { world.remove(t.obj.pts, t.obj.ln, t.obj.ru); t.obj.pts.geometry.dispose(); t.obj.ln.geometry.dispose(); t.obj.ru.geometry.dispose(); }
    t.obj = { pts, ln, ru }; world.add(pts, ln, ru); world.updateMatrixWorld(true); applyMinimum(ru);
    t.state = 2; stats.tilesLoaded++;
    if (stats.tilesLoaded === stats.tilesTotal) stats.tAllTilesMs = performance.now() - t0;
    dirty = true;
  }
  function loadAllTiles() { pinAll = true; // search pins everything it loads (see README: weak point for huge N)
    for (const t of tiles.values()) if (!t.state) requestTile(t.id, false);
    return new Promise(res => { const chk = () => (stats.tilesLoaded >= stats.tilesTotal ? res() : setTimeout(chk, 50)); chk(); });
  }

  /* ---------------- camera state & controls ---------------- */
  const st = { tx: 0, ty: 0, tz: 0, dist: 1000, yaw: 0, pitch: 0 };
  let vw = 1, vh = 1, focal = 1, dirty = true, anim = null, continuous = false;
  const qTmp = new THREE.Quaternion(