/* ============================================================
   Habitar — o diorama 3D: casa realista sobre uma ilha-jardim
   flutuante, sob o céu azul estrelado (three.js + glTF).
   Os modelos foram gerados por IA a partir de renders
   arquitetônicos feitos na paleta da marca.

   Usado pela página de vendas (#heroCasa, monta sozinho) e pelo
   app (window.Casa3D.montar(host, opts) — handshake __casaAlvo se o
   módulo ainda não carregou). Um renderer só, que muda de tela.

   Progressivo e seguro: a fachada SVG dentro do host continua sendo
   o padrão; o 3D só assume quando o GLB da casa carrega e o WebGL
   existe. Sem módulos, sem WebGL ou com prefers-reduced-motion,
   nada muda.
   ============================================================ */

const REDUZIDO = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const MODELO = "assets/modelos/casa-habitar-web.glb?v=2";
const JARDIM = "assets/modelos/jardim-habitar-web.glb?v=1";

/* nível do gramado no centro da ilha = base da casa */
const BASE = -0.75;
/* diâmetro da ilha; a casa tem ~2.55 de largura (metade da ilha) */
const DIAMETRO_ILHA = 5.0;

/* ---------- medições de modelos gerados ---------- */

/* Percorre a malha maior de um modelo e devolve os vértices em
   coordenadas de mundo (escala 1), junto com a caixa. */
function vertices(raiz, T) {
  let malha = null;
  raiz.updateMatrixWorld(true);
  raiz.traverse(function (o) {
    if (o.isMesh && (!malha || o.geometry.attributes.position.count > malha.geometry.attributes.position.count)) malha = o;
  });
  const pos = malha.geometry.attributes.position, v = new T.Vector3();
  const n = pos.count;
  const xs = new Float32Array(n), ys = new Float32Array(n), zs = new Float32Array(n);
  const min = new T.Vector3(Infinity, Infinity, Infinity), max = new T.Vector3(-Infinity, -Infinity, -Infinity);
  for (let i = 0; i < n; i++) {
    v.fromBufferAttribute(pos, i).applyMatrix4(malha.matrixWorld);
    xs[i] = v.x; ys[i] = v.y; zs[i] = v.z;
    min.min(v); max.max(v);
  }
  return { xs, ys, zs, n, min, max };
}

/* Mede a casa pela silhueta: fatia a altura em 40 faixas e olha a
   largura de cada uma. Um "chão" artificial aparece como faixas de
   baixo bem mais largas que o corpo; a base real é onde a largura
   cai. Devolve tudo em unidades do modelo (escala 1). */
function medirCasa(raiz, T) {
  const { xs, ys, zs, n, min, max } = vertices(raiz, T);
  const B = 40, h = (max.y - min.y) / B || 1;
  const minX = new Float32Array(B).fill(Infinity), maxX = new Float32Array(B).fill(-Infinity);
  const minZ = new Float32Array(B).fill(Infinity), maxZ = new Float32Array(B).fill(-Infinity);
  for (let i = 0; i < n; i++) {
    const b = Math.min(B - 1, ((ys[i] - min.y) / h) | 0);
    if (xs[i] < minX[b]) minX[b] = xs[i];
    if (xs[i] > maxX[b]) maxX[b] = xs[i];
    if (zs[i] < minZ[b]) minZ[b] = zs[i];
    if (zs[i] > maxZ[b]) maxZ[b] = zs[i];
  }
  const larg = (b) => Math.max(maxX[b] - minX[b], maxZ[b] - minZ[b]);
  const corpo = [];
  for (let b = 10; b < 30; b++) if (isFinite(larg(b))) corpo.push(larg(b));
  corpo.sort((a, b) => a - b);
  const larguraCorpo = corpo[corpo.length >> 1] || larg(0);
  const temChao = larg(0) > larguraCorpo * 1.25;
  let b = 0;
  if (temChao) { b = 1; while (b < B - 1 && larg(b) >= larg(0) * 0.9) b++; }
  return { base: min.y + b * h, topo: max.y, larguraCorpo, temChao };
}

/* Mede a ilha: raio horizontal, centro e a altura do gramado no
   miolo (onde a casa vai sentar). */
function medirIlha(raiz, T) {
  const { xs, ys, zs, n, min, max } = vertices(raiz, T);
  const cx = (min.x + max.x) / 2, cz = (min.z + max.z) / 2;
  const raio = Math.max(max.x - min.x, max.z - min.z) / 2;
  const r2 = Math.pow(raio * 0.22, 2);
  let topoCentro = -Infinity;
  for (let i = 0; i < n; i++) {
    const dx = xs[i] - cx, dz = zs[i] - cz;
    if (dx * dx + dz * dz < r2 && ys[i] > topoCentro) topoCentro = ys[i];
  }
  if (!isFinite(topoCentro)) topoCentro = max.y;
  return { cx, cz, raio, topoCentro, fundo: min.y };
}

/* ---------- o universo atrás da casa (só na chegada) ----------
   A ilha precisava de um lugar de onde flutuar. São cinco camadas, da
   mais longe para a mais perto: a cúpula com o degradê da noite, uma
   nebulosa pintada em canvas que gira devagar, o campo de estrelas que
   cintila, a lua com halo e um mar de nuvens lá embaixo. Tudo dentro da
   MESMA cena da casa, então a aproximação move o céu junto: é isso que
   dá profundidade de verdade, em vez de um fundo colado atrás. */

/* nebulosa e via láctea pintadas uma vez num canvas equiretangular:
   sai muito mais barato que ruído por pixel a cada quadro, e dá para
   compor a olho */
function texturaNebulosa(T) {
  const c = document.createElement("canvas");
  c.width = 1024; c.height = 512;
  const g = c.getContext("2d");
  g.globalCompositeOperation = "lighter";
  if ("filter" in g) g.filter = "blur(16px)";
  const cores = ["74,59,107", "42,85,96", "53,80,127", "96,74,116"];
  function mancha(x, y, r, cor, a) {
    const grd = g.createRadialGradient(x, y, 0, x, y, r);
    grd.addColorStop(0, "rgba(" + cor + "," + a + ")");
    grd.addColorStop(0.55, "rgba(" + cor + "," + (a * 0.4) + ")");
    grd.addColorStop(1, "rgba(" + cor + ",0)");
    g.fillStyle = grd;
    g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fill();
  }
  /* a via láctea: uma faixa diagonal de poeira luminosa */
  for (let i = 0; i < 90; i++) {
    const u = i / 89;
    const x = u * 1024;
    const y = 210 + Math.sin(u * 2.1) * 70 + (Math.random() - 0.5) * 96;
    mancha(x, y, 70 + Math.random() * 150, "126,140,186", 0.035 + Math.random() * 0.04);
  }
  /* nuvens de gás espalhadas, em roxo e verde-azulado bem contidos */
  for (let i = 0; i < 34; i++) {
    mancha(Math.random() * 1024, 60 + Math.random() * 380,
      90 + Math.random() * 210, cores[i % cores.length], 0.035 + Math.random() * 0.05);
  }
  const tex = new T.CanvasTexture(c);
  tex.colorSpace = T.SRGBColorSpace;
  tex.wrapS = T.RepeatWrapping;
  return tex;
}

/* Uma nuvem com silhueta, não um borrão: bolhas maiores no meio e
   achatadas nas pontas (cumulus de mar de nuvens), pouco desfoque para
   a borda existir, topo iluminado e barriga escura. Borrão chapado é o
   que fazia as nuvens parecerem sujeira no céu. */
function texturaNuvem(T) {
  const c = document.createElement("canvas");
  c.width = 256; c.height = 128;
  const g = c.getContext("2d");
  if ("filter" in g) g.filter = "blur(5px)";
  g.globalCompositeOperation = "lighter";
  const n = 19;
  for (let i = 0; i < n; i++) {
    const u = i / (n - 1);
    const cheia = Math.sin(u * Math.PI); /* 0 nas pontas, 1 no meio */
    const x = 24 + u * 208 + (Math.random() - 0.5) * 10;
    const r = 9 + cheia * 24 + Math.random() * 9;
    const y = 84 - cheia * 15 - Math.random() * 12;
    const grd = g.createRadialGradient(x, y, r * 0.15, x, y, r);
    grd.addColorStop(0, "rgba(255,255,255,0.5)");
    grd.addColorStop(0.6, "rgba(255,255,255,0.28)");
    grd.addColorStop(1, "rgba(255,255,255,0)");
    g.fillStyle = grd;
    g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fill();
  }
  g.filter = "none";
  /* o topo pega o sol e a barriga fica na sombra */
  g.globalCompositeOperation = "source-atop";
  const rampa = g.createLinearGradient(0, 26, 0, 106);
  rampa.addColorStop(0, "#FFFFFF");
  rampa.addColorStop(0.42, "#DCD2C6");
  rampa.addColorStop(1, "#6E7480");
  g.fillStyle = rampa;
  g.fillRect(0, 0, 256, 128);
  /* e as pontas morrem antes da borda, senão o retângulo do sprite
     aparece contra o céu */
  g.globalCompositeOperation = "destination-in";
  const lados = g.createLinearGradient(0, 0, 256, 0);
  lados.addColorStop(0, "rgba(0,0,0,0)");
  lados.addColorStop(0.14, "rgba(0,0,0,1)");
  lados.addColorStop(0.86, "rgba(0,0,0,1)");
  lados.addColorStop(1, "rgba(0,0,0,0)");
  g.fillStyle = lados;
  g.fillRect(0, 0, 256, 128);
  const tex = new T.CanvasTexture(c);
  tex.colorSpace = T.SRGBColorSpace;
  return tex;
}

/* Cirros: as fitas finas bem no alto, que pegam a última luz. Céu de
   fim de tarde sem elas é um degradê; com elas o olho tem onde pousar. */
function texturaCirro(T) {
  const c = document.createElement("canvas");
  c.width = 1024; c.height = 256;
  const g = c.getContext("2d");
  if ("filter" in g) g.filter = "blur(3px)";
  g.globalCompositeOperation = "lighter";
  for (let i = 0; i < 64; i++) {
    const larg = 44 + Math.random() * 210;
    const fina = 1.6 + Math.random() * 5.5;
    g.save();
    g.translate(Math.random() * 1024, 34 + Math.random() * 188);
    g.rotate((Math.random() - 0.5) * 0.17);
    g.scale(1, fina / larg);
    const grd = g.createRadialGradient(0, 0, 0, 0, 0, larg / 2);
    grd.addColorStop(0, "rgba(255,255,255,0.38)");
    grd.addColorStop(0.5, "rgba(255,255,255,0.16)");
    grd.addColorStop(1, "rgba(255,255,255,0)");
    g.fillStyle = grd;
    g.beginPath(); g.arc(0, 0, larg / 2, 0, Math.PI * 2); g.fill();
    g.restore();
  }
  /* as quatro bordas morrem, senão o retângulo do plano aparece */
  g.filter = "none";
  g.globalCompositeOperation = "destination-in";
  const h = g.createLinearGradient(0, 0, 1024, 0);
  h.addColorStop(0, "rgba(0,0,0,0)"); h.addColorStop(0.12, "rgba(0,0,0,1)");
  h.addColorStop(0.88, "rgba(0,0,0,1)"); h.addColorStop(1, "rgba(0,0,0,0)");
  g.fillStyle = h; g.fillRect(0, 0, 1024, 256);
  const v = g.createLinearGradient(0, 0, 0, 256);
  v.addColorStop(0, "rgba(0,0,0,0)"); v.addColorStop(0.2, "rgba(0,0,0,1)");
  v.addColorStop(0.8, "rgba(0,0,0,1)"); v.addColorStop(1, "rgba(0,0,0,0)");
  g.fillStyle = v; g.fillRect(0, 0, 1024, 256);
  const tex = new T.CanvasTexture(c);
  tex.colorSpace = T.SRGBColorSpace;
  return tex;
}

/* O sol: núcleo pequeno e velado, halo grande. O disco duro é o que
   estoura a imagem; o que vende a hora é o halo. */
function texturaSol(T) {
  const c = document.createElement("canvas");
  c.width = 256; c.height = 256;
  const g = c.getContext("2d");
  const halo = g.createRadialGradient(128, 128, 6, 128, 128, 126);
  halo.addColorStop(0, "rgba(255,238,206,0.8)");
  halo.addColorStop(0.06, "rgba(255,206,142,0.3)");
  halo.addColorStop(0.17, "rgba(246,176,110,0.09)");
  halo.addColorStop(0.42, "rgba(230,150,96,0.02)");
  halo.addColorStop(1, "rgba(230,150,96,0)");
  g.fillStyle = halo;
  g.fillRect(0, 0, 256, 256);
  const nucleo = g.createRadialGradient(128, 128, 0, 128, 128, 17);
  nucleo.addColorStop(0, "rgba(255,250,235,1)");
  nucleo.addColorStop(0.72, "rgba(255,232,186,0.75)");
  nucleo.addColorStop(1, "rgba(255,226,170,0)");
  g.fillStyle = nucleo;
  g.beginPath(); g.arc(128, 128, 17, 0, Math.PI * 2); g.fill();
  const tex = new T.CanvasTexture(c);
  tex.colorSpace = T.SRGBColorSpace;
  return tex;
}

/* Um pássaro em quatro tempos de asa, lado a lado numa tira: cada
   bicho anda pela tira no seu ritmo, então o bando nunca bate junto. */
function texturaPassaro(T) {
  const c = document.createElement("canvas");
  c.width = 256; c.height = 64;
  const g = c.getContext("2d");
  g.strokeStyle = "#FFFFFF";
  g.lineCap = "round";
  g.lineWidth = 3.4;
  const pontas = [-14, -5, 5, -5]; /* asa em cima, meio, embaixo, meio */
  for (let f = 0; f < 4; f++) {
    const ox = f * 64 + 32, oy = 34, a = pontas[f];
    g.beginPath();
    g.moveTo(ox - 23, oy + a);
    g.quadraticCurveTo(ox - 10, oy + a * 0.2 - 2, ox, oy);
    g.quadraticCurveTo(ox + 10, oy + a * 0.2 - 2, ox + 23, oy + a);
    g.stroke();
  }
  const tex = new T.CanvasTexture(c);
  tex.colorSpace = T.SRGBColorSpace;
  tex.wrapS = T.RepeatWrapping;
  return tex;
}

/* Poeira no ar: o que separa "modelo 3D num fundo" de "coisa dentro de
   um lugar com atmosfera". */
function criarPoeira(T, n, px, cor) {
  const pos = new Float32Array(n * 3), fase = new Float32Array(n), vel = new Float32Array(n);
  for (let i = 0; i < n; i++) {
    pos[i * 3] = (Math.random() - 0.5) * 14;
    pos[i * 3 + 1] = -3.4 + Math.random() * 6.4;
    pos[i * 3 + 2] = -4 + Math.random() * 7;
    fase[i] = Math.random() * Math.PI * 2;
    vel[i] = 0.18 + Math.random() * 0.4;
  }
  const geo = new T.BufferGeometry();
  geo.setAttribute("position", new T.BufferAttribute(pos, 3));
  geo.setAttribute("aFase", new T.BufferAttribute(fase, 1));
  geo.setAttribute("aVel", new T.BufferAttribute(vel, 1));
  return new T.Points(geo, new T.ShaderMaterial({
    uniforms: { uT: { value: 0 }, uPx: { value: px }, uCor: { value: new T.Color(cor) } },
    transparent: true, depthWrite: false, blending: T.AdditiveBlending, fog: false,
    vertexShader: [
      "attribute float aFase; attribute float aVel;",
      "uniform float uT; uniform float uPx; varying float vA;",
      "void main() {",
      "  vec3 p = position;",
      "  p.x += sin(uT * aVel * 0.5 + aFase) * 0.9;",
      "  p.y += sin(uT * aVel * 0.34 + aFase * 1.7) * 0.55;",
      "  p.z += cos(uT * aVel * 0.42 + aFase) * 0.7;",
      "  vec4 mv = modelViewMatrix * vec4(p, 1.0);",
      "  vA = 0.35 + 0.65 * (0.5 + 0.5 * sin(uT * aVel * 1.3 + aFase * 2.0));",
      "  gl_PointSize = (1.5 + 2.2 * vA) * uPx * (13.0 / -mv.z);",
      "  gl_Position = projectionMatrix * mv;",
      "}"
    ].join("\n"),
    fragmentShader: [
      "uniform vec3 uCor; varying float vA;",
      "void main() {",
      "  float d = length(gl_PointCoord - 0.5);",
      "  gl_FragColor = vec4(uCor, smoothstep(0.5, 0.04, d) * vA * 0.55);",
      "}"
    ].join("\n")
  }));
}

/* Uma serra: silhueta de cume desenhada por soma de senos, pintada com
   perspectiva aérea (o pé some na névoa) e um toque quente no lado do
   sol. É o que transforma um degradê num lugar: sem linha de horizonte
   e sem camadas, qualquer céu parece papel de parede. */
function texturaSerra(T, cores, aspereza, semente, ladoQuente) {
  const c = document.createElement("canvas");
  c.width = 2048; c.height = 512;
  const g = c.getContext("2d");
  const topo = 168;
  g.beginPath();
  g.moveTo(0, 512);
  for (let x = 0; x <= 2048; x += 6) {
    const u = x / 2048;
    let y = topo;
    y += Math.sin(u * 7.3 + semente) * 26 * aspereza;
    y += Math.sin(u * 17.1 + semente * 2.1) * 12 * aspereza;
    y += Math.sin(u * 38.9 + semente * 3.7) * 6 * aspereza;
    y += Math.sin(u * 86.0 + semente * 5.3) * 2.5 * aspereza;
    g.lineTo(x, y);
  }
  g.lineTo(2048, 512);
  g.closePath();
  const grd = g.createLinearGradient(0, topo - 70, 0, 512);
  grd.addColorStop(0, cores[0]);
  grd.addColorStop(0.45, cores[1]);
  grd.addColorStop(1, cores[2]);
  g.fillStyle = grd;
  g.fill();
  /* o sol bate de um lado só */
  g.globalCompositeOperation = "source-atop";
  const h = g.createLinearGradient(0, 0, 2048, 0);
  h.addColorStop(ladoQuente > 0 ? 0 : 1, "rgba(240,185,120,0)");
  h.addColorStop(ladoQuente > 0 ? 1 : 0, "rgba(240,185,120,0.4)");
  g.fillStyle = h;
  g.fillRect(0, 0, 2048, 512);
  /* e o pé da serra se dissolve na névoa em vez de terminar reto */
  g.globalCompositeOperation = "destination-in";
  const v = g.createLinearGradient(0, topo, 0, 512);
  v.addColorStop(0, "rgba(0,0,0,1)");
  v.addColorStop(0.62, "rgba(0,0,0,0.95)");
  v.addColorStop(1, "rgba(0,0,0,0)");
  g.fillStyle = v;
  g.fillRect(0, 0, 2048, 512);
  const tex = new T.CanvasTexture(c);
  tex.colorSpace = T.SRGBColorSpace;
  return tex;
}

/* a lua: disco com crateras de leve e um halo à parte (o halo é
   aditivo, o disco não — senão as crateras não escurecem nada) */
function texturaLua(T, halo) {
  const c = document.createElement("canvas");
  c.width = 256; c.height = 256;
  const g = c.getContext("2d");
  if (halo) {
    const grd = g.createRadialGradient(128, 128, 12, 128, 128, 126);
    grd.addColorStop(0, "rgba(214,226,255,0.34)");
    grd.addColorStop(0.22, "rgba(180,198,240,0.12)");
    grd.addColorStop(0.6, "rgba(150,172,220,0.03)");
    grd.addColorStop(1, "rgba(150,172,220,0)");
    g.fillStyle = grd;
    g.fillRect(0, 0, 256, 256);
  } else {
    const grd = g.createRadialGradient(112, 108, 4, 128, 128, 46);
    grd.addColorStop(0, "rgba(255,252,242,1)");
    grd.addColorStop(0.72, "rgba(243,233,209,1)");
    grd.addColorStop(1, "rgba(226,214,190,0)");
    g.fillStyle = grd;
    g.beginPath(); g.arc(128, 128, 46, 0, Math.PI * 2); g.fill();
    g.globalCompositeOperation = "source-atop";
    const crateras = [[112, 112, 13], [146, 134, 9], [124, 150, 7], [138, 106, 5]];
    crateras.forEach(function (k) {
      const cg = g.createRadialGradient(k[0], k[1], 0, k[0], k[1], k[2]);
      cg.addColorStop(0, "rgba(196,186,166,0.5)");
      cg.addColorStop(1, "rgba(196,186,166,0)");
      g.fillStyle = cg;
      g.beginPath(); g.arc(k[0], k[1], k[2], 0, Math.PI * 2); g.fill();
    });
  }
  const tex = new T.CanvasTexture(c);
  tex.colorSpace = T.SRGBColorSpace;
  return tex;
}

function criarCeu(T, px, cfg) {
  const ceu = new T.Group();
  /* o "astro" é para onde o céu olha: a lua na chegada, o sol que
     acabou de se pôr na tela dos cômodos */
  const astro = new T.Vector3(cfg.astro[0], cfg.astro[1], cfg.astro[2]).normalize();

  /* ---- cúpula: o degradê do céu ---- */
  const cupula = new T.Mesh(
    new T.SphereGeometry(58, 48, 32),
    new T.ShaderMaterial({
      side: T.BackSide, depthTest: false, depthWrite: false, fog: false,
      uniforms: {
        uZenite: { value: new T.Color(cfg.zenite) },
        uMeio: { value: new T.Color(cfg.meio) },
        uHorizonte: { value: new T.Color(cfg.horizonte) },
        uBaixo: { value: new T.Color(cfg.baixo) },
        uBrilho: { value: new T.Color(cfg.brilho) },
        uFaixa: { value: cfg.faixa },
        uLargura: { value: cfg.largura },
        uForca: { value: cfg.forcaBrilho },
        uHalo: { value: new T.Color(cfg.halo) },
        uForcaHalo: { value: cfg.forcaHalo },
        uLua: { value: astro }
      },
      vertexShader: [
        "varying vec3 vDir;",
        "void main() {",
        "  vDir = normalize(position);",
        "  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);",
        "}"
      ].join("\n"),
      fragmentShader: [
        "precision highp float;",
        "varying vec3 vDir;",
        "uniform vec3 uZenite, uMeio, uHorizonte, uBaixo, uBrilho, uHalo, uLua;",
        "uniform float uFaixa, uLargura, uForca, uForcaHalo;",
        "float hash21(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }",
        "void main() {",
        "  float h = vDir.y;",
        "  vec3 cor = mix(uHorizonte, uMeio, smoothstep(-0.04, 0.44, h));",
        "  cor = mix(cor, uZenite, smoothstep(0.34, 0.96, h));",
        "  cor = mix(uBaixo, cor, smoothstep(-0.86, 0.02, h));",
        "  /* a última luz do dia, baixa, por tras do mar de nuvens */",
        "  float faixa = exp(-pow((h + uFaixa) * uLargura, 2.0));",
        "  /* e ela se concentra no lado do astro, não em toda a volta */",
        "  float lado = 0.14 + 0.86 * pow(max(dot(normalize(vec3(vDir.x, 0.0, vDir.z)), normalize(vec3(uLua.x, 0.0, uLua.z))), 0.0), 1.6);",
        "  cor += uBrilho * faixa * uForca * lado;",
        "  /* halo do astro */",
        "  float d = max(dot(vDir, uLua), 0.0);",
        "  cor += uHalo * pow(d, 30.0) * uForcaHalo;",
        "  cor += uHalo * pow(d, 5.0) * uForcaHalo * 0.32;",
        "  /* sem dither o degradê aparece em faixas */",
        "  cor += (hash21(gl_FragCoord.xy) - 0.5) * 0.007;",
        "  gl_FragColor = vec4(cor, 1.0);",
        "}"
      ].join("\n")
    })
  );
  cupula.renderOrder = -10;
  cupula.frustumCulled = false;
  ceu.add(cupula);

  /* ---- nebulosa: uma casca aditiva que gira muito devagar ---- */
  let nebulosa = null;
  if (cfg.nebulosa > 0) {
    nebulosa = new T.Mesh(
      new T.SphereGeometry(55, 40, 24),
      new T.MeshBasicMaterial({
        map: texturaNebulosa(T), side: T.BackSide, transparent: true,
        blending: T.AdditiveBlending, depthWrite: false, opacity: cfg.nebulosa, fog: false
      })
    );
    nebulosa.renderOrder = -9;
    nebulosa.frustumCulled = false;
    ceu.add(nebulosa);
  }

  /* ---- serras: as camadas que dão lugar ao céu ----
     Cada uma mais longe é mais clara e mais azul (perspectiva aérea).
     Ficam nas colunas de céu à esquerda e à direita da casa, que é
     onde sobra quadro nesta tela. */
  (cfg.serras || []).forEach(function (r) {
    const plano = new T.Mesh(
      new T.PlaneGeometry(r.larg, r.alt),
      new T.MeshBasicMaterial({
        map: texturaSerra(T, r.cores, r.aspereza, r.semente, cfg.astro[0]),
        transparent: true, depthWrite: false, opacity: r.opacidade, fog: false
      })
    );
    /* o cume mora a 168/512 do topo da textura: posiciona o plano pelo
       cume, que é o que precisa cair na altura certa do quadro */
    plano.position.set(0, r.cume - r.alt * (0.5 - 168 / 512), r.z);
    plano.renderOrder = -8;
    ceu.add(plano);
  });

  /* ---- o sol, baixo, velado ---- */
  if (cfg.sol) {
    const s = new T.Sprite(new T.SpriteMaterial({
      map: texturaSol(T), transparent: true, depthWrite: false,
      blending: T.AdditiveBlending, fog: false, opacity: cfg.sol.forca
    }));
    s.position.copy(astro).multiplyScalar(cfg.sol.dist);
    s.scale.setScalar(cfg.sol.tam);
    s.renderOrder = -9;
    ceu.add(s);
  }

  /* ---- cirros: fitas altas, em duas ou três profundidades ---- */
  const texC = cfg.cirros && cfg.cirros.length ? texturaCirro(T) : null;
  const cirros = [];
  (cfg.cirros || []).forEach(function (f) {
    const plano = new T.Mesh(
      new T.PlaneGeometry(f.larg, f.larg * 0.25),
      new T.MeshBasicMaterial({
        map: texC, transparent: true, depthWrite: false, fog: false,
        color: new T.Color(f.cor), opacity: f.opacidade,
        blending: T.AdditiveBlending
      })
    );
    plano.position.set(f.x || 0, f.y, f.z);
    plano.renderOrder = -7;
    plano.userData = { x0: f.x || 0, v: 0.012 + Math.random() * 0.012, f: Math.random() * 6.3 };
    cirros.push(plano);
    ceu.add(plano);
  });

  /* ---- estrelas ---- */
  const n = cfg.estrelas;
  const pos = new Float32Array(n * 3), fase = new Float32Array(n);
  const vel = new Float32Array(n), tam = new Float32Array(n), quente = new Float32Array(n);
  for (let i = 0; i < n; i++) {
    /* distribuição uniforme na esfera; um terço se aperta na via láctea */
    let x, y, z;
    const u = Math.random() * 2 - 1, a = Math.random() * Math.PI * 2;
    const r = Math.sqrt(1 - u * u);
    x = r * Math.cos(a); y = u; z = r * Math.sin(a);
    if (i % 3 === 0) { y = y * 0.28 + 0.16; const k = Math.hypot(x, y, z); x /= k; y /= k; z /= k; }
    const raio = 40 + Math.random() * 10;
    pos[i * 3] = x * raio; pos[i * 3 + 1] = y * raio; pos[i * 3 + 2] = z * raio;
    fase[i] = Math.random() * Math.PI * 2;
    vel[i] = 0.25 + Math.random() * 0.9;
    tam[i] = Math.pow(Math.random(), 2.6) * 4.6 + 0.9;
    quente[i] = Math.random() < 0.24 ? Math.random() : 0;
  }
  const geoE = new T.BufferGeometry();
  geoE.setAttribute("position", new T.BufferAttribute(pos, 3));
  geoE.setAttribute("aFase", new T.BufferAttribute(fase, 1));
  geoE.setAttribute("aVel", new T.BufferAttribute(vel, 1));
  geoE.setAttribute("aTam", new T.BufferAttribute(tam, 1));
  geoE.setAttribute("aQuente", new T.BufferAttribute(quente, 1));
  const estrelas = new T.Points(geoE, new T.ShaderMaterial({
    uniforms: { uT: { value: 0 }, uPx: { value: px }, uForca: { value: cfg.forcaEstrelas } },
    transparent: true, depthWrite: false, blending: T.AdditiveBlending, fog: false,
    vertexShader: [
      "attribute float aFase; attribute float aVel; attribute float aTam; attribute float aQuente;",
      "uniform float uT; uniform float uPx; uniform float uForca; varying float vA; varying float vQ;",
      "void main() {",
      "  vec4 mv = modelViewMatrix * vec4(position, 1.0);",
      "  float pisca = 0.62 + 0.38 * sin(uT * aVel + aFase);",
      "  vA = pisca * uForca;",
      "  vQ = aQuente;",
      "  gl_PointSize = aTam * uPx * (0.72 + 0.34 * pisca);",
      "  gl_Position = projectionMatrix * mv;",
      "}"
    ].join("\n"),
    fragmentShader: [
      "varying float vA; varying float vQ;",
      "void main() {",
      "  float d = length(gl_PointCoord - 0.5);",
      "  float halo = smoothstep(0.5, 0.06, d);",
      "  float nucleo = smoothstep(0.22, 0.0, d);",
      "  vec3 fria = vec3(0.80, 0.87, 1.0);",
      "  vec3 morna = vec3(1.0, 0.86, 0.68);",
      "  vec3 cor = mix(fria, morna, vQ);",
      "  gl_FragColor = vec4(cor, (halo * 0.55 + nucleo * 0.75) * vA);",
      "}"
    ].join("\n")
  }));
  estrelas.renderOrder = -8;
  estrelas.frustumCulled = false;
  ceu.add(estrelas);

  /* ---- a lua (no entardecer o sol já se pôs: fica só o brilho) ---- */
  if (cfg.lua) {
    const posLua = astro.clone().multiplyScalar(46);
    const halo = new T.Sprite(new T.SpriteMaterial({
      map: texturaLua(T, true), transparent: true, depthWrite: false,
      blending: T.AdditiveBlending, fog: false
    }));
    halo.position.copy(posLua);
    halo.scale.setScalar(20);
    halo.renderOrder = -7;
    ceu.add(halo);
    const disco = new T.Sprite(new T.SpriteMaterial({
      map: texturaLua(T, false), transparent: true, depthWrite: false, fog: false
    }));
    disco.position.copy(posLua);
    disco.scale.setScalar(7);
    disco.renderOrder = -6;
    ceu.add(disco);
  }

  /* ---- mar de nuvens: a ilha passa a flutuar SOBRE alguma coisa ---- */
  const texN = texturaNuvem(T);
  const nuvens = [];
  for (let i = 0; i < cfg.nuvens; i++) {
    const longe = i / (cfg.nuvens - 1);
    const s = new T.Sprite(new T.SpriteMaterial({
      map: texN, transparent: true, depthWrite: false, fog: false,
      color: new T.Color().lerpColors(new T.Color(cfg.nuvemPerto), new T.Color(cfg.nuvemLonge), longe),
      opacity: cfg.opacidadeNuvem * (1 - longe * 0.55)
    }));
    const larg = cfg.nuvemLarg + Math.random() * cfg.nuvemVar;
    s.scale.set(larg, larg * 0.42, 1);
    /* longe do meio: é atrás da casa que a nuvem não aparece, e é nas
       colunas de céu dos lados que ela faz falta */
    s.position.set(
      (i % 2 ? 1 : -1) * (5 + Math.random() * 17),
      cfg.nuvemY - Math.random() * cfg.nuvemEspalha,
      cfg.nuvemZ - Math.random() * cfg.nuvemFundo
    );
    s.renderOrder = -5;
    s.userData = { x0: s.position.x, y0: s.position.y, v: 0.03 + Math.random() * 0.05, f: Math.random() * 6.3 };
    nuvens.push(s);
    ceu.add(s);
  }
  /* névoa rente à ilha: as raízes se dissolvem em vez de terminarem */
  for (let i = 0; i < 5; i++) {
    const s = new T.Sprite(new T.SpriteMaterial({
      map: texN, transparent: true, depthWrite: false, fog: false,
      color: new T.Color(cfg.nevoa), opacity: 0.1
    }));
    s.scale.set(9 + Math.random() * 6, 3.4, 1);
    s.position.set((Math.random() - 0.5) * 5.5, cfg.chao - 1.5 - Math.random() * 1.1, -1 + Math.random() * 3);
    s.renderOrder = -4;
    s.userData = { x0: s.position.x, y0: s.position.y, v: 0.05 + Math.random() * 0.06, f: Math.random() * 6.3 };
    nuvens.push(s);
    ceu.add(s);
  }

  /* ---- pássaros: um bando atravessando, em V frouxo ---- */
  let bando = null;
  if (cfg.passaros) {
    const texP = texturaPassaro(T);
    bando = new T.Group();
    for (let i = 0; i < cfg.passaros.n; i++) {
      const t2 = texP.clone();
      t2.needsUpdate = true;
      t2.repeat.set(0.25, 1);
      const sp = new T.Sprite(new T.SpriteMaterial({
        map: t2, transparent: true, depthWrite: false, fog: false,
        color: new T.Color(cfg.passaros.cor), opacity: cfg.passaros.opacidade
      }));
      const lado = i % 2 ? 1 : -1, fila = Math.floor(i / 2);
      const tam = cfg.passaros.tam * (0.82 + Math.random() * 0.36);
      sp.scale.set(tam, tam * 0.42, 1);
      sp.position.set(-fila * 1.35, lado * fila * 0.26 + (Math.random() - 0.5) * 0.3, -fila * 0.7);
      sp.userData = { y0: sp.position.y, fase: Math.random() * 6.3, vel: 5.5 + Math.random() * 3.5 };
      bando.add(sp);
    }
    bando.position.set(0, cfg.passaros.y, cfg.passaros.z);
    bando.userData = { y0: cfg.passaros.y, alcance: cfg.passaros.alcance, ciclo: cfg.passaros.ciclo };
    ceu.add(bando);
  }

  /* ---- poeira dourada no ar, em volta da ilha ---- */
  let poeira = null;
  if (cfg.poeira) {
    poeira = criarPoeira(T, cfg.poeira.n, px, cfg.poeira.cor);
    poeira.renderOrder = -3;
    ceu.add(poeira);
  }

  /* ---- estrela cadente: rara, rápida (só na noite fechada) ---- */
  let risco = null;
  if (cfg.cadente) {
    risco = new T.Sprite(new T.SpriteMaterial({
      map: texturaRisco(T), transparent: true, depthWrite: false,
      /* a ponta acesa vai na frente, e o rastro fica para trás: o risco
         desce para a esquerda, então ele aponta para lá */
      blending: T.AdditiveBlending, opacity: 0, fog: false, rotation: -2.74
    }));
    risco.scale.set(9, 1.6, 1);
    risco.renderOrder = -6;
    ceu.add(risco);
  }

  ceu.userData = { nebulosa, estrelas, nuvens, cirros, bando, poeira, risco,
    proxima: 7 + Math.random() * 14, ini: 0 };
  return ceu;
}

/* um risco luminoso que morre nas duas pontas */
function texturaRisco(T) {
  const c = document.createElement("canvas");
  c.width = 256; c.height = 32;
  const g = c.getContext("2d");
  const grd = g.createLinearGradient(0, 0, 256, 0);
  grd.addColorStop(0, "rgba(255,255,255,0)");
  grd.addColorStop(0.72, "rgba(214,228,255,0.5)");
  grd.addColorStop(0.94, "rgba(255,255,255,0.95)");
  grd.addColorStop(1, "rgba(255,255,255,0)");
  g.fillStyle = grd;
  g.fillRect(0, 0, 256, 32);
  /* e afina também de cima para baixo: sem isso o risco é um retângulo
     de bordas duras */
  g.globalCompositeOperation = "destination-in";
  const v = g.createLinearGradient(0, 0, 0, 32);
  v.addColorStop(0, "rgba(0,0,0,0)");
  v.addColorStop(0.5, "rgba(0,0,0,1)");
  v.addColorStop(1, "rgba(0,0,0,0)");
  g.fillStyle = v;
  g.fillRect(0, 0, 256, 32);
  const tex = new T.CanvasTexture(c);
  tex.colorSpace = T.SRGBColorSpace;
  return tex;
}

function animarCeu(ceu, t) {
  const d = ceu.userData;
  if (d.nebulosa) d.nebulosa.rotation.y = t * 0.0055;
  d.estrelas.material.uniforms.uT.value = t;
  d.estrelas.rotation.y = t * 0.0022;
  d.nuvens.forEach(function (s) {
    s.position.x = s.userData.x0 + Math.sin(t * s.userData.v + s.userData.f) * 3.2;
    s.position.y = s.userData.y0 + Math.sin(t * s.userData.v * 0.7 + s.userData.f) * 0.5;
  });
  (d.cirros || []).forEach(function (p) {
    p.position.x = p.userData.x0 + Math.sin(t * p.userData.v + p.userData.f) * 5.5;
  });
  if (d.poeira) d.poeira.material.uniforms.uT.value = t;
  /* o bando atravessa o quadro e some por um tempo antes de voltar */
  if (d.bando) {
    const b = d.bando, u = (t % b.userData.ciclo) / b.userData.ciclo;
    b.position.x = -b.userData.alcance + u * b.userData.alcance * 2;
    b.position.y = b.userData.y0 + Math.sin(t * 0.22) * 0.7;
    b.children.forEach(function (p) {
      p.material.map.offset.x = (Math.floor(t * p.userData.vel + p.userData.fase) % 4) * 0.25;
      p.position.y = p.userData.y0 + Math.sin(t * 1.5 + p.userData.fase) * 0.1;
    });
  }
  /* a estrela cadente cruza em 1,1s e some por um bom tempo */
  if (d.risco && t > d.proxima) {
    if (!d.ini) {
      d.ini = t;
      /* a faixa de céu que aparece acima da casa é estreita: o risco
         cruza dentro dela, senão passa fora do quadro */
      d.x0 = 12 + Math.random() * 14;
      d.y0 = Math.random() * 5;
    }
    const k = (t - d.ini) / 1.1;
    if (k >= 1) {
      d.risco.material.opacity = 0;
      d.ini = 0;
      d.proxima = t + 16 + Math.random() * 26;
    } else {
      d.risco.position.set(d.x0 - k * 26, d.y0 - k * 11, -34);
      d.risco.material.opacity = Math.sin(k * Math.PI) * 0.85;
    }
  }
}

/* ---------- peças da cena ---------- */

/* Vaga-lumes: pontos quentes que piscam e derivam no jardim */
function criarVagalumes(T, n, px) {
  const pos = new Float32Array(n * 3), fase = new Float32Array(n), vel = new Float32Array(n);
  for (let i = 0; i < n; i++) {
    pos[i * 3] = (Math.random() - 0.5) * 5.2;
    pos[i * 3 + 1] = BASE + 0.15 + Math.random() * 2.4;
    pos[i * 3 + 2] = (Math.random() - 0.5) * 4.6;
    fase[i] = Math.random() * Math.PI * 2;
    vel[i] = 0.35 + Math.random() * 0.6;
  }
  const geo = new T.BufferGeometry();
  geo.setAttribute("position", new T.BufferAttribute(pos, 3));
  geo.setAttribute("aFase", new T.BufferAttribute(fase, 1));
  geo.setAttribute("aVel", new T.BufferAttribute(vel, 1));
  const mat = new T.ShaderMaterial({
    uniforms: { uT: { value: 0 }, uPx: { value: px } },
    vertexShader: [
      "attribute float aFase; attribute float aVel;",
      "uniform float uT; uniform float uPx; varying float vA;",
      "void main() {",
      "  vec3 p = position;",
      "  p.x += sin(uT * aVel + aFase) * 0.22;",
      "  p.y += sin(uT * aVel * 0.7 + aFase * 1.3) * 0.14;",
      "  p.z += cos(uT * aVel * 0.9 + aFase) * 0.22;",
      "  vec4 mv = modelViewMatrix * vec4(p, 1.0);",
      "  float pisca = 0.5 + 0.5 * sin(uT * 1.7 * aVel + aFase * 2.0);",
      "  vA = smoothstep(0.3, 1.0, pisca);",
      "  gl_PointSize = (2.4 + 4.4 * vA) * uPx * (11.0 / -mv.z);",
      "  gl_Position = projectionMatrix * mv;",
      "}"
    ].join("\n"),
    fragmentShader: [
      "varying float vA;",
      "void main() {",
      "  float d = length(gl_PointCoord - 0.5);",
      "  float a = smoothstep(0.5, 0.05, d) * vA;",
      "  gl_FragColor = vec4(1.0, 0.78, 0.42, a);",
      "}"
    ].join("\n"),
    transparent: true, depthWrite: false, blending: T.AdditiveBlending
  });
  return new T.Points(geo, mat);
}

/* Mancha radial deitada no chão (poça de luz ou oclusão de contato).
   Gradiente elíptico que chega a zero DENTRO do canvas nos dois
   eixos — senão a borda do plano aparece como retângulo. */
