<!-- SOLUV · part 474 -->
) + '  \u00b7  CAMS ' + meta.cams + '/' + meta.maxCams + '  \u269c', W, 6 * ppe);
    const n = meta.maxCams, cols = 10, rows = Math.ceil(n / cols), sw2 = 5 * ppe, sh = 3.4 * ppe, gap = 0.8 * ppe;
    const bx = W - cols * (sw2 + gap) + gap, by = 9 * ppe;
    for (let i = 0; i < n; i++) {
      const x = bx + (i % cols) * (sw2 + gap), y = by + Math.floor(i / cols) * (sh + gap);
      ctx.strokeStyle = 'rgba(95,244,255,0.6)'; ctx.lineWidth = Math.max(1, 0.25 * ppe); ctx.strokeRect(x, y, sw2, sh);
      if (i < meta.cams) { ctx.fillStyle = 'rgba(255,95,109,0.85)'; ctx.fillRect(x + 0.5 * ppe, y + 0.5 * ppe, sw2 - ppe, sh - ppe); }
    }
    ctx.font = FONT(false, 3 * ppe); ctx.fillStyle = COL.dim;
    ctx.fillText(S.msgs.length + ' messages  \u00b7  ' + S.lines.length + ' lines  \u00b7  1 column  \u00b7  ' + (WIDTH / BLOCK).toFixed(0) + '\u00d7' + ((S.Hem * EM) / BLOCK).toFixed(1) + ' gold blocks', W, by + rows * (sh + gap) + 4 * ppe);
    ctx.textAlign = 'left';
  }

  /* ----- LOD + on-demand loading, run once per rendered frame (works inside any host loop) ----- */
  const _f = new T.Frustum(), _m = new T.Matrix4(), _v = new T.Vector3(), _s = new T.Sphere(), _b = new T.Box3(), _sz = new T.Vector2();
  function ppeAt(depth, camera, cssH, worldEm) {
    if (camera.isOrthographicCamera) return worldEm * cssH * camera.zoom / (camera.top - camera.bottom);
    return worldEm * cssH / (2 * Math.tan(camera.fov * Math.PI / 360) * Math.max(depth, 1e-3));
  }
  function tierFor(devPpe) { for (const t of TIERS) if (t >= devPpe * 0.85) return t; return TIERS[TIERS.length - 1]; }
  function update(camera, renderer) {
    if (!camera || !renderer) return;
    const frame = renderer.info && renderer.info.render ? renderer.info.render.frame : -1;
    if (frame !== -1 && frame === S.frame && camera === S.cam) return; S.frame = frame; S.cam = camera; lastRenderer = renderer;
    root.updateMatrixWorld(true); camera.updateMatrixWorld();
    _m.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); _f.setFromProjectionMatrix(_m);
    const cssH = (renderer.domElement && renderer.domElement.clientHeight) || renderer.getSize(_sz).y, pr = renderer.getPixelRatio();
    const worldEm = EM * root.matrixWorld.getMaxScaleOnAxis();
    const camPos = camera.getWorldPosition(_v.clone());
    if (glass) _b.setFromObject(glass);
    const wallPpe = ppeAt(_b.distanceToPoint(camPos), camera, cssH, worldEm);
    S.wallPpe = wallPpe;
    // load once the wall is a real thing on screen: its face at least 120 px across its longer side (the 600-tall glass seen whole counts)
    const seen = (wallPpe >= LOAD_PPE || wallPpe * Math.max(Wem, S.Hem) >= 120) && _f.intersectsBox(_b);
    if (!S.started && source && seen) startSafe();
    if (!fontPromise && seen) loadFont(fontUrl).then(() => { for (const t of S.tiles.values()) t.stale = t.tier > 0; drawHeaderSoon(); root.dispatchEvent({ type: 'change' }); });
    const maxTex = renderer.capabilities.maxTextureSize || 4096;
    const queue = [];
    const all = [...S.tiles.values()]; if (S.header) all.push(S.header);
    let readable = 0, inView = 0;
    for (const t of all) {
      t.mesh.getWorldPosition(_s.center); _s.radius = Math.hypot(t.w, t.h) * 0.5 * worldEm;
      t.inView = _f.intersectsSphere(_s);
      if (!t.inView || t.blank) continue; inView++;
      const depth = Math.max(0.01, _s.center.distanceTo(camPos) - _s.radius * 0.6);
      const css = ppeAt(depth, camera, cssH, worldEm); t.ppe = css; if (css >= READABLE_PPE) readable++;
      if (!t.header && css * pr < 1.5) continue;            // under 1.5 device px per em a line is a smudge: the glass glow shows activity instead (data thrift)
      let need = tierFor(css * pr);
      while (need > 4 && t.w * need > Math.min(maxTex, 4096)) need /= 2;
      if (t.stale || t.tier < need) queue.push({ t, need, pri: (t.tier === 0 ? 1000 : 0) + (t.stale ? 500 : 0) + need - depth * 0.01 });
    }
    S.inView = inView; S.readable = readable;
    queue.sort((a, b) => b.pri - a.pri);
    const t0 = performance.now(); let done = 0;
    for (const q of queue) { if (done > 0 && performance.now() - t0 > 10) break; renderTile(q.t, q.need); done++; }
    S.pending = queue.length - done;
    if (S.texBytes > TEX_BUDGET) {                        // evict the biggest textures that are off screen
      const off = all.filter(t => !t.inView && t.bytes).sort((a, b) => b.bytes - a.bytes);
      for (const t of off) { if (S.texBytes <= TEX_BUDGET * 0.8) break; freeTile(t); t.stale = false; }
    }
    if (S.pending > 0 || done > 0) root.dispatchEvent({ type: 'change' });
  }

  /* ----- live data: room API (GET + SSE), static test file, or none (visible) ----- */
  function setStatus(state, text) { S.status = { state, text }; drawHeaderSoon(); placeTiles(); activity(); root.dispatchEvent({ type: 'status', state, text }); root.dispatchEvent({ type: 'change' }); }
  function normalize(m) {
    if (m.body !== undefined) {   //