<!-- SOLUV · part 472 -->
lse, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -4 }); mat.toneMapped = false; const m = new T.Mesh(tileGeo, mat); m.renderOrder = 3; m.userData.text = true; m.name = name; m.visible = false; textGroup.add(m); return m; };
  function placeTiles() {
    const want = new Set(), zText = L.TXT_Z * EM;
    // tiles only where text is: rows that hold a line, lanes that the line actually reaches (data thrift)
    // chunks are cut by the line's place in the whole story, so a new line only redraws the newest chunk (others just move up)
    const chunks = [];
    if (!S.msgs.length) chunks.push({ j: 'n', r0: S.R - 3, r1: S.R });
    else { const f = S.first || 0, n = S.lines.length, base = baseRow(); for (let j = Math.floor(f / L.CHUNK); j * L.CHUNK < f + n; j++) { const g0 = Math.max(j * L.CHUNK, f), g1 = Math.min((j + 1) * L.CHUNK, f + n); chunks.push({ j, r0: base + g0 - f, r1: base + g1 - f }); } }
    for (const { j, r0, r1 } of chunks) {
      const cells = []; let ext = 0;
      for (let r = r0; r < r1; r++) { const x = cell(0, r); cells.push(x); if (x) ext = Math.max(ext, x.k === 'live' ? x.ln.ext : x.k === 'blank' ? 0 : (x.ext || (x.ln && x.ln.ext) || TEXT_W)); }
      for (let c = 0; c < SEGS; c++) {
      if (c * SEG_W >= ext) continue;
      const k = c + ':' + j; want.add(k);
      let t = S.tiles.get(k);
      if (!t) { t = { key: k, c, mesh: newTextMesh('text tile ' + k), tier: 0, sig: null }; S.tiles.set(k, t); }
      const keys = cells.map(x => x ? x.key : '-');
      const sig = keys.join('|');
      if (t.sig !== sig) { t.stale = t.sig !== null || t.stale; t.sig = sig; }   // same lines = same picture, wherever it sits
      t.blank = keys.every(k => k === 'b' || k === '-'); if (t.blank && t.bytes) freeTile(t);   // nothing to draw: no texture at all
      Object.assign(t, { r0, r1, x: laneX(c), yTop: rowTop(r0), w: Math.min(SEG_W, TEXT_W - c * SEG_W), h: (r1 - r0) * L.LINE });
      t.mesh.scale.set(t.w * EM, t.h * EM, 1);
      t.mesh.position.set((t.x + t.w / 2) * EM, (t.yTop - t.h / 2) * EM, zText);
    } }
    for (const [k, t] of S.tiles) if (!want.has(k)) { freeTile(t); textGroup.remove(t.mesh); t.mesh.material.dispose(); S.tiles.delete(k); }
    if (!opts.header) return;
    let h = S.header;
    if (!h) h = S.header = { key: 'header', mesh: newTextMesh('header'), tier: 0, header: true };
    h.w = Wem - 2 * PADX; h.h = HEAD - 2; h.x = -Wem / 2 + PADX; h.yTop = L.PLINTH + S.Hem - 1;
    h.mesh.scale.set(h.w * EM, h.h * EM, 1); h.mesh.position.set(0, (h.yTop - h.h / 2) * EM, zText); h.stale = true;
  }
  function freeTile(t) { if (t.mesh.material.map) { t.mesh.material.map.dispose(); t.mesh.material.map = null; } S.texBytes -= t.bytes || 0; t.bytes = 0; t.tier = 0; t.mesh.visible = false; }
  function drawHeaderSoon() { if (S.header) S.header.stale = true; }

  let lastRenderer = null;
  function makeTexture(canvas) {
    const tex = new T.CanvasTexture(canvas);
    if ('colorSpace' in tex && T.SRGBColorSpace) tex.colorSpace = T.SRGBColorSpace;
    else if (lastRenderer && T.sRGBEncoding && lastRenderer.outputEncoding === T.sRGBEncoding) tex.encoding = T.sRGBEncoding;
    tex.generateMipmaps = true; tex.minFilter = T.LinearMipmapLinearFilter; tex.magFilter = T.LinearFilter;
    if (lastRenderer) tex.anisotropy = Math.min(8, lastRenderer.capabilities.getMaxAnisotropy());
    return tex;
  }
  const FONT = (bold, px) => (bold ? 700 : 400) + ' ' + px + 'px ' + FONT_STACK;
  function drawSlot(ctx, x, y, ppe, still) { // tiny live-camera slot (or frozen last frame with a replay mark)
    const w = 1.2 * ppe, h = 0.8 * ppe, top = y - 0.72 * ppe;
    ctx.fillStyle = still ? COL.still : '#ff5f6d'; ctx.globalAlpha = still ? 0.8 : 0.95; ctx.fillRect(x, top, w, h); ctx.globalAlpha = 1;
    ctx.fillStyle = '#071018'; ctx.beginPath();
    if (still) { ctx.moveTo(x + w * 0.38, top + h * 0.22); ctx.lineTo(x + w * 0.38, top + h * 0.78); ctx.lineTo(x + w * 0.72, top + h * 0.5); } else ctx.arc(x + w * 0.5, top + h * 0.5, h * 0.28, 0, 6.2832);
    ctx.fill();
  }
  function drawRuns(ctx, ln, top, ppe, W) {
    const base = top + 0.99 * ppe, x0 = 0.15 * ppe;
    for (const r of ln.runs) {
      const x = x0 + r.x * ppe;
      if (r.k === 'cam' || r.k === 'still') { drawSlot(ctx, x, base, ppe, r.k === 'still'); continue; }
      const bold = r.k === 'tag' || r.k === 'ktag';
      ctx.font = FONT(bold, ppe);
      ctx.fillStyle = r.k === 'tag' ? ln.color : r.k === 'ktag' ? COL.gold : r.k === 'ktext' ? COL.king : r.k === 'rec' ? COL.cyan : COL.text;
      if (r.k === 'ktag') { ctx.shadowColor = 'rgba(231,181,58,0.6)'; ctx.shadowBlur = 0.4 * ppe; }
      ctx.fillText(r.s, x, base); ctx.shadowBlur = 0;
      if (r.k === 'rec') ctx.fillRect(x, base + 0.12 * ppe, r.w * ppe, Math.max(1, 0.05 * ppe));
    }
  }
  function renderTile(t, tier) {
    const ppe = tier, W = Math.ceil(t.w * ppe), H = Math.ceil(t.h * ppe);
    const cv = document.createElement('canvas'); cv.