function criarMancha(T, rgb, alfa, larg, prof, y) {
  const c = document.createElement("canvas");
  c.width = 128; c.height = 64;
  const g = c.getContext("2d");
  g.translate(64, 32);
  g.scale(1, 0.5);
  const grd = g.createRadialGradient(0, 0, 2, 0, 0, 64);
  grd.addColorStop(0, "rgba(" + rgb + "," + alfa + ")");
  grd.addColorStop(0.45, "rgba(" + rgb + "," + (alfa * 0.35) + ")");
  grd.addColorStop(0.84, "rgba(" + rgb + ",0)");
  grd.addColorStop(1, "rgba(" + rgb + ",0)");
  g.fillStyle = grd; g.fillRect(-64, -64, 128, 128);
  const mesh = new T.Mesh(
    new T.PlaneGeometry(larg, prof),
    new T.MeshBasicMaterial({ map: new T.CanvasTexture(c), transparent: true, depthWrite: false })
  );
  mesh.rotation.x = -Math.PI / 2;
  mesh.position.y = y;
  return mesh;
}

/* ---------- base compartilhada (renderer + diorama) ---------- */

let base = null;        /* { T, renderer, cena, camera, giro, ... } */
let carregando = null;  /* promise da base */

async function carregarBase() {
  const T = await import("./vendor/three.module.min.js");
  const { GLTFLoader } = await import("./vendor/GLTFLoader.js");
  const { MeshoptDecoder } = await import("./vendor/meshopt_decoder.module.js");
  const { RoomEnvironment } = await import("./vendor/RoomEnvironment.js");

  const loader = new GLTFLoader();
  loader.setMeshoptDecoder(MeshoptDecoder);
  const [gltf, jardim] = await Promise.all([
    loader.loadAsync(MODELO),
    loader.loadAsync(JARDIM).catch(function () { return null; })
  ]);

  const renderer = new T.WebGLRenderer({ alpha: true, antialias: true });
  renderer.setClearColor(0x000000, 0);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.toneMapping = T.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.0;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = T.PCFSoftShadowMap;
  renderer.localClippingEnabled = true;

  const cena = new T.Scene();
  const pmrem = new T.PMREMGenerator(renderer);
  cena.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
  pmrem.dispose();

  const camera = new T.PerspectiveCamera(30, 1, 0.1, 100);
  camera.position.set(0, 2.4, 11.2);
  camera.lookAt(0, 0, 0);
  const pouso = { y: 2.4, z: 11.2 };
  /* a câmera da chegada persegue um alvo: serve para a entrada e para a
     aproximação até a casa abrir */
  const cam = {
    pos: new T.Vector3(0, pouso.y, pouso.z),
    alvo: new T.Vector3(0, pouso.y, pouso.z),
    olhar: new T.Vector3(0, 0, 0),
    olharAlvo: new T.Vector3(0, 0, 0),
    passo: 0.045
  };

  /* noite: céu azul + luar frio com sombra + luz quente que vaza das
     janelas e da porta (a casa acesa é o ponto quente da cena) */
  cena.add(new T.HemisphereLight(0x8FA6D6, 0x1C2238, 0.85));
  const lua = new T.DirectionalLight(0xD6E2FF, 1.6);
  lua.position.set(-3.5, 6.5, 3.5);
  lua.castShadow = true;
  lua.shadow.mapSize.set(2048, 2048);
  lua.shadow.camera.near = 1;
  lua.shadow.camera.far = 24;
  lua.shadow.camera.left = -3.6;
  lua.shadow.camera.right = 3.6;
  lua.shadow.camera.top = 3.6;
  lua.shadow.camera.bottom = -3.6;
  lua.shadow.bias = -0.0004;
  lua.shadow.normalBias = 0.03;
  cena.add(lua);
  const calor = new T.PointLight(0xFFB86B, 9, 7, 1.6);
  calor.position.set(0.2, BASE + 0.45, 1.9);
  cena.add(calor);
  const frente = new T.DirectionalLight(0xFFD2A0, 0.45);
  frente.position.set(0, 2, 6);
  cena.add(frente);

  /* o diorama inteiro balança junto */
  const giro = new T.Group();
  cena.add(giro);

  /* ---- casa ---- */
  const casa = gltf.scene;
  casa.traverse(function (o) {
    if (!o.isMesh) return;
    o.castShadow = true;
    o.receiveShadow = true;
    if (o.material) o.material.envMapIntensity = 0.35;
  });
  const medida = medirCasa(casa, T);
  let esc = 2.55 / medida.larguraCorpo;
  if ((medida.topo - medida.base) * esc > 3.4) esc = 3.4 / (medida.topo - medida.base);
  casa.scale.setScalar(esc);
  const caixaC = new T.Box3().setFromObject(casa);
  const centroC = caixaC.getCenter(new T.Vector3());
  casa.position.sub(centroC);
  casa.position.y += BASE - (medida.base * esc - centroC.y);
  if (medida.temChao) {
    /* corta o "chão" gerado junto com a casa; o corte fica no gramado */
    const corte = new T.Plane(new T.Vector3(0, 1, 0), -(BASE + 0.012));
    casa.traverse(function (o) {
      if (o.isMesh && o.material) { o.material.clippingPlanes = [corte]; o.material.clipShadows = true; }
    });
  }
  const pivoCasa = new T.Group();
  pivoCasa.add(casa);
  pivoCasa.rotation.y = -Math.PI / 2; /* a frente do modelo nasce em +x */
  giro.add(pivoCasa);

  /* caixa invisível da casa para o clique (raycast barato) */
  const tamC = new T.Box3().setFromObject(pivoCasa).getSize(new T.Vector3());
  const alvoCasa = new T.Mesh(
    new T.BoxGeometry(tamC.x, tamC.y, tamC.z),
    new T.MeshBasicMaterial({ visible: false })
  );
  alvoCasa.position.set(0, BASE + tamC.y / 2, 0);
  giro.add(alvoCasa);

  /* ---- ilha-jardim ---- */
  let ilhaModelo = null;
  if (jardim) {
    const ilha = jardim.scene;
    ilhaModelo = ilha;
    ilha.traverse(function (o) {
      if (!o.isMesh) return;
      o.castShadow = true;
      o.receiveShadow = true;
      if (o.material) o.material.envMapIntensity = 0.25;
    });
    const m = medirIlha(ilha, T);
    const escI = (DIAMETRO_ILHA / 2) / m.raio;
    ilha.scale.setScalar(escI);
    ilha.position.set(-m.cx * escI, (BASE + 0.01) - m.topoCentro * escI, -m.cz * escI);
    const pivoIlha = new T.Group();
    pivoIlha.add(ilha);
    pivoIlha.rotation.y = -Math.PI / 2;
    giro.add(pivoIlha);
  }

  giro.add(criarMancha(T, "255,178,96", 0.34, 3.6, 2.0, BASE + 0.03));
  giro.add(criarMancha(T, "5,10,24", 0.5, 3.2, 1.4, BASE + 0.035));
  const vagalumes = criarVagalumes(T, 34, renderer.getPixelRatio());
  giro.add(vagalumes);

  /* o ponteiro na página inteira inclina o diorama de leve */
  const mira = { x: 0, y: 0 };
  window.addEventListener("pointermove", (e) => {
    mira.x = (e.clientX / window.innerWidth - 0.5) * 2;
    mira.y = (e.clientY / window.innerHeight - 0.5) * 2;
  }, { passive: true });

  return { T, renderer, loader, cena, camera, giro, alvoCasa, vagalumes, mira, pouso, cam, ilhaModelo };
}

/* ---------- casa em corte (maquete com 9 cômodos clicáveis) ---------- */

const CORTE = "assets/modelos/casa-corte-web.glb?v=1";
/* posição dos cômodos na caixa do modelo (frações: colunas da esquerda
   para a direita; linhas de baixo para cima — térreo, meio, sótão) */
const GRADE = { colunas: [0.235, 0.5, 0.765], linhas: [0.2, 0.44, 0.66], altura: 0.2, fundo: 0.3 };
/* a casa aberta apoia em y = -2 e recebe a mesma ilha da chegada */
const CHAO_CORTE = -2.0;
const DIAMETRO_CHAO = 6.6;

/* As duas horas do dia que o produto tem. A chegada é noite fechada: a
   casa está apagada por fora e acesa por dentro. A casa aberta é o fim
   da tarde, porque a maquete é iluminada por uma chave quente vinda da
   direita (a mesma luz de sol baixo) e precisa continuar legível: com
   noite fechada os nove cômodos perderiam contraste. */
const CEU_NOITE = {
  zenite: 0x060F24, meio: 0x0E2044, horizonte: 0x1D3F6B, baixo: 0x070E1F,
  brilho: 0x8A5F52, faixa: 0.22, largura: 5.0, forcaBrilho: 0.34,
  halo: 0x9EB4DC, forcaHalo: 0.11,
  astro: [-0.70, 0.06, -0.71], /* lua baixa e à esquerda: cabe no quadro
     estreito acima da casa e concorda com o luar que faz a sombra */
  lua: true, cadente: true,
  estrelas: 900, forcaEstrelas: 1, nebulosa: 0.2,
  nuvens: 22, nuvemPerto: 0x8AA0CC, nuvemLonge: 0x2A3A60, opacidadeNuvem: 0.24,
  nuvemY: -6, nuvemEspalha: 14, nuvemLarg: 11, nuvemVar: 17, nuvemZ: -8, nuvemFundo: 26,
  nevoa: 0x6E82AE, chao: BASE
};
const CEU_TARDE = {
  /* azul fundo em cima descendo para um cinza-claro de bruma no
     horizonte; o dourado NÃO está no degradê, vem só da faixa do sol,
     que é o que evita o roxo-com-salmão de fundo de tela */
  zenite: 0x102A48, meio: 0x2C587C, horizonte: 0x8E9DA3, baixo: 0xAFAFA6,
  brilho: 0xEDAF6E, faixa: 0.06, largura: 5.6, forcaBrilho: 0.34,
  halo: 0xFFC98C, forcaHalo: 0.16,
  /* o sol está posto no céu, e o brilho e o halo saem dele: quando os
     três discordam, o céu deixa de ter uma fonte de luz e vira fundo */
  astro: [0.40, -0.05, -0.915],
  sol: { dist: 42, tam: 4.4, forca: 0.6 },
  lua: false, cadente: false,
  estrelas: 220, forcaEstrelas: 0.32, nebulosa: 0,
  cirros: [
    { z: -50, y: 9.5, larg: 96, cor: 0xE8B98E, opacidade: 0.34 },
    { z: -40, y: 4.6, x: -8, larg: 74, cor: 0xD9A57F, opacidade: 0.28 },
    { z: -30, y: 1.6, x: 10, larg: 58, cor: 0xC79274, opacidade: 0.2 }
  ],
  /* SEM serras e SEM mar de nuvens: eram eles que criavam uma linha de
     horizonte no meio da tela, e é dela que vinha a emenda esquisita de
     "céu virando mar". Aqui é só entardecer aberto — a ilha flutua no ar
     e o céu se resolve no papel da página. Para trazer as montanhas de
     volta basta devolver a lista `serras` (texturaSerra continua no
     módulo) e `nuvens` a 14. */
  serras: [],
  nuvens: 0,
  passaros: { n: 9, tam: 0.66, y: 5.2, z: -21, alcance: 30, ciclo: 42,
    cor: 0x3B4756, opacidade: 0.75 },
  poeira: { n: 70, cor: 0xFFD9A0 },
  /* a névoa rente às raízes fica: é ela que dissolve a terra da ilha */
  nevoa: 0xD8CBBE, chao: CHAO_CORTE
};
let corte = null, carregandoCorte = null;

