<!-- SOLUV · part 456 -->
[o + 2] = c.z;
        D[o + 3] = g[s]; D[o + 4] = g[s + 1]; D[o + 5] = g[s + 2]; D[o + 6] = g[s + 3];
        D[o + 7] = g[s + 4]; D[o + 8] = g[s + 5]; D[o + 9] = g[s + 6]; D[o + 10] = g[s + 7];
        D[o + 11] = g[s + 8]; D[o + 12] = g[s + 9]; D[o + 13] = g[s + 10]; D[o + 14] = c.a;
        D[o + 15] = scale; D[o + 16] = g[s + 11];
        D[o + 17] = ax; D[o + 18] = ay; D[o + 19] = az; D[o + 20] = ux; D[o + 21] = uy; D[o + 22] = uz;
        B.n++;
      }
      labels++; readableWords += lay.words;
      if (c.a >= 0.5 && lay.n) minEm = Math.min(minEm, (c.face ? c.read : c.em) * lay.minScale);
    }
    atlas.flush();
    for (const B of [faceBatch, capBatch]) { B.g.instanceCount = B.n; B.ib.clearUpdateRanges(); B.ib.addUpdateRange(0, Math.max(1, B.n * STRIDE)); B.ib.needsUpdate = true; }
    n = faceBatch.n + capBatch.n;
    let wv = 0; for (const [pg] of pageVis) wv += pg.words; stats.wordsInView = wv; stats.wallsInView = pageVis.length;
    stats.glyphs = n; stats.labels = labels; stats.droppedForBudget = dropped; stats.maxGlyphs = Math.max(stats.maxGlyphs, n);
    stats.candidates = cands.length; stats.wordsReadable = readableWords; stats.minTextPx = minEm === Infinity ? null : +minEm.toFixed(1);
  }

  /* ---------------- resize & loop ---------------- */
  function resize() {
    const r = container.getBoundingClientRect(); vw = Math.max(1, r.width); vh = Math.max(1, r.height);
    renderer.setSize(vw, vh, false); camera.aspect = vw / vh; camera.updateProjectionMatrix();
    focal = (vh / 2) / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
    ptsMat.uniforms.uFocal.value = focal * WS; glowMat.uniforms.uFocal.value = focal * WS; hitMarker.material.uniforms.uFocal.value = focal * WS;
    gw = Math.ceil(vw / CELL) + 1; gh = Math.ceil(vh / CELL) + 1; grid = new Uint8Array(gw * gh);
    const sb = wrap.querySelector('.ws-search').getBoundingClientRect();
    reservedTop = sb.bottom - r.top + 4;
    const cr = wrap.querySelector('.ws-ctrls').getBoundingClientRect(); ctrlRect.x0 = cr.left - r.left - 4; ctrlRect.y0 = cr.top - r.top - 4;
    applyCamera();
  }
  const ro = new ResizeObserver(resize); ro.observe(container);
  resize(); Object.assign(st, homeState()); applyCamera();

  let frameHook = null, lastT = performance.now(), fpsAcc = 0, fpsN = 0, fps = 0, alive = true;
  function frame(now) {
    if (!alive) return;
    requestAnimationFrame(frame);
    const fdt = Math.min(0.05, (now - (frame.prev || now)) / 1000); frame.prev = now;
    if (anim) stepAnim(now); else { stepDrift(fdt); stepMomentum(fdt); followPagePlane(); }
    stepActivity(fdt, now);
    if (highlight) { hitMarker.material.uniforms.uMax.value = 24 + 10 * Math.sin(now / 180); dirty = true; }
    if (!dirty && !continuous) { lastT = now; return; }
    dirty = false;
    const tb = performance.now();
    buildText();
    renderer.render(scene, camera);
    const dt = now - lastT; lastT = now; stats.frames++;
    fpsAcc += dt; fpsN++; if (fpsAcc > 500) { fps = 1000 * fpsN / fpsAcc; fpsAcc = 0; fpsN = 0; }
    if (!stats.tFirstFrameMs) stats.tFirstFrameMs = performance.now() - t0;
    if (!stats.tVisibleTilesMs && queue.length === 0 && inflight === 0 && stats.tilesLoaded > 0) stats.tVisibleTilesMs = performance.now() - t0;
    if (frameHook) frameHook(dt, performance.now() - tb);
    if (opts.hud) hud.textContent = `${fps.toFixed(0)} fps · ${stats.glyphs} glyphs · ${stats.labels} labels · tiles ${stats.tilesLoaded}/${stats.tilesTotal} · ${stats.wordsLoaded.toLocaleString()} words · d=${st.dist.toFixed(0)}`;
  }
  requestAnimationFrame(frame);
  // background-stream every tile once the first view is up, so search covers all text
  if (opts.prefetchAll === true) setTimeout(() => loadAllTiles(), opts.prefetchDelay ?? 1200);

  /* ---------------- search ---------------- */
  let results = [], ri = 0;
  function nearViewFor(m, p) { // face-on, near text ~15 CSS px, message block centred under the search bar
    const px = 15, hText = m.n * L.lineH;
    const ly = m.ly + Math.min(hText, (vh - reservedTop) / px * 0.7) / 2 - (reservedTop / 2) / px;
    const t = wp(p, p.w / 2, ly, 0);
    return { tx: t.x, ty: t.y, tz: Math.max(1, t.z), dist: focal / px, pitch: Math.PI / 2, yaw: yawFor(p) };
  }
  async function search(q) {
    q = (q || '').trim(); if (!q) return [];
    statusEl.textContent = stats.tilesLoaded < stats.tilesTotal ? `Loading text… ${stats.tilesLoaded}/${stats.tilesTotal} regions` : 'Searching…';
    await loadAllTiles();
    const t = performance.now();
    const re = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+'), 'i'), ql = q.toLowerCase();
    const spk = [], heads = [], txt = [];
    for (const p of sel) {
      if (!p.msgs) continue;
      if (re.test(p.hl) || p.kw.some(k => k.toLowerCase() === ql)) heads.push({ type: 'page', p });
      for (const m of p.msgs) {
        if (m.spk.toLowerCase() === ql || (m.sl && re.test(m.spk))) { spk.push({ type: 'msg', p, 