<!-- SOLUV · part 455 -->
p >= FADE_LO * 0.8 && pg.sub && pg.sub.length) {
        const hlLines = wrapText(pg.hl, 20).length, kwLines = wrapText(pg.kw.slice(0, 3).join(' · '), 30).length;
        let ly = hlLines * pg.hlEm * 1.12 + (kwLines + 1) * pg.hlEm * 0.86 * 1.3 + 1;
        for (let k = 0; k < pg.sub.length; k++) {
          const w0 = wp(pg, pg.w / 2, ly), x = w0.x, y = w0.y, z = w0.z;
          ly += wrapText(pg.sub[k], 30).length * pg.subEm * 1.2 + pg.subEm * 0.45;
          if (!faceFrame(pg, x, y, z)) continue;
          const read = Math.min(FI.hpx, FI.vpx) * pg.subEm, a = smooth(FADE_LO, FADE_HI, read); if (a < 0.02) continue;
          cands.push({ kind: 2, obj: { page: pg, k }, key: 's' + pg.id + ':' + k + ':' + pg.labelV, x, y, z, ...FI, em: pg.subEm, read, p, a, solid: true, face: pg, pri: Math.hypot(FI.sx - cx, FI.sy - cy) });
        }
      }
    }
    // L1/L0 messages on the faces you can see, close enough for a message label to reach the readable floor
    for (const [pg, p0, facing] of pageVis) {
      if (!facing || !pg.msgs || p0 * L.midEm < FADE_LO * 0.7) continue;
      for (const m of pg.msgs) {
        if (!faceFrame(pg, m.x, m.y, m.z)) continue;
        const k = Math.min(FI.hpx, FI.vpx); if (FI.hpx < 0.45 * FI.vpx) continue; // too oblique to read
        const hpx = m.n * L.lineH * FI.vpx;
        if (!onScreen(Math.min(FI.sx, FI.sx + FI.rX * pg.w), FI.sy - 4, Math.max(FI.sx, FI.sx + FI.rX * pg.w), FI.sy + Math.max(hpx, L.midEm * 2.3 * FI.vpx))) continue;
        const a0 = smooth(FADE_LO, FADE_HI, k), midPx = L.midEm * k, a1 = smooth(FADE_LO, FADE_HI, midPx) * (1 - a0);
        const d = Math.hypot(FI.sx + FI.rX * pg.w / 2 - cx, FI.sy + hpx / 2 - cy);
        if (a0 > 0.02) cands.push({ kind: 0, obj: m, key: 'n' + m.id + (m.camId ? (m.live ? ':L' : m.leave ? ':X' : ':F') : '') + (highlight && highlight.pid === pg.id && highlight.mi === m.i ? ':h' + highlight.v : ''), x: m.x, y: m.y, z: m.z, ...FI, em: 1, read: k, p: p0, a: a0, solid: a0 >= 0.5, pri: d, mid: m.id, face: pg });
        if (a1 > 0.02) cands.push({ kind: 1, obj: m, key: 'm' + m.id, x: m.x, y: m.y, z: m.z, ...FI, em: L.midEm, read: midPx, p: p0, a: a1, solid: a1 > 0.5, pri: d, mid: m.id, face: pg });
      }
    }
    const order = [4, 3, 2, 0, 1];
    cands.sort((A, B) => ((B.solid ? 1 : 0) - (A.solid ? 1 : 0)) || (order.indexOf(A.kind) - order.indexOf(B.kind)) || (A.pri - B.pri));
    let n = 0, labels = 0, minEm = Infinity, readableWords = 0; const placedMsg = new Map(); let dropped = 0;
    const right = textMat.uniforms.uRight.value, upv = textMat.uniforms.uUp.value;
    right.setFromMatrixColumn(camera.matrixWorld, 0); upv.setFromMatrixColumn(camera.matrixWorld, 1);
    faceBatch.n = 0; capBatch.n = 0;
    for (const c of cands) {
      const lay = layout(c.key, c.kind, c.obj);
      let x0, y0, bw, bh;
      if (c.face) { // parallelogram on the pillar face -> screen bbox
        const ox = lay.center ? -lay.w * c.em / 2 : 0, w = lay.w * c.em, h = lay.h * c.em;
        const xs = [0, w, 0, w].map((u, i) => c.sx + c.rX * (ox + u) + c.uX * (i > 1 ? h : 0)), ys = [0, w, 0, w].map((u, i) => c.sy + c.rY * (ox + u) + c.uY * (i > 1 ? h : 0));
        x0 = Math.min(...xs); y0 = Math.min(...ys); bw = Math.max(...xs) - x0; bh = Math.max(...ys) - y0;
      } else {
        bw = lay.w * c.em; bh = lay.h * c.em; x0 = lay.center ? c.sx - bw / 2 : c.sx; y0 = c.sy;
        if (c.kind >= 3 && bw < vw - 8) { // nudge cap/court labels fully onto the screen (map-style)
          const nx = clamp(x0, 4, vw - 4 - bw), dx = nx - x0;
          if (dx) { x0 = nx; const k = dx / c.p; c.x += right.x * k; c.y += right.y * k; c.z += right.z * k; }
        }
      }
      if (!onScreen(x0, y0, x0 + bw, y0 + bh)) continue;
      if (c.kind >= 2 && c.a < 0.35) continue; // no ghost headlines: fading labels only show once mostly opaque
      const pad = c.kind >= 3 ? 4 : 2;
      const exempt = c.kind <= 1 && placedMsg.has(c.mid) && placedMsg.get(c.mid) !== c.kind; // near/mid crossfade partner already placed
      if (!exempt && !gridFits(x0 - pad, y0 - pad, x0 + bw + pad, y0 + bh + pad)) continue;
      if (c.kind >= 3 && x0 + bw > ctrlRect.x0 && y0 + bh > ctrlRect.y0) continue; // keep caps clear of the buttons
      if (faceBatch.n + capBatch.n + lay.n > MAX_GLYPHS) { dropped++; continue; }
      if (!exempt) gridMark(x0 - pad, y0 - pad, x0 + bw + pad, y0 + bh + pad);
      if (c.kind <= 1) placedMsg.set(c.mid, c.kind);
      const B = c.face ? faceBatch : capBatch, D = B.data, g = lay.g;
      const scale = c.face ? c.em : Math.max(c.em / c.p, 1 / lay.minScale); // THE LAW for text: glyph em >= 1 unit = MINIMUM world
      const ax = c.face ? c.face.rx : right.x, ay = c.face ? c.face.ry : right.y, az = c.face ? 0 : right.z;
      const ux = c.face ? 0 : upv.x, uy = c.face ? 0 : upv.y, uz = c.face ? 1 : upv.z;
      for (let i = 0; i < lay.n; i++) {
        const o = B.n * STRIDE, s = i * 12;
        D[o] = c.x; D[o + 1] = c.y; D