async function carregarCorte() {
  const b = await (carregando || (carregando = carregarBase()));
  base = b; /* a maquete pode ser a primeira tela: garante a base compartilhada */
  const T = b.T;
  const gltf = await b.loader.loadAsync(CORTE);

  const cena = new T.Scene();
  cena.environment = b.cena.environment;
  const camera = new T.PerspectiveCamera(30, 1, 0.1, 100);
  camera.position.set(0, 0.75, 13.0);
  camera.lookAt(0, -0.6, 0);

  /* maquete vista de frente: luz clara e quente, sombra suave */
  cena.add(new T.HemisphereLight(0xFFF1DC, 0x9C8B78, 0.95));
  const chave = new T.DirectionalLight(0xFFDFB2, 1.55);
  chave.position.set(2.5, 5, 6);
  chave.castShadow = true;
  chave.shadow.mapSize.set(2048, 2048);
  chave.shadow.camera.near = 1;
  chave.shadow.camera.far = 24;
  chave.shadow.camera.left = -3.5;
  chave.shadow.camera.right = 3.5;
  chave.shadow.camera.top = 3.5;
  chave.shadow.camera.bottom = -3.5;
  chave.shadow.bias = -0.0004;
  chave.shadow.normalBias = 0.03;
  cena.add(chave);
  /* o lado na sombra pega o azul do céu: é o que costura a maquete no
     ambiente em vez de deixá-la recortada sobre ele */
  const frio = new T.DirectionalLight(0xAEC4EC, 0.52);
  frio.position.set(-4, 2, 3);
  cena.add(frio);

  const giro = new T.Group();
  cena.add(giro);

  /* a casa aberta fica no mesmo lugar da chegada, no fim da tarde */
  const ceu = criarCeu(T, b.renderer.getPixelRatio(), CEU_TARDE);
  cena.add(ceu);

  const modelo = gltf.scene;
  modelo.traverse(function (o) {
    if (!o.isMesh) return;
    o.castShadow = true;
    o.receiveShadow = true;
    /* a névoa da cena existe SÓ para as ilhas distantes: a maquete e o
       gramado ficam de fora, senão a casa perde contraste */
    if (o.material) { o.material.envMapIntensity = 0.3; o.material.fog = false; }
  });
  /* a frente do modelo nasce em +x: gira antes de medir */
  const pivo = new T.Group();
  pivo.add(modelo);
  pivo.rotation.y = -Math.PI / 2;
  pivo.updateMatrixWorld(true);
  const caixa1 = new T.Box3().setFromObject(pivo);
  const tam1 = caixa1.getSize(new T.Vector3());
  let esc = 4.7 / tam1.x;
  if (tam1.y * esc > 4.3) esc = 4.3 / tam1.y;
  pivo.scale.setScalar(esc);
  pivo.updateMatrixWorld(true);
  const caixa2 = new T.Box3().setFromObject(pivo);
  const centro = caixa2.getCenter(new T.Vector3());
  pivo.position.x -= centro.x;
  pivo.position.z -= centro.z;
  pivo.position.y += -2.0 - caixa2.min.y;
  giro.add(pivo);
  pivo.updateMatrixWorld(true);
  const caixa = new T.Box3().setFromObject(pivo);

  /* o mesmo chão de gramado da chegada, agora sob a casa aberta: um
     clone (compartilha geometria e textura, não pesa nada) */
  if (b.ilhaModelo) {
    const ilha = b.ilhaModelo.clone(true);
    /* o original já foi escalado e posicionado para a chegada: o clone
       herda tudo isso, então volta ao estado cru antes de medir */
    ilha.position.set(0, 0, 0);
    ilha.rotation.set(0, 0, 0);
    ilha.scale.setScalar(1);
    ilha.updateMatrixWorld(true);
    const mi = medirIlha(ilha, T);
    const escI = (DIAMETRO_CHAO / 2) / mi.raio;
    ilha.scale.setScalar(escI);
    ilha.position.set(-mi.cx * escI, (CHAO_CORTE + 0.02) - mi.topoCentro * escI, -mi.cz * escI);
    /* corta a ilha na altura do gramado: as árvores e o lampião
       cobririam os cômodos. Fica o chão com as moitas rentes, as pedras
       e a terra com raízes. O material é clonado para o corte não
       atingir a ilha da chegada. */
    const corteChao = new T.Plane(new T.Vector3(0, -1, 0), CHAO_CORTE + 0.5);
    ilha.traverse(function (o) {
      if (!o.isMesh) return;
      o.castShadow = false;
      o.receiveShadow = true;
      /* a ilha mora no mesmo grupo da maquete: sem esta marca, afiarMaquete
         trocaria a textura do jardim pela textura 4K da casa */
      o.userData.ilha = true;
      if (o.material) {
        o.material = o.material.clone();
        o.material.clippingPlanes = [corteChao];
        o.material.clipShadows = true;
        o.material.fog = false;
      }
    });
    const pivoIlha = new T.Group();
    pivoIlha.add(ilha);
    /* mesmo giro da chegada: é ele que põe o caminho de pedra de frente,
       chegando aos degraus da casa. Medindo a massa alta da ilha, as duas
       árvores caem atrás (105° e 220°) e o que sobra na frente é o
       caminho, as moitas floridas e o pedestal do lampião ao lado dele. */
    pivoIlha.rotation.y = -Math.PI / 2;
    pivoIlha.position.z = -0.35;
    giro.add(pivoIlha);

    /* ---- duas ilhas menores boiando ao longe ----
       Geometria de verdade, não silhueta pintada: em sprite elas viravam
       mancha. Ficam FORA do `giro`, senão balançariam junto com a casa a
       cada movimento do ponteiro, o que denunciaria que são cenário. A
       perspectiva aérea vem da névoa da cena, que só elas recebem. */
    [
      { x: -15, y: 2.2, z: -24, diametro: 3.0, giro: 2.1, tombo: 0.07 },
      { x: 18, y: -7, z: -29, diametro: 4.2, giro: -1.2, tombo: -0.05 }
    ].forEach(function (d) {
      const longe = b.ilhaModelo.clone(true);
      /* o clone herda a escala e a posição do original (já ajustadas para
         a chegada): volta ao estado cru antes de medir */
      longe.position.set(0, 0, 0);
      longe.rotation.set(0, 0, 0);
      longe.scale.setScalar(1);
      longe.updateMatrixWorld(true);
      const md = medirIlha(longe, T);
      const escD = (d.diametro / 2) / md.raio;
      longe.scale.setScalar(escD);
      longe.position.set(-md.cx * escD, -md.topoCentro * escD, -md.cz * escD);
      longe.traverse(function (o) {
        if (!o.isMesh) return;
        o.castShadow = false;
        o.receiveShadow = false;
        o.userData.ilha = true; /* afiarMaquete não troca a textura delas */
      });
      const pivoLonge = new T.Group();
      pivoLonge.add(longe);
      pivoLonge.rotation.set(d.tombo, d.giro, d.tombo * 0.7);
      pivoLonge.position.set(d.x, d.y, d.z);
      cena.add(pivoLonge);
    });
    cena.fog = new T.FogExp2(0x9AA8B0, 0.022);
  }

  /* a câmera desliza entre enquadramentos (casa inteira ↔ um cômodo) */
  const cam = {
    pos: new T.Vector3(0, 0.9, 9.8), alvo: new T.Vector3(0, 0.05, 0),
    posAlvo: new T.Vector3(0, 0.9, 9.8), alvoAlvo: new T.Vector3(0, 0.05, 0)
  };
  return { cena, camera, giro, caixa, cam, ceu, comodos: [], alvos: [], marcadores: null, quarto: null };
}

/* enquadramento de um cômodo da maquete (coluna 0-2, linha 0=térreo) */
function molduraQuarto(c, coluna, linha) {
  const T = base.T;
  const tam = c.caixa.getSize(new T.Vector3());
  const x = c.caixa.min.x + tam.x * GRADE.colunas[coluna];
  const y = c.caixa.min.y + tam.y * GRADE.linhas[linha];
  const zF = c.caixa.max.z, prof = tam.z * 0.6;
  return {
    pos: new T.Vector3(x, y + 0.04, zF + 0.9),
    alvo: new T.Vector3(x, y - 0.03, zF - prof * 0.45),
    x, y, zF, prof, larg: tam.x / 3, alt: tam.y * GRADE.altura
  };
}

/* aplica o deslize da câmera (com paralaxe leve do ponteiro) */
function moverCamera(c, k) {
  const b = base;
  c.cam.pos.lerp(c.cam.posAlvo, k);
  c.cam.alvo.lerp(c.cam.alvoAlvo, k);
  c.camera.position.set(c.cam.pos.x + b.mira.x * 0.07, c.cam.pos.y + b.mira.y * 0.04, c.cam.pos.z);
  c.camera.lookAt(c.cam.alvo);
}

/* cria alvos invisíveis e uma luz por cômodo, na grade da maquete */
function configurarComodos(c, lista, debug) {
  const T = base.T;
  c.alvos.forEach(function (a) { c.giro.remove(a); });
  c.comodos.forEach(function (k) { c.giro.remove(k.luz); });
  if (c.marcadores) { c.giro.remove(c.marcadores); c.marcadores = null; }
  c.alvos = []; c.comodos = [];
  const tam = c.caixa.getSize(new T.Vector3());
  const larg = tam.x / 3, alt = tam.y * GRADE.altura, prof = tam.z * 0.6;
  const zLuz = c.caixa.max.z - tam.z * GRADE.fundo;
  const marc = debug ? new T.Group() : null;
  (lista || []).forEach(function (info) {
    const x = c.caixa.min.x + tam.x * GRADE.colunas[info.coluna];
    const y = c.caixa.min.y + tam.y * GRADE.linhas[info.linha];
    const alvo = new T.Mesh(new T.BoxGeometry(larg * 0.92, alt, prof), new T.MeshBasicMaterial({ visible: false }));
    alvo.position.set(x, y, c.caixa.max.z - prof / 2);
    alvo.userData.id = info.id;
    c.giro.add(alvo);
    c.alvos.push(alvo);
    const luz = new T.PointLight(new T.Color(info.cor || "#F7C873"), 0, 1.9, 1.8);
    luz.position.set(x, y + alt * 0.15, zLuz);
    c.giro.add(luz);
    c.comodos.push({ id: info.id, luz, alvo, visitado: !!info.visitado, sugerido: !!info.sugerido, hover: false, fase: Math.random() * 6 });
    if (marc) {
      const m = new T.Mesh(new T.SphereGeometry(0.06, 12, 12), new T.MeshBasicMaterial({ color: 0xFF2D55 }));
      m.position.set(x, y, c.caixa.max.z + 0.05);
      marc.add(m);
    }
  });
  if (marc) { c.giro.add(marc); c.marcadores = marc; }
}

/* intensidade alvo de cada cômodo: apagado, visitado (meia-luz),
   sugerido (pulsa) ou sob o ponteiro (acende de vez) */
function atualizarLuzes(c, t) {
  c.comodos.forEach(function (k) {
    let alvo = 0;
    if (k.visitado) alvo = 1.4;
    if (k.sugerido) alvo = 3.2 + Math.sin(t * 1.6 + k.fase) * 1.4;
    if (k.hover) alvo = 7.5;
    if (k.foco) alvo = 6.5 + Math.sin(t * 1.1 + k.fase) * 0.6; /* dentro do cômodo */
    k.luz.intensity += (alvo - k.luz.intensity) * 0.12;
  });
}

/* ---------- quarto: a câmera entra num cômodo da própria maquete ---------- */

/* de perto, a maquete troca para a textura 4K do modelo original
   (carregada só na primeira visita a um cômodo) */
const TEXTURA_4K = "assets/modelos/casa-corte-4k.webp?v=1";
let tex4k = null, carregandoTex4k = null;
function afiarMaquete(c) {
  if (!carregandoTex4k) {
    const T = base.T;
    carregandoTex4k = new T.TextureLoader().loadAsync(TEXTURA_4K).then(function (t) {
      t.flipY = false;
      t.colorSpace = T.SRGBColorSpace;
      t.anisotropy = Math.min(8, base.renderer.capabilities.getMaxAnisotropy());
      tex4k = t;
      return t;
    }).catch(function () { return null; });
  }
  carregandoTex4k.then(function (t) {
    if (!t) return;
    c.giro.traverse(function (o) {
      if (o.isMesh && !o.userData.ilha && o.material && o.material.map && o.geometry.attributes.position.count > 1000 && o.material.map !== t) {
        o.material.map = t;
        o.material.needsUpdate = true;
      }
    });
  });
}

/* a aproximação é o gesto de ENTRAR: acontece na primeira visita à
   sala; depois disso a pessoa já está dentro e vai direto ao ambiente */
const salasEntradas = new Set();

async function montarQuarto(host, opts) {
  if (!host || REDUZIDO) return null;
  opts = opts || {};
  if (opts.reserva && salasEntradas.has(opts.roomId)) return montarSala(host, opts.reserva);
  let c;
  try {
    if (!carregandoCorte) carregandoCorte = carregarCorte();
    c = corte = await carregandoCorte;
  } catch (e) {
    carregandoCorte = null;
    /* sem a maquete, a sala cai para a foto com profundidade */
    return opts.reserva ? montarSala(host, opts.reserva) : desistir(host);
  }
  if (!host.isConnected) return null;
  const T = base.T;
  if (!c.comodos.length && opts.comodos) configurarComodos(c, opts.comodos, false);

  /* o cômodo em foco acende; os outros descansam */
  c.comodos.forEach(function (k) { k.hover = false; k.foco = k.id === opts.roomId; });

  /* voo da câmera até o cômodo (parte de onde ela estava — da casa
     inteira, se a pessoa veio da maquete) */
  const q = molduraQuarto(c, opts.coluna || 0, opts.linha || 0);
  c.cam.posAlvo.copy(q.pos);
  c.cam.alvoAlvo.copy(q.alvo);
  if (!montagem || montagem.tipo !== "corte") {
    /* chegada direta: começa um pouco recuada e entra */
    c.cam.pos.set(q.x, q.y + 0.5, q.zF + 6.5);
    c.cam.alvo.copy(q.alvo);
  }

  /* marcadores dos objetos: distribuídos pela largura do cômodo em
     duas alturas e grudados na primeira superfície que o raio encontra */
  if (c.quarto) { c.quarto.marcadores.forEach(function (m) { c.giro.remove(m.sprite); }); }
  if (!texMarcador) texMarcador = texturaMarcador(T);
  const malhas = [];
  c.giro.traverse(function (o) { if (o.isMesh && o.geometry.attributes.position.count > 1000) malhas.push(o); });
  const ray = new T.Raycaster();
  const marcadores = [];
  const objetos = opts.objetos || [];
  const n = objetos.length || 1;
  objetos.forEach(function (o, i) {
    const u = (i + 0.5) / n - 0.5;                    /* -0.5 .. 0.5 pela largura */
    const v = (i % 2 === 0) ? -0.22 : 0.06;            /* alterna chão / meia-altura */
    const ponto = new T.Vector3(q.x + u * q.larg * 0.64, q.y + v * q.alt, q.zF);
    const origem = q.pos.clone();
    ray.set(origem, ponto.clone().sub(origem).normalize());
    ray.far = 12;
    const hits = ray.intersectObjects(malhas, false).filter(function (h) { return h.point.z < q.zF + 0.05; });
    const p = hits.length ? hits[0].point.clone() : new T.Vector3(ponto.x, ponto.y, q.zF - q.prof * 0.85);
    p.z += 0.03;
    const mat = new T.SpriteMaterial({ map: texMarcador, transparent: true, depthWrite: false, depthTest: false, opacity: o.visitado ? 0.55 : 0.95 });
    const sprite = new T.Sprite(mat);
    sprite.position.copy(p);
    sprite.renderOrder = 10;
    const tam = 0.11;
    sprite.scale.set(tam, tam, 1);
    sprite.userData.base = tam;
    c.giro.add(sprite);
    marcadores.push({ id: o.id, nome: o.nome, sprite, visitado: !!o.visitado, hover: false, fase: Math.random() * 6 });
  });
  c.quarto = { marcadores, q, t0: performance.now(), aoChegar: null };
  afiarMaquete(c); /* já pronta se a casa foi aquecida; nunca refaz */

  const m = prepararMontagem(host, opts, "quarto", c.camera);
  if (!m) return null;

  /* ao chegar dentro do cômodo, a maquete se dissolve na sala
     fotográfica com profundidade (pré-carregada durante o voo) */
  if (opts.reserva) {
    preCarregarSala(opts.reserva);
    let virou = false;
    const virar = function () {
      if (virou || !host.isConnected) return;
      virou = true;
      salasEntradas.add(opts.roomId);
      /* mergulho final: a câmera continua entrando enquanto a luz sobe */
      c.cam.posAlvo.z -= 0.5;
      c.cam.alvoAlvo.z -= 0.18;
      /* a luz da chegada cobre a troca de cena: nada de crossfade entre
         imagens de enquadramentos diferentes */
      const luz = cortinaDo(host);
      luz.classList.add("acesa");
      setTimeout(function () {
        if (!host.isConnected) return;
        montarSala(host, opts.reserva, true).then(function () {
          requestAnimationFrame(function () { luz.classList.remove("acesa"); });
        });
      }, 430);
    };
    c.quarto.aoChegar = virar;
    setTimeout(virar, 3600); /* garantia mesmo com a aba parada */
  }

  const rotulo = document.createElement("div");
  rotulo.className = "sala3d-rotulo";
  rotulo.hidden = true;
  m.wrap.appendChild(rotulo);

  const v = new T.Vector3();
  function perto(e) {
    const r = base.renderer.domElement.getBoundingClientRect();
    let melhor = null, dist = 32;
    marcadores.forEach(function (k) {
      v.copy(k.sprite.position).project(c.camera);
      const sx = r.left + (v.x + 1) / 2 * r.width, sy = r.top + (1 - v.y) / 2 * r.height;
      const dd = Math.hypot(sx - e.clientX, sy - e.clientY);
      if (dd < dist) { dist = dd; melhor = { k, sx: sx - r.left, sy: sy - r.top }; }
    });
    return melhor;
  }
  let atual = null;
  m.wrap.addEventListener("pointermove", function (e) {
    const p = perto(e), id = p ? p.k.id : null;
    marcadores.forEach(function (k) { k.hover = k.id === id; });
    m.wrap.style.cursor = id ? "pointer" : "";
    if (p) {
      rotulo.textContent = p.k.nome;
      rotulo.style.left = p.sx + "px";
      rotulo.style.top = (p.sy - 14) + "px";
      rotulo.hidden = false;
    } else rotulo.hidden = true;
    if (id !== atual) { atual = id; if (opts.aoPassar) opts.aoPassar(id); }
  });
  m.wrap.addEventListener("pointerleave", function () {
    atual = null;
    marcadores.forEach(function (k) { k.hover = false; });
    rotulo.hidden = true;
    if (opts.aoPassar) opts.aoPassar(null);
  });
  m.wrap.addEventListener("click", function (e) {
    const p = perto(e);
    if (p && opts.aoClicar) opts.aoClicar(p.k.id);
  });
  return m.controle;
}

