<!-- SOLUV · part 503 -->
idth, cssH = () => canvasEl.clientHeight || innerHeight;
  const faceAt = (x, y, dist) => { const p = wall.localToWorld(new T.Vector3(x, 0, 0)); return { tx: p.x, ty: y, tz: p.z, dist, yaw: rotY, pitch: 0.02 }; };
  const farView = () => { const hTan = tanH() * Math.max(0.2, cssW() - 72) / cssH(); return faceAt(0, (H + 0.4) * 0.5, Math.max(((W / 2 + 2) * 1.04) / hTan, ((H + 1) / 2 * 1.08) / tanH())); };
  const liveView = () => { const d = EM * cssH() / (2 * tanH() * 12), vis = 2 * d * tanH(), a = wall.wall.lineAnchor(); return a ? faceAt(a.local.x, Math.max(a.local.y - vis * 0.08, vis * 0.45), d) : faceAt(0, vis * 0.45, d); };
  label.addEventListener('click', () => api.flyTo(farView(), 2000));
  walk.addEventListener('click', () => api.flyTo(liveView(), 1400));

  // tap the tower itself (when she is drawn): fly to her
  let down = null; const ray = new T.Raycaster(), ndc = new T.Vector2();
  canvasEl.addEventListener('pointerdown', e => { down = { x: e.clientX, y: e.clientY, t: performance.now() }; });
  canvasEl.addEventListener('pointerup', e => {
    if (!down || Math.hypot(e.clientX - down.x, e.clientY - down.y) > 10 || performance.now() - down.t > 600) { down = null; return; }
    down = null; if (!group.visible) return; const r = canvasEl.getBoundingClientRect();
    ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); ray.setFromCamera(ndc, camera);
    if (!ray.intersectObject(body, false).length) return;
    api.flyTo(wall.wall.distanceTo(camera.position) > 200 ? farView() : liveView(), 1600);
  });

  // who am I (posting uses the viewer's own session; guests see "Log in to post")
  let me = null, asking = null;
  function refreshMe() {
    if (asking) return asking;
    asking = wall.wall.whoAmI().then(j => { me = j; form.classList.toggle('spt-guest', !j.username); input.disabled = false; })
      .catch(e => { console.error('[soluv] Soul Print: ' + e.message); say('Could not check who you are: ' + e.message); })
      .finally(() => { asking = null; });
    return asking;
  }
  form.addEventListener('submit', async e => {
    e.preventDefault(); const text = input.value.trim(); if (!text) return;
    send.disabled = true;
    try { const r = await wall.wall.post(text); if (r.ok) { input.value = ''; draw(); } else { say(r.error); if (r.login) { me = null; form.classList.add('spt-guest'); } } }
    catch (err) { console.error('[soluv] Soul Print post: ' + err.message); say('Not sent: ' + err.message); }
    finally { send.disabled = false; }
  });

  // per frame (only does work when the camera moved): label, walk-up button, composer
  const gotoBtn = host.querySelector('.sw-goto'), lastM = new T.Matrix4(), _p = new T.Vector3(), _q = new T.Vector3();
  let wasNear = null, wasClose = null;
  function frame() {
    requestAnimationFrame(frame);
    camera.updateMatrixWorld();
    if (lastM.equals(camera.matrixWorld) && wasNear !== null) return; lastM.copy(camera.matrixWorld);
    const d = wall.wall.distanceTo(camera.position), near = d <= NEAR, close = !near && (aimedAtHer() || camH() < 400);
    // map label when she is only a few pixels on screen
    _p.set(0, H, 0); wall.localToWorld(_p).project(camera); _q.set(W, H, 0); wall.localToWorld(_q).project(camera);
    const sw = cssW(), sh = cssH(), x = (_p.x * 0.5 + 0.5) * sw, y = (-_p.y * 0.5 + 0.5) * sh, px = Math.abs(_q.x - _p.x) * 0.5 * sw;
    // from the map she is always marked (pinned to the screen edge with an arrow when she is off it); elsewhere only when tiny on screen
    const high = camera.position.y > SHOW_Y, onScreen = _p.z < 1 && x > 4 && x < sw - 84 && y > 64 && y < sh - 120;
    const showL = group.visible && px < 30 && (onScreen || (high && _p.z < 1));
    if (label.hidden === showL) label.hidden = !showL;
    if (showL) {
      const cx = Math.max(70, Math.min(sw - 150, x)), cy = Math.max(110, Math.min(sh - 130, y));
      const arrow = onScreen ? '' : (y < 110 ? '\u2191 ' : y > sh - 130 ? '\u2193 ' : x < 70 ? '\u2190 ' : '\u2192 ');
      const t = arrow + '\u2726 Soul Print'; if (label.firstChild.textContent !== t) label.firstChild.textContent = t;
      label.style.transform = 'translate(' + Math.round(cx) + 'px,' + Math.round(cy) + 'px) translate(-50%,-110%)';
    }
    if (near !== wasNear) { form.hidden = !near; if (near) refreshMe(); else if (document.activeElement === input) input.blur(); }
    if (close !== wasClose) walk.hidden = !close;
    if (gotoBtn) gotoBtn.style.visibility = near || close ? 'hidden' : '';
    if (near) { const q2 = new URLSearchParams(location.search); q2.delete('at'); q2.delete('view'); loginA.href = '/login?next=' + encodeURIComponent(location.pathname + (q2.toString() ? '?' + q2 : '')); }
    wasNear = near; wasClose = close;
  }
  requestAnimationFrame(frame);

  api.soulPrintTower = { position: { ...POS, width: W, height: H, depth: DEPTH, rotationY: rotY }, group, wall, room: ROOM.base, get visible() { return group.visib