<!-- SOLUV · part 443 -->
varying float vA;
void main(){
  vec4 mv = modelViewMatrix * vec4(position, 1.0);
  float px = size * uFocal / max(-mv.z, 0.01);
  vA = smoothstep(0.4, 1.6, px) * 0.8 + 0.2;
  gl_PointSize = clamp(px, 1.6, uMax) * uDpr; vC = color;
  gl_Position = projectionMatrix * mv;
}`;
const PTS_FS = `
varying vec3 vC; varying float vA;
void main(){ vec2 p = gl_PointCoord - 0.5; float r = length(p); float a = smoothstep(0.5, 0.15, r); if (a < 0.02) discard; gl_FragColor = vec4(vC * a * vA, a * vA); }`;

const GLOW_VS = `
attribute float size; attribute float act; uniform float uFocal; uniform float uDpr; uniform float uTime; varying float vA; varying float vAct;
void main(){
  vec4 mv = modelViewMatrix * vec4(position, 1.0);
  float raw = size * (0.55 + 0.9 * act) * uFocal / max(-mv.z, 0.01);
  float px = clamp(raw, 3.0 + 42.0 * act, 70.0 + 190.0 * act);
  float pulse = act > 0.04 ? 0.72 + 0.28 * sin(uTime * 3.2 + position.x * 0.013) : 1.0;
  vA = mix(0.28, 1.0, act) * pulse * (1.0 - 0.85 * smoothstep(160.0, 520.0, raw)); // fade the halo once a page fills the screen
  vAct = act; gl_PointSize = px * uDpr; gl_Position = projectionMatrix * mv;
}`;
const GLOW_FS = `
varying float vA; varying float vAct;
void main(){
  float r = length(gl_PointCoord - 0.5) * 2.0; if (r > 1.0) discard;
  float core = exp(-r * r * 5.0), halo = exp(-r * r * 1.6) * 0.45;
  vec3 ember = vec3(0.05, 0.28, 0.45), hot = vec3(0.35, 0.95, 1.0); // holographic cyan
  vec3 c = mix(ember, hot, vAct);
  float a = (core + halo * vAct) * vA * (0.45 + 0.55 * vAct);
  gl_FragColor = vec4(c * a, a);
}`;

/* ---------------- main ---------------- */
/* ---------------- castle layout: any number of pillars -> keep rings, a colonnade avenue, tower courts ----------------
   Every pillar gets: bx,by (box centre on the ground), nx,ny (face normal), rx,ry (left->right along the face),
   x,y,z (top-left corner of the face: page-local origin), d (depth). Heights/widths come from the data (conversation length). */
function layoutCastle(sel, books) {
  const GAP = 10, D = 2, rings = [], avenues = [];
  const place = (p, cx, cy, nx, ny) => {
    p.nx = nx; p.ny = ny; p.rx = -ny; p.ry = nx; p.d = D; p.bx = cx; p.by = cy;
    p.x = cx - p.rx * p.w / 2 + nx * D / 2; p.y = cy - p.ry * p.w / 2 + ny * D / 2; p.z = p.h;
  };
  // concentric rings: pairs of (inward-facing ring, outward-facing ring back-to-back 40 behind), halls of 120 between pairs
  function ringCourt(list, cx, cy, firstArcMax, rMin) {
    const arc = list.reduce((a, p) => a + p.w + GAP, 0);
    let r = Math.max(rMin, Math.min(arc, firstArcMax) / (2 * Math.PI)), k = 0, i = 0, outer = r;
    while (i < list.length) {
      const inward = k % 2 === 0, cap = 2 * Math.PI * r; let used = 0; const ring = [];
      while (i < list.length && used + list[i].w + GAP <= cap) { used += list[i].w + GAP; ring.push(list[i++]); }
      if (!ring.length) ring.push(list[i++]);
      // spread evenly round the whole ring (a partly filled last ring still reads as a ring)
      const tot = ring.reduce((a, p) => a + p.w + GAP, 0), slack = (cap - tot) / ring.length; let a = Math.PI / 2;
      for (const p of ring) {
        const da = (p.w + GAP + slack) / r, am = a + da / 2, c = Math.cos(am), s = Math.sin(am);
        place(p, cx + c * r, cy + s * r, inward ? -c : c, inward ? -s : s); a += da;
      }
      rings.push({ cx, cy, r, inward }); outer = r + D;
      r += inward ? 40 : 120; k++;
    }
    return outer;
  }
  if (sel.length === 1) { // the great room: one pillar at the centre of an open court, facing south (toward the home camera)
    const p = sel[0]; p.slab = true; place(p, 0, 0, 0, -1); p.d = 2; p.x = p.bx - p.rx * p.w / 2 + p.nx; p.y = p.by - p.ry * p.w / 2 + p.ny; // thin flat monolith
    for (const b of books) { b.lab = false; }
    const r = Math.max(60, p.w * 3); rings.push({ cx: 0, cy: 0, r, inward: true });
    return { cx: 0, cy: 0, radius: Math.max(r, p.h * 0.55), rings, avenues, single: true };
  }
  const groups = new Map();
  for (const p of sel) { if (!groups.has(p.b)) groups.set(p.b, []); groups.get(p.b).push(p); }
  for (const b of books) { b.lab = false; b.cx = 0; b.cy = 0; b.r = 0; b.lz = 0; }
  const keepId = groups.has(0) ? 0 : [...groups.keys()].sort((a, b) => groups.get(b).length - groups.get(a).length)[0];
  const keep = [...groups.get(keepId)]; groups.delete(keepId);
  for (const [id, g] of [...groups]) if (g.length < 4) { keep.push(...g); groups.delete(id); }
  // the keep: live rooms innermost (sorted so chat is ring 0), small collections behind them
  keep.sort((a, b) => (books[a.b].kind === 'chat' ? 0 : 1) - (books[b.b].kind === 'chat' ? 0 : 1) || a.id - b.id);
  const keepOuter = ringCourt(keep, 0, 0, 1400, 60);
  Object.assign(books[keepId], { lab: true, cx: 0, cy: 0, r: keepOuter, lz: Math.max(...keep.map(p => p.h)) + 40 });
  let extent = keepOuter;
  // colonnade: the largest collection's first chapters form an avenue of facing rows leading south to the ke