function atualizarQuarto(c, t) {
  /* a maquete volta a ficar reta e a câmera termina o voo */
  c.giro.rotation.y += (0 - c.giro.rotation.y) * 0.08;
  c.giro.rotation.x += (0 - c.giro.rotation.x) * 0.08;
  moverCamera(c, 0.055);
  atualizarLuzes(c, t);
  /* chegou perto o bastante: hora de virar sala fotográfica (com um
     tempo mínimo, para a aproximação ser vista de verdade) */
  if (c.quarto && c.quarto.aoChegar && c.cam.pos.distanceTo(c.cam.posAlvo) < 0.14 &&
      performance.now() - c.quarto.t0 > 1700) {
    const f = c.quarto.aoChegar; c.quarto.aoChegar = null; f();
  }
  (c.quarto ? c.quarto.marcadores : []).forEach(function (m) {
    const pulso = 1 + Math.sin(t * 2.2 + m.fase) * 0.08;
    const alvo = m.sprite.userData.base * pulso * (m.hover ? 1.45 : 1);
    m.sprite.scale.x += (alvo - m.sprite.scale.x) * 0.2;
    m.sprite.scale.y = m.sprite.scale.x;
    const op = m.hover ? 1 : (m.visitado ? 0.55 : 0.95);
    m.sprite.material.opacity += (op - m.sprite.material.opacity) * 0.2;
  });
}

async function montarCorte(host, opts) {
  if (!host || REDUZIDO) return null;
  opts = opts || {};
  let c;
  try {
    if (!carregandoCorte) carregandoCorte = carregarCorte();
    c = corte = await carregandoCorte;
  } catch (e) {
    carregandoCorte = null;
    if (opts.aoPronto) opts.aoPronto(false);
    return desistir(host);
  }
  if (!host.isConnected) return null;
  configurarComodos(c, opts.comodos, /[?&]grade=1/.test(location.search));
  /* volta ao enquadramento da casa inteira (deslizando, se vinha de um cômodo) */
  c.cam.posAlvo.set(0, 0.75, 13.0);
  c.cam.alvoAlvo.set(0, -0.6, 0);
  /* se a pessoa veio se aproximando da casa fechada, a maquete continua
     o movimento: entra de perto e assenta */
  if (veioAproximando) {
    veioAproximando = false;
    c.cam.pos.set(0, 0.45, 8.8);
    c.cam.alvo.set(0, -0.6, 0);
  }
  if (c.quarto) { c.quarto.marcadores.forEach(function (k) { c.giro.remove(k.sprite); }); c.quarto = null; }
  const m = prepararMontagem(host, opts, "corte", c.camera);
  if (!m) return null;

  /* ponteiro: acende o cômodo e avisa o app; clique entra */
  const T = base.T, ray = new T.Raycaster(), ponto = new T.Vector2();
  function sob(e) {
    const r = base.renderer.domElement.getBoundingClientRect();
    ponto.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
    ray.setFromCamera(ponto, c.camera);
    const hits = ray.intersectObjects(c.alvos, false);
    return hits.length ? hits[0].object.userData.id : null;
  }
  let atual = null;
  m.wrap.addEventListener("pointermove", function (e) {
    const id = sob(e);
    if (id !== atual) {
      atual = id;
      c.comodos.forEach(function (k) { k.hover = k.id === id; });
      m.wrap.style.cursor = id ? "pointer" : "";
      if (opts.aoPassar) opts.aoPassar(id);
    }
  });
  m.wrap.addEventListener("pointerleave", function () {
    atual = null;
    c.comodos.forEach(function (k) { k.hover = false; });
    if (opts.aoPassar) opts.aoPassar(null);
  });
  m.wrap.addEventListener("click", function (e) {
    const id = sob(e);
    if (id && opts.aoEntrar) opts.aoEntrar(id);
  });
  /* a maquete está no ar: quem acendeu a luz da chegada pode apagá-la */
  if (opts.aoPronto) requestAnimationFrame(function () { opts.aoPronto(true); });
  return m.controle;
}

/* ---------- montagem numa tela ---------- */

let montagem = null; /* { host, wrap, ro, io, raf, naTela, t0, opts, tipo, camera } */

function quadro(agora) {
  const m = montagem;
  if (!m) return;
  m.raf = requestAnimationFrame(quadro);
  if (!m.wrap.isConnected) { cancelAnimationFrame(m.raf); m.raf = 0; return; }
  renderizar((agora - m.t0) / 1000);
}

/* um quadro da cena ativa (também chamado logo após redimensionar,
   porque setSize limpa o canvas) */
function renderizar(t) {
  const m = montagem, b = base;
  if (!m || !b) return;
  if (m.tipo === "sala") {
    atualizarSala(sala, t);
    b.renderer.render(sala.cena, sala.camera);
    return;
  }
  if (m.tipo === "quarto") {
    atualizarQuarto(corte, t);
    b.renderer.render(corte.cena, corte.camera);
    return;
  }
  if (m.tipo === "corte") {
    /* a maquete gira com o ponteiro até uns 22 graus, o bastante para
       mostrar as laterais e a profundidade dos cômodos sem que as
       paredes divisórias comecem a esconder o que tem dentro. O giro
       PERSEGUE o ponteiro em vez de colar nele: nesta amplitude, seguir
       o mouse quadro a quadro fica seco. */
    const giroY = Math.sin(t * 0.18) * 0.05 + b.mira.x * 0.38;
    const giroX = b.mira.y * 0.11;
    corte.giro.rotation.y += (giroY - corte.giro.rotation.y) * 0.09;
    corte.giro.rotation.x += (giroX - corte.giro.rotation.x) * 0.09;
    moverCamera(corte, 0.09);
    atualizarLuzes(corte, t);
    if (corte.ceu) animarCeu(corte.ceu, t);
    b.renderer.render(corte.cena, corte.camera);
    return;
  }
  /* balanço sutil: o diorama nunca vira de costas, só respira */
  b.giro.rotation.y = Math.sin(t * 0.2) * 0.12 + b.mira.x * 0.2;
  b.giro.rotation.x = b.mira.y * 0.04;
  /* a câmera persegue o alvo: entra em movimento na chegada e se
     aproxima quando a pessoa toca a casa */
  b.cam.pos.lerp(b.cam.alvo, b.cam.passo);
  b.cam.olhar.lerp(b.cam.olharAlvo, b.cam.passo);
  b.camera.position.set(b.cam.pos.x + b.mira.x * 0.08, b.cam.pos.y + b.mira.y * 0.05, b.cam.pos.z);
  b.camera.lookAt(b.cam.olhar);
  b.vagalumes.material.uniforms.uT.value = t;
  if (ceu3d && ceu3d.parent) animarCeu(ceu3d, t);
  b.renderer.render(b.cena, b.camera);
}

/* só gasta quadro com o palco na tela e a aba visível */
function ligar() {
  const m = montagem;
  if (!m) return;
  const deve = m.naTela && !document.hidden && m.wrap.isConnected;
  if (!deve && m.raf) { cancelAnimationFrame(m.raf); m.raf = 0; }
  else if (deve && !m.raf) { m.t0 = performance.now(); m.raf = requestAnimationFrame(quadro); }
}
document.addEventListener("visibilitychange", ligar);

function dimensionar() {
  const m = montagem;
  if (!m) return;
  const w = m.wrap.clientWidth || 1, h = m.wrap.clientHeight || 1;
  base.renderer.setSize(w, h, false);
  /* o canvas pode transbordar o quadro do hospedeiro. Quando transborda,
     o ENQUADRAMENTO continua sendo o do quadro (mesmo tamanho, mesmo
     centro) e o que sobra é ar em volta: é isso que deixa a casa crescer
     na aproximação sem esbarrar numa borda. */
  const rq = m.host.getBoundingClientRect(), rc = m.wrap.getBoundingClientRect();
  const solto = m.opts.solto && rq.width > 4 && rq.height > 4 &&
    (Math.abs(rc.width - rq.width) > 1 || Math.abs(rc.height - rq.height) > 1);
  if (solto) {
    m.camera.aspect = rq.width / rq.height;
    m.camera.setViewOffset(rq.width, rq.height, rc.left - rq.left, rc.top - rq.top, rc.width, rc.height);
  } else {
    m.camera.clearViewOffset();
    m.camera.aspect = w / h;
  }
  /* a sala é uma foto larga num quadro mais estreito: a abertura passa
     a ser calculada pela LARGURA, para nunca cortar as laterais (o
     corte sobra em cima/embaixo, onde piso e teto continuam) */
  if (m.tipo === "sala" && sala) {
    sala.fovBase = 2 * Math.atan((sala.tanH * 0.94) / m.camera.aspect) * 180 / Math.PI;
    m.camera.fov = sala.fovBase;
  }
  m.camera.updateProjectionMatrix();
  renderizar((performance.now() - m.t0) / 1000);
}

/* o 3D não veio (sem WebGL, sem modelo, sem calibração): devolve as
   ilustrações de reserva, que ficam escondidas enquanto há esperança */
function desistir(host) {
  if (!host) return null;
  host.classList.add("volta-antigo");
  host.classList.remove("aguarda3d", "oculta-antigo");
  return null;
}

/* troca o canvas único para um novo host e liga observadores */
function prepararMontagem(host, opts, tipo, camera, semFade) {
  if (montagem) {
    if (montagem.raf) cancelAnimationFrame(montagem.raf);
    montagem.ro.disconnect();
    montagem.io.disconnect();
    if (montagem.wrap.parentNode) montagem.wrap.parentNode.removeChild(montagem.wrap);
    /* o host que fica para trás perde o canvas: devolve a foto no lugar,
       senão sobra um quadro vazio durante a troca de tela */
    montagem.host.classList.remove("em3d", "aguarda3d");
  }
  const wrap = document.createElement("div");
  wrap.className = "v-casa3d";
  wrap.style.pointerEvents = opts.interativo || tipo !== "diorama" ? "auto" : "none";
  if (semFade) { wrap.style.transition = "none"; wrap.style.opacity = "1"; }
  wrap.appendChild(base.renderer.domElement);
  host.appendChild(wrap);
  /* a cortina fica sempre por cima do canvas */
  const luz = host.querySelector(".cortina3d");
  if (luz) host.appendChild(luz);

  const m = montagem = { host, wrap, opts, tipo, camera, raf: 0, naTela: true, t0: performance.now() };
  m.ro = new ResizeObserver(dimensionar);
  m.ro.observe(wrap);
  /* o canvas pode ser maior que o hospedeiro (ver dimensionar): o
     enquadramento segue o quadro do host, então ele também é medido */
  if (host !== wrap) m.ro.observe(host);
  m.io = new IntersectionObserver(function (ents) {
    m.naTela = ents[0].isIntersecting;
    ligar();
  }, { rootMargin: "80px" });
  m.io.observe(wrap);
  dimensionar();
  ligar();
  /* pronto: o 3D assume. A ordem importa: `em3d` entra no MESMO quadro
     em que o espaço de espera sai, senão a reserva pisca por baixo. */
  requestAnimationFrame(function () {
    host.classList.add("em3d");
    host.classList.remove("aguarda3d");
    if (semFade) requestAnimationFrame(function () { wrap.style.transition = ""; });
  });
  m.controle = { desmontar: function () { if (montagem === m) ligar(); } };
  return m;
}

