<!-- SOLUV · part 445 -->
" aria-label="Zoom in">+</button><button class="ws-btn" data-a="out" aria-label="Zoom out">&minus;</button><button class="ws-btn ws-live" data-a="live" aria-label="Most active page">Live</button><button class="ws-btn ws-view" data-a="view" aria-label="Camera angle: next view">Wide</button><button class="ws-btn ws-np" data-a="np" aria-label="Pillar count: next preset">N</button><button class="ws-btn" data-a="home" aria-label="Whole castle">&#8962;</button></div>
    <div class="ws-badge"></div><div class="ws-hud"></div>`;
  container.appendChild(wrap);
  const canvas = wrap.querySelector('.ws-canvas'), statusEl = wrap.querySelector('.ws-status'), badge = wrap.querySelector('.ws-badge'), hud = wrap.querySelector('.ws-hud');
  const qEl = wrap.querySelector('.ws-q'), nextBtn = wrap.querySelector('.ws-next');
  if (opts.hud) hud.style.display = 'block';

  /* index */
  const qN0 = parseInt(new URLSearchParams(location.search).get('pillars') || '', 10);
  const roomN = opts.room || parseRoute(location); // wall N needs at least N walls on the map
  const wantN = Math.max(opts.pillars || (qN0 > 0 ? qN0 : 1), roomN || 1);
  let idxRes = wantN === 1 ? await fetch(new URL('index-great.json', dataBase)).catch(() => null) : null;
  if (!idxRes || !idxRes.ok) idxRes = await fetch(new URL('index.json', dataBase));
  const idxBuf = await idxRes.arrayBuffer(); stats.indexBytes = idxBuf.byteLength;
  const index = JSON.parse(new TextDecoder().decode(idxBuf));
  stats.tIndexMs = performance.now() - t0;
  const L = index.layout, GOLD = L.gold;
  const books = index.books, pages = index.pages;
  const tiles = new Map(index.tiles.map(t => [t.id, { ...t, state: 0 }]));
  stats.tilesTotal = tiles.size;

  // ---- how many pillars: opts.pillars / ?pillars=N. Live chat rooms first, then archive stories fill the rest ----
  const qN = parseInt(new URLSearchParams(location.search).get('pillars') || '', 10);
  const NP = clamp(wantN, 1, index.totals.pages || pages.length);
  const sel = (() => {
    const out = pages.filter(p => books[p.b].kind === 'chat' && !p.dup).slice(0, NP);
    const queues = books.map(b => b.pages.map(id => pages[id]).filter(p => !(b.kind === 'chat' && !p.dup)));
    for (let r = 0; out.length < NP; r++) { let any = false; for (const q of queues) if (r < q.length) { any = true; out.push(q[r]); if (out.length >= NP) break; } if (!any) break; }
    return out;
  })();
  for (const p of pages) p.on = false; for (const p of sel) p.on = true;
  { const need = new Set(sel.map(p => p.tile)); for (const id of [...tiles.keys()]) if (!need.has(id)) tiles.delete(id); stats.tilesTotal = tiles.size; stats.tileBytesTotal = [...tiles.values()].reduce((a, t) => a + t.bytes, 0); stats.tileGzTotal = [...tiles.values()].reduce((a, t) => a + t.gz, 0); }
  const castle = layoutCastle(sel, books);
  for (const b of books) { b.labelV = 0; b.on = b.pages.some(id => pages[id].on); }
  sel.forEach((p, i) => { p.room = i + 1; p.route = '/soluv/' + (i + 1); p.roomApi = `?room=${i + 1}`; }); // each wall keyed by its room id
  for (const p of sel) {
    p.msgs = null; p.labelV = 0; p.act = 0; p.newLines = 0; p.baseHl = p.hl; p.baseSub = p.sub.slice();
    // reserved room model (placeholders only): mode text|audio|video, access watch|text|audio|video, up to maxCams video tiles
    p.mode = p.mode || 'text'; p.access = p.access || (p.locked ? 'watch' : 'text'); p.maxCams = p.maxCams ?? 20; p.cams = p.cams || 0;
    const cols = 5, rowsN = Math.ceil(p.maxCams / cols), tw = (p.w - 1.2 - (cols - 1) * 0.4) / cols, th = tw * 0.5625;
    p.band = { cols, rows: rowsN, tw, th, top: 1.0 }; p.capH = rowsN * th + (rowsN - 1) * 0.4 + 2.2; // the capital above the text holds the camera band
  }
  stats.pillars = sel.length; stats.wordsTotal = sel.reduce((a, p) => a + p.words, 0);
  stats.lockedPillars = sel.filter(p => p.locked).length; stats.rooms = { video: sel.filter(p => p.mode === 'video').length, audio: sel.filter(p => p.mode === 'audio').length, text: sel.filter(p => p.mode === 'text').length }; stats.archivePillars = sel.filter(p => books[p.b].kind !== 'chat').length;
  // page-local (lx across the face, ly down from the top) -> world, on the pillar's face plane
  const WP = { x: 0, y: 0, z: 0 };
  const wp = (p, lx, ly, off = 0.08) => { WP.x = p.x + p.rx * lx + p.nx * off; WP.y = p.y + p.ry * lx + p.ny * off; WP.z = p.z - ly; return WP; };

  /* three */
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'high-performance' });
  const dpr = Math.min(window.devicePixelRatio || 1, opts.maxDpr || 2);
  renderer.setPixelRatio(dpr); renderer.setClearColor(0x000000, 1); // black sky
  const scene = new THREE.Scene();
  const world = new THREE.Group(); world.scale.setScalar(WS); world.name = 'world (layout units x 0.13)'; scene.add(world);
  const camera = new THREE.PerspectiveCamera(50, 1, 0.5 * WS, 60000 * WS);
  const atlas = new SDFAtlas();
  for (let c = 32; c < 127;