<!-- SOLUV · part 454 -->
low = rg[3]; bold = rg[4] || bold; }
          const g = atlas.get(ch, bold);
          if (!g.empty) glyphs.push([x + g.x0 * r.scale, base + g.yB * r.scale, x + g.x1 * r.scale, base + g.yT * r.scale, g.u0, g.vB, g.u1, g.vT, col[0], col[1], col[2], glow]);
          x += g.adv * r.scale; ci += ch.length;
        }
        if (center) for (let i = start; i < glyphs.length; i++) { glyphs[i][0] -= x / 2; glyphs[i][2] -= x / 2; }
        maxW = Math.max(maxW, x); Y -= r.lh * r.scale; off += line.length + 1;
      }
    }
    const g = new Float32Array(glyphs.length * 12); glyphs.forEach((a, i) => g.set(a, i * 12));
    lay = { g, n: glyphs.length, w: maxW, h: -Y, center, minScale, words: runs.reduce((a, r) => a + (r.text.match(/\S+/g) || []).length, 0) };
    layoutCache.set(key, lay);
    if (layoutCache.size > LRU) layoutCache.delete(layoutCache.keys().next().value);
    stats.atlasGlyphs = atlas.next;
    return lay;
  }

  // screen-space collision grid
  const CELL = 6; let gw = 1, gh = 1, grid = new Uint8Array(1);
  function gridFits(x0, y0, x1, y1) {
    const a = Math.max(0, (x0 / CELL) | 0), b = Math.min(gw - 1, (x1 / CELL) | 0), c = Math.max(0, (y0 / CELL) | 0), d = Math.min(gh - 1, (y1 / CELL) | 0);
    for (let y = c; y <= d; y++) for (let x = a; x <= b; x++) if (grid[y * gw + x]) return false;
    return true;
  }
  function gridMark(x0, y0, x1, y1) {
    const a = Math.max(0, (x0 / CELL) | 0), b = Math.min(gw - 1, (x1 / CELL) | 0), c = Math.max(0, (y0 / CELL) | 0), d = Math.min(gh - 1, (y1 / CELL) | 0);
    for (let y = c; y <= d; y++) for (let x = a; x <= b; x++) grid[y * gw + x] = 1;
  }

  const P = { sx: 0, sy: 0, w: 0 };
  let VP = null;
  function project(x, y, z) {
    const e = VP; const cw = e[3] * x + e[7] * y + e[11] * z + e[15];
    P.w = cw / WS; if (cw <= 0.01 * WS) return false; // depth in layout units, so focal / P.w = px per layout unit
    P.sx = ((e[0] * x + e[4] * y + e[8] * z + e[12]) / cw * 0.5 + 0.5) * vw;
    P.sy = (0.5 - (e[1] * x + e[5] * y + e[9] * z + e[13]) / cw * 0.5) * vh;
    return true;
  }
  const vpm = new THREE.Matrix4();
  let reservedTop = 0; const ctrlRect = { x0: 1e9, y0: 1e9 };
  function buildText() {
    vpm.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse).multiply(SWS); VP = vpm.elements;
    grid.fill(0);
    gridMark(0, 0, vw, reservedTop); // keep text out from under the search bar
    const cands = []; const cx = vw / 2, cy = (vh + reservedTop) / 2;
    const onScreen = (x0, y0, x1, y1) => x1 > -20 && x0 < vw + 20 && y1 > -20 && y0 < vh + 20;
    const camP = camL;
    // a face label's screen frame: origin + per-world-unit screen vectors along the face's R and -U
    const FI = { sx: 0, sy: 0, rX: 0, rY: 0, uX: 0, uY: 0, hpx: 0, vpx: 0 };
    function faceFrame(pg, x, y, z) {
      if (!project(x, y, z)) return false; const sx = P.sx, sy = P.sy;
      if (!project(x + pg.rx, y + pg.ry, z)) return false; const rX = P.sx - sx, rY = P.sy - sy;
      if (!project(x, y, z - 1)) return false; const uX = P.sx - sx, uY = P.sy - sy;
      Object.assign(FI, { sx, sy, rX, rY, uX, uY, hpx: Math.hypot(rX, rY), vpx: Math.hypot(uX, uY) });
      return project(x, y, z);
    }
    // L4 courts (books): label floats over the court centre
    for (const b of books) {
      if (!b.on || !b.lab || !project(b.cx, b.cy, b.lz)) continue; const p = focal / P.w;
      const cw = b.r * 2 * p, a = (1 - smooth(0.7 * vw, 1.5 * vw, cw)) * smooth(250, 450, camP.z); if (a <= 0.02) continue; // court names read from the air, not at ground level
      const em = clamp(b.r / 6 * p, 16, 30);
      cands.push({ kind: 4, obj: b, key: 'b' + b.id + ':' + b.labelV + ':' + (b.liveN || 0), x: b.cx, y: b.cy, z: b.lz, sx: P.sx, sy: P.sy, em, p, a, solid: a > 0.5, pri: b.kind === 'chat' ? -1e12 : -b.words });
    }
    const pageVis = [];
    for (const pg of sel) {
      const mz = pg.h / 2;
      if (!project(pg.bx, pg.by, mz)) continue; const p = focal / P.w;
      const pw = pg.w * p, ph = pg.h * p;
      if (!onScreen(P.sx - pw, P.sy - ph / 2, P.sx + pw, P.sy + ph / 2)) continue;
      const facing = (camP.x - pg.bx) * pg.nx + (camP.y - pg.by) * pg.ny > pg.d / 2;
      pageVis.push([pg, p, facing]);
      if (p * L.midEm >= FADE_LO * 0.75) { if (pg.msgs === null) requestTile(pg.tile, true); const tt = tiles.get(pg.tile); if (tt) tt.seen = performance.now(); }
      const cap = wp(pg, pg.w / 2, -(pg.capH + 0.4), pg.d / 2 + 0.5);
      const a = smooth(14, 22, pw);
      if (a > 0.02 && project(cap.x, cap.y, cap.z)) {
        const pp = focal / P.w, em = clamp(pg.hlEm * pp, 14, 30);
        cands.push({ kind: 3, obj: pg, key: 'h' + pg.id + ':' + pg.labelV + (pg.act > 0.25 ? ':L' + (pg.newLines || 0) : ''), x: cap.x, y: cap.y, z: cap.z, sx: P.sx, sy: P.sy, em, p: pp, a, solid: true, bill: true,
          pri: Math.hypot(P.sx - cx, P.sy - cy) * (1 + P.w / 3000) - (pg.locked ? 150 : 0) - 2000 * pg.act });
      }
      if (facing && pg.subEm * 