/* clique/hover na casa (só quando a montagem é interativa) */
function ligarInteracao(m) {
  const { T, renderer, camera, alvoCasa } = base;
  const ray = new T.Raycaster(), ponto = new T.Vector2();
  function acerta(e) {
    const r = renderer.domElement.getBoundingClientRect();
    ponto.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
    ray.setFromCamera(ponto, camera);
    return ray.intersectObject(alvoCasa, false).length > 0;
  }
  m.wrap.addEventListener("pointermove", function (e) {
    m.wrap.style.cursor = acerta(e) ? "pointer" : "";
  });
  m.wrap.addEventListener("click", function (e) {
    if (acerta(e) && m.opts.aoClicarCasa) m.opts.aoClicarCasa();
  });
}

/* o céu só entra onde o canvas é a tela inteira (a chegada). No hero da
   vendas e nas telas de acesso o canvas é um retângulo pequeno: uma
   cúpula opaca ali viraria um quadrado de céu no meio da página. */
let ceu3d = null;
function ligarCeu(b, ligado) {
  if (ligado && !ceu3d) ceu3d = criarCeu(b.T, b.renderer.getPixelRatio(), CEU_NOITE);
  if (!ceu3d) return;
  if (ligado && !ceu3d.parent) b.cena.add(ceu3d);
  else if (!ligado && ceu3d.parent) b.cena.remove(ceu3d);
}

async function montar(host, opts) {
  if (!host || REDUZIDO) return null;
  opts = opts || {};
  try {
    if (!carregando) carregando = carregarBase();
    base = await carregando;
  } catch (e) { carregando = null; return desistir(host); }
  if (!host.isConnected) return null;
  ligarCeu(base, !!opts.ceu);
  base.cam.pos.set(0, base.pouso.y + 0.3, base.pouso.z + 1.15);
  base.cam.alvo.set(0, base.pouso.y, base.pouso.z);
  base.cam.olhar.set(0, 0, 0);
  base.cam.olharAlvo.set(0, 0, 0);
  base.cam.passo = 0.045;
  base.camera.position.copy(base.cam.pos);
  base.camera.lookAt(base.cam.olhar);
  const m = prepararMontagem(host, opts, "diorama", base.camera);
  if (opts.interativo) ligarInteracao(m);
  return m.controle;
}

/* ---------- sala 3D: a foto do cômodo vira um ambiente com profundidade ----------
   Técnica "tour into the picture": a foto é projetada numa caixa
   (parede de fundo, piso, teto e laterais) construída a partir do
   retângulo da parede de fundo na imagem. Vista do ponto original
   ela é pixel a pixel a foto; quando a câmera se desloca com o
   ponteiro, as paredes ganham paralaxe de verdade. Os objetos do
   cômodo são pontos na imagem que viram marcadores clicáveis no
   espaço. */

const CALIBRACAO_SALAS = "assets/salas/calibracao.json?v=8";
const SALA_FOV = 50, SALA_D = 4;
let calibracao = null, carregandoCalibracao = null;
let sala = null; /* cena da sala atual */
const texturasSala = {};

function carregarCalibracao() {
  if (!carregandoCalibracao) {
    carregandoCalibracao = fetch(CALIBRACAO_SALAS)
      .then(function (r) { return r.ok ? r.json() : {}; })
      .catch(function () { return {}; })
      .then(function (j) { calibracao = j; return j; });
  }
  return carregandoCalibracao;
}

/* material que projeta a foto a partir da câmera ORIGINAL (na origem),
   qualquer que seja a câmera que renderiza — perspectiva correta */
function materialProjetado(T, tex, proj) {
  return new T.ShaderMaterial({
    uniforms: { uMapa: { value: tex }, uProj: { value: proj } },
    vertexShader: [
      "uniform mat4 uProj; varying vec4 vP;",
      "void main() {",
      "  vec4 mundo = modelMatrix * vec4(position, 1.0);",
      "  vP = uProj * mundo;",
      "  gl_Position = projectionMatrix * viewMatrix * mundo;",
      "}"
    ].join("\n"),
    fragmentShader: [
      "uniform sampler2D uMapa; varying vec4 vP;",
      "void main() {",
      "  vec2 uv = (vP.xy / vP.w) * 0.5 + 0.5;",
      "  vec2 d = max(-uv, uv - vec2(1.0));",       /* o quanto passou da foto */
      "  float fora = max(max(d.x, d.y), 0.0);",
      /* fora da imagem a textura estica: escurece devagar, virando
         sombra de canto em vez de borrão aparente */
      "  float k = 1.0 - smoothstep(0.0, 0.075, fora) * 0.62;",
      "  vec3 cor = texture2D(uMapa, clamp(uv, 0.0, 1.0)).rgb * k;",
      "  gl_FragColor = vec4(cor, 1.0);",
      "}"
    ].join("\n"),
    side: T.DoubleSide
  });
}

/* quatro cantos → dois triângulos */
function quad(T, a, b, c, d, mat) {
  const g = new T.BufferGeometry();
  g.setAttribute("position", new T.Float32BufferAttribute([
    a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z,
    a.x, a.y, a.z, c.x, c.y, c.z, d.x, d.y, d.z
  ], 3));
  g.computeVertexNormals();
  return new T.Mesh(g, mat);
}

/* marcador de objeto: anel macio com um ponto quente no centro */
function texturaMarcador(T) {
  const c = document.createElement("canvas");
  c.width = c.height = 128;
  const g = c.getContext("2d");
  const halo = g.createRadialGradient(64, 64, 6, 64, 64, 62);
  halo.addColorStop(0, "rgba(255,210,130,0.55)");
  halo.addColorStop(0.5, "rgba(255,190,110,0.18)");
  halo.addColorStop(1, "rgba(255,190,110,0)");
  g.fillStyle = halo; g.fillRect(0, 0, 128, 128);
  g.lineWidth = 4; g.strokeStyle = "rgba(255,248,235,0.95)";
  g.beginPath(); g.arc(64, 64, 26, 0, Math.PI * 2); g.stroke();
  g.fillStyle = "rgba(255,236,200,0.95)";
  g.beginPath(); g.arc(64, 64, 8, 0, Math.PI * 2); g.fill();
  const t = new T.CanvasTexture(c);
  t.colorSpace = T.SRGBColorSpace;
  return t;
}
let texMarcador = null;

async function carregarSala(roomId, foto, caixa, camadas) {
  const b = await (carregando || (carregando = carregarBase()));
  base = b;
  const T = b.T;
  await carregarCalibracao();
  if (!texturasSala[roomId]) {
    texturasSala[roomId] = new T.TextureLoader().loadAsync(foto).then(function (t) {
      t.colorSpace = T.SRGBColorSpace;
      t.anisotropy = Math.min(8, b.renderer.capabilities.getMaxAnisotropy());
      return t;
    });
  }
  const tex = await texturasSala[roomId];
  const aspecto = tex.image.width / tex.image.height;

  /* câmera original: na origem, olhando -z, mesma abertura da caixa */
  const camOrig = new T.PerspectiveCamera(SALA_FOV, aspecto, 0.1, 100);
  camOrig.position.set(0, 0, 0);
  camOrig.lookAt(0, 0, -1);
  camOrig.updateMatrixWorld(true);
  const proj = new T.Matrix4().multiplyMatrices(camOrig.projectionMatrix, camOrig.matrixWorldInverse);
  const tanV = Math.tan(T.MathUtils.degToRad(SALA_FOV / 2)), tanH = tanV * aspecto;
  const d = SALA_D;

  /* retângulo da parede de fundo em NDC */
  const X0 = caixa[0] * 2 - 1, Y0 = 1 - caixa[1] * 2, X1 = caixa[2] * 2 - 1, Y1 = 1 - caixa[3] * 2;
  const mat = materialProjetado(T, tex, proj);
  const cena = new T.Scene();
  const grupo = new T.Group();
  cena.add(grupo);
  const paredes = [];

  /* planos das paredes (o fundo fica em z = -d) */
  const xl = X0 * tanH * d, xr = X1 * tanH * d, yf = Y1 * tanV * d, yc = Y0 * tanV * d;
  /* canto do fundo em 3D */
  const F = (X, Y) => new T.Vector3(X * tanH * d, Y * tanV * d, -d);
  /* ponto da imagem projetado num plano vertical (x = xc) ou horizontal (y = yc) */
  const emX = (X, Y, xc) => { const t = xc / (X * tanH); return new T.Vector3(xc, Y * tanV * t, -t); };
  const emY = (X, Y, yc2) => { const t = yc2 / (Y * tanV); return new T.Vector3(X * tanH * t, yc2, -t); };
  /* as paredes seguem ALÉM da borda da foto (EXT): o quadro pode ser
     mais aberto que a imagem, e a textura clampada estica a última
     linha de pixels — parede e piso continuam sem deixar vazio */
  const EXT = 1.42;
  const saida = (X, Y) => { const s = EXT / Math.max(Math.abs(X), Math.abs(Y), 1e-6); return [X * s, Y * s]; };
  const lim = EXT * 0.999;
  const em = (p, lado) => lado === "baixo" ? p[1] < -lim : lado === "cima" ? p[1] > lim : lado === "esq" ? p[0] < -lim : p[0] > lim;
  /* polígono convexo (contorno em ordem) → leque de triângulos */
  function poligono(pts) {
    const arr = [];
    for (let i = 1; i + 1 < pts.length; i++) {
      arr.push(pts[0].x, pts[0].y, pts[0].z, pts[i].x, pts[i].y, pts[i].z, pts[i + 1].x, pts[i + 1].y, pts[i + 1].z);
    }
    const g = new T.BufferGeometry();
    g.setAttribute("position", new T.Float32BufferAttribute(arr, 3));
    g.computeVertexNormals();
    const m = new T.Mesh(g, mat);
    grupo.add(m); paredes.push(m);
  }

  /* fundo */
  poligono([F(X0, Y1), F(X1, Y1), F(X1, Y0), F(X0, Y0)]);
  /* piso: da base do fundo até a borda inferior da imagem, incluindo
     os cantos da imagem que caem do lado dele */
  if (Y1 > -0.985) {
    const eL = saida(X0, Y1), eR = saida(X1, Y1), pts = [F(X0, Y1), F(X1, Y1), emY(eR[0], eR[1], yf)];
    if (em(eR, "dir")) pts.push(emY(EXT, -EXT, yf));
    if (em(eL, "esq")) pts.push(emY(-EXT, -EXT, yf));
    pts.push(emY(eL[0], eL[1], yf));
    poligono(pts);
  }
  /* teto */
  if (Y0 < 0.985) {
    const eL = saida(X0, Y0), eR = saida(X1, Y0), pts = [F(X1, Y0), F(X0, Y0), emY(eL[0], eL[1], yc)];
    if (em(eL, "esq")) pts.push(emY(-EXT, EXT, yc));
    if (em(eR, "dir")) pts.push(emY(EXT, EXT, yc));
    pts.push(emY(eR[0], eR[1], yc));
    poligono(pts);
  }
  /* parede esquerda */
  if (X0 > -0.985) {
    const eB = saida(X0, Y1), eT = saida(X0, Y0), pts = [F(X0, Y1), F(X0, Y0), emX(eT[0], eT[1], xl)];
    if (em(eT, "cima")) pts.push(emX(-EXT, EXT, xl));
    if (em(eB, "baixo")) pts.push(emX(-EXT, -EXT, xl));
    pts.push(emX(eB[0], eB[1], xl));
    poligono(pts);
  }
  /* parede direita */
  if (X1 < 0.985) {
    const eB = saida(X1, Y1), eT = saida(X1, Y0), pts = [F(X1, Y0), F(X1, Y1), emX(eB[0], eB[1], xr)];
    if (em(eB, "baixo")) pts.push(emX(EXT, -EXT, xr));
    if (em(eT, "cima")) pts.push(emX(EXT, EXT, xr));
    pts.push(emX(eT[0], eT[1], xr));
    poligono(pts);
  }

  /* câmera que renderiza: parte do ponto original e desloca de leve;
     um pouco mais fechada para nunca mostrar a borda da caixa */
  const camera = new T.PerspectiveCamera(SALA_FOV * 0.9, aspecto, 0.1, 100);
  /* começa um passo atrás: a entrada continua o movimento da aproximação */
  camera.position.set(0, 0, 0.38);
  camera.lookAt(0, 0, -d);

  const sala = { cena, camera, camOrig, grupo, paredes, tanH, tanV, d, marcadores: [], camadas: [], roomId };
  /* o id da textura pode ter o sufixo do fundo; os recortes moram na
     pasta da sala */
  if (camadas) criarCamadas(sala, roomId.replace(/-fundo$/, ""), camadas);
  return sala;
}

/* Camadas: os objetos recortados da própria foto entram como planos
   à frente do fundo. Vistos do ponto original eles cobrem exatamente
   os mesmos pixels; quando a câmera desliza, cada um se move conforme
   a sua profundidade — paralaxe de verdade dentro da cena. */
