<!-- SOLIDIVISM · part 63 -->
 + cat.color.toString(16).padStart(6, '0')), side: THREE.DoubleSide, transparent: false }));
  plate.position.set(x, h + 2.4, z + d / 2 - 1.5);
  plate.name = 'plate-' + page.path; cityGroup.add(plate);

  /* invisible hover/click target (raycast list) */
  const hit = new THREE.Mesh(new THREE.BoxGeometry(w + 1, h + 4, d + 1),
    new THREE.MeshBasicMaterial({ visible: false }));
  hit.position.set(x, (h + 4) / 2, z); hit.userData.path = page.path;
  cityGroup.add(hit); hitMeshes.push(hit);

  const rec = { path: page.path, title, cat, x, z, w, d, h,
    door: { x, z: z + d / 2 + 2.6 }, plate,
    aabb: { minX: x - w / 2 - 0.4, maxX: x + w / 2 + 0.4, minZ: z - d / 2 - 0.4, maxZ: z + d / 2 + 0.4 } };
  buildings.push(rec); colliders.push(rec.aabb);
  return rec;
}

/* ---------------- THE RoomSpec SYSTEM --------------------------------
 * Every building generates the same spec shape (the one in the
 * commission). Rooms come from the page's own headings when the
 * page can be read; otherwise the standard two-room spec serves. */
const specCache = new Map();
async function makeRoomSpec(rec) {
  if (specCache.has(rec.path)) return specCache.get(rec.path);
  const rooms = [];
  try {
    const res = await fetch(rec.path, { credentials: 'omit' });
    if (res.ok) {
      const html = await res.text();
      const heads = [...html.matchAll(/<h([12])[^>]*>([\s\S]*?)<\/h\1>/gi)]
        .map(m => m[2].replace(/<[^>]+>/g, ' ').replace(/&[a-z]+;/g, ' ').replace(/\s+/g, ' ').trim())
        .filter(t => t.length > 2 && t.length < 70).slice(0, 4);
      const paras = [...html.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)]
        .map(m => m[1].replace(/<[^>]+>/g, ' ').replace(/&[a-z]+;/g, ' ').replace(/\s+/g, ' ').trim())
        .filter(t => t.length > 40);
      heads.forEach((label, i) => rooms.push({
        id: 'room-' + (i + 1), label, size: [6, 4, 6],
        contentUrl: 'https://epistles.xyz' + rec.path + '#section-' + (i + 1),
        blurb: (paras[i] || paras[0] || '').slice(0, 300)
      }));
    }
  } catch (e) { /* an unreadable page still gets its rooms */ }
  if (!rooms.length) {
    rooms.push({ id: 'room-1', label: 'Overview', size: [6, 4, 6],
      contentUrl: 'https://epistles.xyz' + rec.path + '#section-1',
      blurb: 'This room holds the page itself: ' + rec.title + ' — epistles.xyz' + rec.path });
    rooms.push({ id: 'room-2', label: 'Details', size: [6, 4, 6],
      contentUrl: 'https://epistles.xyz' + rec.path + '#section-2',
      blurb: 'Every heading of the page becomes a room on this hall. Open the page to read it whole.' });
  }
  const spec = { buildingId: rec.path.replace(/\//g, '') || 'home',
    hallway: { length: 12, width: 2.4, doorsPerSide: 3 }, rooms };
  specCache.set(rec.path, spec);
  return spec;
}

/* ---------------- buildInterior(roomSpec) ----------------------------
 * The interior is a pocket built at (0,-800,0) — the city above is
 * never moved or teleported; the CAMERA fades through the doorway
 * and the King wakes in the hall. One interior lives at a time:
 * entering another building rebuilds the pocket for it.          */
const POCKET = new THREE.Vector3(0, -800, 0);
let interiorGroup = null, interiorWalls = [], interiorSpec = null, interiorRec = null;

function panelTexture(title, body, footer, colorCss) {
  const c = document.createElement('canvas'); c.width = 512; c.height = 320;
  const g = c.getContext('2d');
  g.fillStyle = '#0a0e14'; g.fillRect(0, 0, 512, 320);
  g.strokeStyle = colorCss; g.lineWidth = 3; g.strokeRect(5, 5, 502, 310);
  g.fillStyle = '#ffe9a8'; g.font = 'bold 27px Georgia, serif'; g.textAlign = 'left';
  g.fillText(title.slice(0, 30), 24, 44);
  g.fillStyle = '#d7e3f2'; g.font = '17px Georgia, serif';
  const words = (body || '').split(/\s+/); let line = '', y = 82;
  for (const w of words) {
    if (g.measureText(line + ' ' + w).width > 462) { g.fillText(line, 24, y); y += 23; line = w; if (y > 258) break; }
    else line = line ? line + ' ' + w : w;
  }
  if (line && y <= 258) g.fillText(line, 24, y);
  g.fillStyle = '#39c2d7'; g.font = '12px monospace';
  g.fillText((footer || '').slice(0, 62), 24, 296);
  const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
  return tex;
}

function buildInterior(spec, rec) {
  if (interiorGroup) { scene.remove(interiorGroup); interiorGroup = null; }
  interiorWalls = []; interiorSpec = spec; interiorRec = rec;
  const G = new THREE.Group(); G.position.copy(POCKET); G.name = 'interior-' + spec.buildingId;
  const H = spec.hallway, hallLen = H.length, hallW = H.width, wallH = 3.4;
  const wallMat = new THREE.MeshStandardMaterial({ color: 0x232a33, roughness: 0.95 });
  const floorMat = new THREE.MeshStandardMaterial({ color: 0x171c22, roughness: 1 });
  const colorCss = '#' + rec.cat.color.toString(16).padStart(6, '0');
  const addBox = (w, h, d, x, y, z, mat) => {
    const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat |