<!-- SOLUV · part 666 -->
') return endPlace('Placing cancelled');
    const pl = place.plan;
    const rec = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), room: b.dataset.room, x: pl.x, z: pl.z, rotY: pl.rotY, width: pl.width, height: PLACE_H,
      creator: me.username, creatorName: me.king ? 'The Epistolary' : (me.name || me.username), t: Date.now() };
    endPlace(); const w = makePlaced(rec); savePlaced();
    current = w; refreshStatus(); refreshMe(); flyTo(views.live(w), 1600);
    say('Room placed \u2713 ' + (rec.room === 'lobby' ? 'Lobby' : 'Soluv room') + ', ' + pl.blocks + ' block' + (pl.blocks > 1 ? 's' : '') + ' wide. Saved on this device only (prototype).', () => tearDown(w, 'Undone: the room is gone'));
  });
  tearBtn.addEventListener('click', () => { const w = nearW; const rec = w && w.userData.placed; if (!rec || !me || !(me.username === rec.creator || me.king)) return say('Only the person who placed this room (or the King) can tear it down'); tearDown(w, 'Torn down \u2713'); });
  // Place Room ships in the next version: only with ?place=1 (prototype, this device only) until then
  if (qs.has('place')) for (const rec of loadPlaced()) { if (rec && Number.isFinite(rec.x) && Number.isFinite(rec.z) && rec.width >= BLOCK) makePlaced(rec); }

  function refreshStatus() {
    const s = current.wall.status; numBtn.textContent = String(current.wall.room);
    statusEl.textContent = (deep ? 'Wall ' + current.wall.room + ' \u00b7 ' : 'Map \u00b7 wall ' + current.wall.room + ' \u00b7 ') + current.wall.route + ' \u00b7 ' + (s.state === 'live' ? 'LIVE \u00b7 ' : '') + s.text;
    statusEl.dataset.state = s.state;
    badge.textContent = (test ? 'TEST TEXT on wall 1 \u00b7 ' : '') + 'Wall ' + current.wall.room + ': ' + (current.wall.info().widthBlocks) + '\u00d7' + current.wall.info().heightBlocks.toFixed(1) + ' gold blocks \u00b7 1 column \u00b7 full: oldest to archive \u00b7 min 0.13';
  }

  const RAIL = 72; // the button column: views are centred in the free area left of it, so buttons never cover the wall
  function resize() { const w = cssW(), h = cssH(); renderer.setSize(w, h, false); camera.aspect = w / h; camera.setViewOffset(w, h, RAIL / 2, 0, w, h); camera.updateProjectionMatrix(); need = true; }
  const ro = new ResizeObserver(resize); ro.observe(container); resize();

  const ease = t => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  let frames = 0, alive = true, lastT = 0;
  function tick(now) {
    if (!alive) return; requestAnimationFrame(tick);
    const dt = Math.min(0.1, (now - (lastT || now)) / 1000); lastT = now;
    if (tyGoal !== null && !anim) { const d = tyGoal - st.ty; st.ty += Math.abs(d) < 1e-3 ? d : d * (1 - Math.exp(-dt * 12)); if (Math.abs(tyGoal - st.ty) < 1e-3) { st.ty = tyGoal; tyGoal = null; } applyCamera(); }
    if (flingTy && !ptr.size && !anim) { const ny = st.ty + flingTy * dt, top = wallTop() + 2; if ((ny > top && flingTy > 0) || ny < 0.05) flingTy = 0; else { st.ty = ny; flingTy *= Math.pow(0.04, dt); if (Math.abs(flingTy) < pxWorld() * 20) flingTy = 0; } applyCamera(); }
    if (anim) { const t = Math.min(1, (now - anim.t0) / anim.ms), e = ease(t); for (const k of ['tx', 'ty', 'tz', 'pitch']) st[k] = anim.a[k] + (anim.b[k] - anim.a[k]) * e; let dy = anim.b.yaw - anim.a.yaw; dy = Math.atan2(Math.sin(dy), Math.cos(dy)); st.yaw = anim.a.yaw + dy * e; st.dist = Math.exp(Math.log(anim.a.dist) + (Math.log(anim.b.dist) - Math.log(anim.a.dist)) * e); applyCamera(); if (t >= 1) anim = null; }
    if (glide) stepGlide(now);
    if (!need) return; need = false;
    for (const w of walls) w.wall.update(camera, renderer);    // same-frame LOD (walls also self-update in onBeforeRender)
    renderer.render(scene, camera); frames++;
    placeLabels(); updateProximity();
  }
  /* map labels: when a wall is only a few pixels wide (map view), a small screen label marks where it stands */
  const labelsEl = wrap.querySelector('.sw-labels'); const _p = new THREE.Vector3(), _q = new THREE.Vector3();
  function placeLabels() {
    const W = cssW(), H = cssH();
    walls.forEach((w, i) => {
      let el = labelsEl.children[i];
      if (!el) { el = document.createElement('div'); el.className = 'sw-label'; labelsEl.appendChild(el); }
      const inf = w.wall.info();
      _p.set(0, inf.heightWorld, 0).applyMatrix4(w.matrixWorld).project(camera);
      _q.set(inf.widthWorld, inf.heightWorld, 0).applyMatrix4(w.matrixWorld).project(camera);
      const x = (_p.x * 0.5 + 0.5) * W, y = (-_p.y * 0.5 + 0.5) * H, px = Math.abs(_q.x - _p.x) * 0.5 * W;
      const show = _p.z < 1 && px < 30 && x > 4 && x < W - 84 && y > 64 && y < H - 120;
      el.style.display = show ? 'block' : 'none';
      if (show) { el.textContent = '▮ SOLUV ' + w.wall.room + (w.wall.status.state === 'live' ? ' · live' : ''); el.style.transform = 'translate(' + Math.round(x) + 'px,' + Math.round(y) + 'px) translate(-50%,-120%)'; }
    });
  }

  // start: /soluv = the map, then fly