function criarCamadas(s, roomId, camadas) {
  const T = base.T;
  s.camadas = [];
  const carregador = new T.TextureLoader();
  Object.keys(camadas || {}).forEach(function (id) {
    const item = camadas[id];
    if (!item || !item.caixa) return;
    const zp = s.d * (item.z || 0.75);
    const c = item.caixa;
    const X0 = c[0] * 2 - 1, Y0 = 1 - c[1] * 2, X1 = c[2] * 2 - 1, Y1 = 1 - c[3] * 2;
    const x0 = X0 * s.tanH * zp, x1 = X1 * s.tanH * zp;
    const y0 = Y0 * s.tanV * zp, y1 = Y1 * s.tanV * zp;
    const mat = new T.MeshBasicMaterial({ transparent: true, depthWrite: false, opacity: 0 });
    const plano = new T.Mesh(new T.PlaneGeometry(Math.abs(x1 - x0), Math.abs(y0 - y1)), mat);
    plano.position.set((x0 + x1) / 2, (y0 + y1) / 2, -zp);
    plano.renderOrder = 5 + Math.round((1 - (item.z || 0.75)) * 10);
    s.grupo.add(plano);
    const camada = { id, plano, mat, z: item.z || 0.75, hover: false, base: plano.scale.x };
    s.camadas.push(camada);
    carregador.loadAsync("assets/salas/objetos/" + roomId + "/" + id + ".webp").then(function (tex) {
      tex.colorSpace = T.SRGBColorSpace;
      tex.anisotropy = Math.min(8, base.renderer.capabilities.getMaxAnisotropy());
      mat.map = tex;
      mat.opacity = 1;
      mat.needsUpdate = true;
    }).catch(function () {
      /* sem recorte, o objeto continua no fundo: some o plano vazio */
      s.grupo.remove(plano);
      s.camadas = s.camadas.filter(function (k) { return k !== camada; });
    });
  });
}

/* coloca um marcador por objeto: o raio da câmera original pelo ponto
   da imagem encontra a parede/piso onde o objeto está */
function configurarObjetos(s, objetos) {
  const T = base.T;
  s.marcadores.forEach(function (m) { s.grupo.remove(m.sprite); });
  s.marcadores = [];
  if (!texMarcador) texMarcador = texturaMarcador(T);
  const ray = new T.Raycaster();
  (objetos || []).forEach(function (o) {
    if (!o.uv) return;
    const X = o.uv[0] * 2 - 1, Y = 1 - o.uv[1] * 2;
    ray.set(new T.Vector3(0, 0, 0), new T.Vector3(X * s.tanH, Y * s.tanV, -1).normalize());
    const hits = ray.intersectObjects(s.paredes, false);
    if (!hits.length) return;
    const p = hits[0].point.clone().multiplyScalar(0.97); /* um fio à frente da parede */
    const mat = new T.SpriteMaterial({ map: texMarcador, transparent: true, depthWrite: false, opacity: 0 });
    const sprite = new T.Sprite(mat);
    sprite.position.copy(p);
    const tam = 0.075 * p.length();
    sprite.scale.set(tam * 0.4, tam * 0.4, 1);
    sprite.userData.base = tam;
    s.grupo.add(sprite);
    /* acendem em cascata, da esquerda para a direita */
    s.marcadores.push({ id: o.id, nome: o.nome, sprite, visitado: !!o.visitado,
      hover: false, fase: Math.random() * 6, nasce: 0.45 + s.marcadores.length * 0.13, vida: 0 });
  });
  s.marcadores.sort(function (a, b) { return a.sprite.position.x - b.sprite.position.x; })
    .forEach(function (m, i) { m.nasce = 0.45 + i * 0.13; });
}

function atualizarSala(s, t) {
  const b = base;
  /* paralaxe: a câmera desliza com o ponteiro e respira sozinha */
  const ax = b.mira.x * 0.105 + Math.sin(t * 0.23) * 0.028;
  const ay = -b.mira.y * 0.06 + Math.cos(t * 0.19) * 0.018;
  s.camera.position.x += (ax - s.camera.position.x) * 0.06;
  s.camera.position.y += (ay - s.camera.position.y) * 0.06;
  s.camera.position.z += (0 - s.camera.position.z) * 0.03; /* assenta na foto */
  /* sobre um objeto, a câmera se aproxima um fio e olha para ele */
  const sob = s.marcadores.find(function (m) { return m.hover; });
  const fovAlvo = (s.fovBase || SALA_FOV * 0.9) * (sob ? 0.96 : 1);
  s.camera.fov += (fovAlvo - s.camera.fov) * 0.08;
  s.camera.updateProjectionMatrix();
  if (!s.olhar) s.olhar = new b.T.Vector3(0, 0, -s.d);
  const alvo = sob ? sob.sprite.position.clone().multiplyScalar(0.09).add(new b.T.Vector3(0, 0, -s.d * 0.91)) : new b.T.Vector3(0, 0, -s.d);
  s.olhar.lerp(alvo, 0.06);
  s.camera.lookAt(s.olhar);
  (s.camadas || []).forEach(function (k) {
    /* sob o ponteiro, o objeto ganha um passo à frente */
    const alvo = k.hover ? 1.035 : 1;
    k.plano.scale.x += (alvo - k.plano.scale.x) * 0.12;
    k.plano.scale.y = k.plano.scale.x;
  });
  s.marcadores.forEach(function (m) {
    /* nascimento: cresce e acende no seu tempo */
    m.vida += ((t > m.nasce ? 1 : 0) - m.vida) * 0.09;
    const pulso = 1 + Math.sin(t * 2.2 + m.fase) * 0.08;
    const alvo = m.sprite.userData.base * pulso * (m.hover ? 1.45 : 1) * (0.4 + 0.6 * m.vida);
    m.sprite.scale.x += (alvo - m.sprite.scale.x) * 0.2;
    m.sprite.scale.y = m.sprite.scale.x;
    const op = (m.hover ? 1 : (m.visitado ? 0.55 : 0.95)) * m.vida;
    m.sprite.material.opacity += (op - m.sprite.material.opacity) * 0.2;
  });
}

/* A luz da entrada cobre a troca de TELA inteira. Precisa viver fora
   do #app, senão morre junto com a tela que sai. */
let luzEl = null;
function luz(acender) {
  if (!luzEl) {
    luzEl = document.createElement("div");
    luzEl.className = "luz-entrada";
    document.body.appendChild(luzEl);
  }
  clearTimeout(luzEl._t);
  if (acender) {
    luzEl.classList.add("acesa");
    /* seguro: se a próxima tela falhar, a luz não fica presa */
    luzEl._t = setTimeout(function () { luzEl.classList.remove("acesa"); }, 2800);
  } else {
    luzEl.classList.remove("acesa");
  }
}

/* a luz da chegada: fica no host, não no canvas, para sobreviver à
   troca de cena */
function cortinaDo(host) {
  let luz = host.querySelector(".cortina3d");
  if (!luz) {
    luz = document.createElement("div");
    luz.className = "cortina3d";
    host.appendChild(luz);
  }
  return luz;
}

/* aquece a sala fotográfica (calibração + textura) sem montar nada */
function preCarregarSala(opts) {
  if (!opts || !opts.roomId || !opts.foto) return;
  carregarCalibracao().then(function (cal) {
    const c = cal && cal[opts.roomId];
    if (c && c.caixa) return carregarSala(opts.roomId, opts.foto, c.caixa);
  }).catch(function () {});
}

async function montarSala(host, opts, semFade) {
  if (!host || REDUZIDO) return null;
  opts = opts || {};
  await carregarCalibracao();
  const cal = (calibracao && calibracao[opts.roomId]) || null;
  if (!cal || !cal.caixa) {
    if (opts.aoPronto) opts.aoPronto(false);
    return desistir(host); /* sem calibração, fica a foto */
  }
  let s;
  const temCamadas = !!(cal.camadas && Object.keys(cal.camadas).length);
  /* com camadas, o fundo é a foto SEM os objetos (eles vêm por cima) */
  const foto = temCamadas ? ("assets/salas/" + opts.roomId + "-fundo.webp") : opts.foto;
  try {
    s = sala = await carregarSala(opts.roomId + (temCamadas ? "-fundo" : ""), foto, cal.caixa, temCamadas ? cal.camadas : null);
  } catch (e) {
    if (opts.aoPronto) opts.aoPronto(false);
    return desistir(host);
  }
  if (!host.isConnected) return null;
  const pontos = cal.pontos || {};
  configurarObjetos(s, (opts.objetos || []).map(function (o) {
    return { id: o.id, nome: o.nome, visitado: o.visitado, uv: pontos[o.id] || null };
  }));
  const m = prepararMontagem(host, opts, "sala", s.camera, semFade);
  if (!m) return null;

  /* rótulo HTML que acompanha o marcador sob o ponteiro */
  const rotulo = document.createElement("div");
  rotulo.className = "sala3d-rotulo";
  rotulo.hidden = true;
  m.wrap.appendChild(rotulo);

  /* escolha em espaço de tela: o marcador mais perto do ponteiro */
  const T = base.T, v = new T.Vector3();
  function perto(e) {
    const r = base.renderer.domElement.getBoundingClientRect();
    let melhor = null, dist = 30;
    s.marcadores.forEach(function (k) {
      v.copy(k.sprite.position).project(s.camera);
      const sx = r.left + (v.x + 1) / 2 * r.width, sy = r.top + (1 - v.y) / 2 * r.height;
      const dd = Math.hypot(sx - e.clientX, sy - e.clientY);
      if (dd < dist) { dist = dd; melhor = { k, sx: sx - r.left, sy: sy - r.top }; }
    });
    return melhor;
  }
  let atual = null;
  m.wrap.addEventListener("pointermove", function (e) {
    const p = perto(e);
    const id = p ? p.k.id : null;
    s.marcadores.forEach(function (k) { k.hover = k.id === id; });
    (s.camadas || []).forEach(function (k) { k.hover = k.id === id; });
    m.wrap.style.cursor = id ? "pointer" : "";
    if (p) {
      rotulo.textContent = p.k.nome;
      rotulo.style.left = p.sx + "px";
      rotulo.style.top = (p.sy - 14) + "px";
      rotulo.hidden = false;
    } else rotulo.hidden = true;
    if (id !== atual) { atual = id; if (opts.aoPassar) opts.aoPassar(id); }
  });
  m.wrap.addEventListener("pointerleave", function () {
    atual = null;
    s.marcadores.forEach(function (k) { k.hover = false; });
    (s.camadas || []).forEach(function (k) { k.hover = false; });
    rotulo.hidden = true;
    if (opts.aoPassar) opts.aoPassar(null);
  });
  m.wrap.addEventListener("click", function (e) {
    const p = perto(e);
    if (p && opts.aoClicar) opts.aoClicar(p.k.id);
  });
  /* a sala está pronta: quem acendeu a luz da entrada pode apagá-la */
  if (opts.aoPronto) requestAnimationFrame(function () { opts.aoPronto(true); });
  return m.controle;
}

/* aquece a maquete em segundo plano (a pessoa ainda está na chegada):
   quando ela abrir a casa, o modelo já está pronto */
function aquecerCorte() {
  if (!carregandoCorte) {
    carregandoCorte = carregarCorte().catch(function () { carregandoCorte = null; return null; });
    /* a textura fina entra agora, com folga: subir 4096² durante o voo
       engasga a animação */
    carregandoCorte.then(function (c) { if (c) afiarMaquete(c); });
  }
}

/* a casa da chegada se aproxima: o mesmo gesto de entrar, um passo
   antes da casa se abrir nos cômodos */
let veioAproximando = false;
function aproximarCasa() {
  if (!base || !montagem || montagem.tipo !== "diorama") return false;
  const dist = 5.15;
  base.cam.alvo.set(0, 0.75, dist);
  /* enquanto se aproxima, a casa também escorrega para o meio do canvas:
     o enquadramento nasce ancorado no quadro pequeno lá em cima, e é aí
     que ela encostaria na borda. Olhar mais alto empurra a casa para
     baixo, exatamente o quanto falta para ela ficar centrada na tela. */
  const rq = montagem.host.getBoundingClientRect();
  const rc = montagem.wrap.getBoundingClientRect();
  const dy = (rc.top + rc.height / 2) - (rq.top + rq.height / 2);
  const escala = rq.height / (2 * Math.tan(base.camera.fov * Math.PI / 360) * dist);
  base.cam.olharAlvo.set(0, 0.15 + (escala > 1 ? dy / escala : 0), 0);
  base.cam.passo = 0.055;
  veioAproximando = true;
  aquecerCorte();
  return true;
}

/* começa o voo até um cômodo ainda na tela da casa: quando a tela da
   sala abrir, a câmera já estará a caminho (sem recomeçar do zero) */
function aproximar(coluna, linha, reserva) {
  if (!corte || !montagem || montagem.tipo !== "corte") return false;
  const q = molduraQuarto(corte, coluna || 0, linha || 0);
  corte.cam.posAlvo.copy(q.pos);
  corte.cam.alvoAlvo.copy(q.alvo);
  /* a sala começa a carregar agora, antes da troca de tela: quando o
     voo terminar ela já está pronta e a troca não engasga */
  if (reserva) preCarregarSala(reserva);
  return true;
}

/* handle de calibração (render manual quando o RAF está pausado) */
window.Casa3D = {
  luz,
  aproximar,
  aproximarCasa,
  aquecerCorte,
  montar,
  montarCorte,
  montarSala,
  montarQuarto,
  get debug() {
    if (!base || !montagem) return null;
    const c = (montagem.tipo === "corte" || montagem.tipo === "quarto") ? corte : (montagem.tipo === "sala" ? sala : base);
    return { cena: c.cena, camera: c.camera, renderer: base.renderer, giro: c.giro || c.grupo, host: montagem.host, vagalumes: base.vagalumes, tipo: montagem.tipo, corte, sala };
  }
};
Object.defineProperty(window, "__casa3d", { get: function () { return window.Casa3D.debug; }, configurable: true });

/* página de vendas monta sozinha; o app entrega alvos via handshake */
const heroHost = document.getElementById("heroCasa");
if (heroHost) montar(heroHost);
if (window.__casaAlvo) montar(window.__casaAlvo.el, window.__casaAlvo.opts);
if (window.__corteAlvo) montarCorte(window.__corteAlvo.el, window.__corteAlvo.opts);
if (window.__salaAlvo) montarSala(window.__salaAlvo.el, window.__salaAlvo.opts);
if (window.__quartoAlvo) montarQuarto(window.__quartoAlvo.el, window.__quartoAlvo.opts);
