PageSourceSearch

https://site-nufperpi.web.app/js/energia-3d.js

js site-nufperpi.web.app collected 2026-10-03 12:35:44 UTC 44,191 bytes, 1,100 lines download raw bytes

1/**
2 * energia-3d.js — cena 3D real (WebGL/three.js) da seção #energia-3d,
3 * inspirada no gêmeo digital do rastreador solar (tracker) do CMER
4 * (Visita-China-17-08-2026/02-cmer-sala-situacao-energetica). Substitui
5 * visualmente a ilustração 100% CSS de js/solar-day-cycle.js quando
6 * carrega com sucesso — mas não duplica a lógica de horário/clique dela:
7 * esta cena só ESCUTA o evento "solar:render" que aquele módulo já
8 * dispatcha (relógio real por padrão, clique-para-controlar o horário
9 * manualmente) e usa os mesmos valores (ângulo do painel, elevação do
10 * sol, cores do céu/terreno) pra desenhar em 3D.
11 *
12 * Três gerações simuladas, alternáveis por botão:
13 *   - PV (fotovoltaica): rastreadores solares que seguem o sol.
14 *   - Eólica: aerogeradores com nacele que gira pra encarar o vento (yaw) e
15 *     velocidade/direção do vento simuladas de forma independente do
16 *     horário solar — é o vento, não o sol, que move essa geração.
17 *   - CSP + TES (torre solar): campo de heliostatos que refletem a luz
18 *     pro receptor no topo da torre (matemática real de mira: normal do
19 *     espelho = bissetriz entre a direção do sol e a direção da torre) +
20 *     tanques de sal fundido (quente/frio).
21 * Cada componente clicável (heliostato, torre, tanque, tracker, pás...) abre
22 * um cartão com uma explicação curta — interação didática, não só visual.
23 *
24 * Se o three.js não carregar (offline, CDN bloqueado, WebGL indisponível),
25 * esta função simplesmente não faz nada — a ilustração CSS de
26 * solar-day-cycle.js continua sendo a única cena, sem quebrar nada.
27 */
28(async function energia3d() {
29  const wrap = document.getElementById("solar-scene");
30  const canvas = document.getElementById("energia-3d-canvas");
31  if (!wrap || !canvas) return;
32  if (!window.WebGLRenderingContext) return; // navegador sem suporte a WebGL
33
34  let THREE;
35  try {
36    THREE = await import("https://unpkg.com/[email protected]/build/three.module.js");
37  } catch (e) {
38    console.warn("[energia-3d] three.js indisponível — mantendo ilustração CSS.", e);
39    return;
40  }
41
42  const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
43
44  function parseRgb(str, fallback) {
45    const m = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(str || "");
46    if (!m) return new THREE.Color(fallback || 0x000000);
47    return new THREE.Color(+m[1] / 255, +m[2] / 255, +m[3] / 255);
48  }
49  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
50
51  // ---------------------------------------------------------------------
52  // Renderer / câmera / cena
53  // ---------------------------------------------------------------------
54  let renderer;
55  try {
56    renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
57  } catch (e) {
58    console.warn("[energia-3d] WebGL falhou ao iniciar — mantendo ilustração CSS.", e);
59    return;
60  }
61  renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
62
63  const scene = new THREE.Scene();
64  scene.fog = new THREE.Fog(0x04141a, 16, 60);
65
66  const camera = new THREE.PerspectiveCamera(48, 1, 0.1, 200);
67
68  // Cada geração tem seu próprio enquadramento (o campo de heliostatos é
69  // bem maior que a fileira de trackers) — a troca entre elas anima
70  // suavemente de um pra outro em vez de cortar seco.
71  const CAMERA_PRESETS = {
72    pv: { target: new THREE.Vector3(0, 1, 0), radius: 12, height: 4.6 },
73    wind: { target: new THREE.Vector3(0, 3, -8), radius: 20, height: 8 },
74    csp: { target: new THREE.Vector3(0, 3.4, 1.5), radius: 25, height: 10.5 },
75  };
76  let camTheta = 0.7;
77  const camLive = {
78    target: CAMERA_PRESETS.pv.target.clone(),
79    radius: CAMERA_PRESETS.pv.radius,
80    height: CAMERA_PRESETS.pv.height,
81  };
82  let camAnim = null; // {from:{target,radius,height}, to:{...}, start, dur}
83
84  function startCameraTransition(preset) {
85    camAnim = {
86      from: { target: camLive.target.clone(), radius: camLive.radius, height: camLive.height },
87      to: { target: preset.target.clone(), radius: preset.radius, height: preset.height },
88      start: performance.now(),
89      dur: 1000,
90    };
91  }
92
93  function updateCameraTransition() {
94    if (!camAnim) return;
95    const t = clamp((performance.now() - camAnim.start) / camAnim.dur, 0, 1);
96    const eased = 1 - Math.pow(1 - t, 3);
97    camLive.target.lerpVectors(camAnim.from.target, camAnim.to.target, eased);
98    camLive.radius = camAnim.from.radius + (camAnim.to.radius - camAnim.from.radius) * eased;
99    camLive.height = camAnim.from.height + (camAnim.to.height - camAnim.from.height) * eased;
100    if (t >= 1) camAnim = null;
101  }
102
103  function placeCamera() {
104    camera.position.set(
105      camLive.target.x + camLive.radius * Math.sin(camTheta),
106      camLive.target.y + camLive.height,
107      camLive.target.z + camLive.radius * Math.cos(camTheta)
108    );
109    camera.lookAt(camLive.target);
110  }
111  placeCamera();
112
113  // ---------------------------------------------------------------------
114  // Luzes
115  // ---------------------------------------------------------------------
116  const ambient = new THREE.AmbientLight(0x6688aa, 0.55);
117  scene.add(ambient);
118  const hemi = new THREE.HemisphereLight(0x3a6a8a, 0x0a1420, 0.4);
119  scene.add(hemi);
120  const sunLight = new THREE.DirectionalLight(0xffe9c0, 1.1);
121  scene.add(sunLight);
122  scene.add(sunLight.target);
123
124  // ---------------------------------------------------------------------
125  // Chão + mar ao fundo (litoral de Luís Correia, mesma referência da
126  // ilustração CSS) — compartilhados pelas duas gerações
127  // ---------------------------------------------------------------------
128  const groundMat = new THREE.MeshStandardMaterial({ color: 0xb9a074, roughness: 1 });
129  const ground = new THREE.Mesh(new THREE.CircleGeometry(46, 48), groundMat);
130  ground.rotation.x = -Math.PI / 2;
131  scene.add(ground);
132
133  const grid = new THREE.GridHelper(46, 28, 0x2c6a52, 0x18342a);
134  grid.position.y = 0.01;
135  grid.material.transparent = true;
136  grid.material.opacity = 0.35;
137  scene.add(grid);
138
139  const seaMat = new THREE.MeshBasicMaterial({ color: 0x1e8c9b, transparent: true, opacity: 0.85 });
140  const sea = new THREE.Mesh(new THREE.PlaneGeometry(160, 40), seaMat);
141  sea.position.set(0, 0.4, -40);
142  scene.add(sea);
143
144  // ---------------------------------------------------------------------
145  // Sol e lua
146  // ---------------------------------------------------------------------
147  const sunMesh = new THREE.Mesh(
148    new THREE.SphereGeometry(0.7, 20, 20),
149    new THREE.MeshBasicMaterial({ color: 0xffd873 })
150  );
151  const sunGlow = new THREE.Mesh(
152    new THREE.SphereGeometry(1.25, 20, 20),
153    new THREE.MeshBasicMaterial({ color: 0xffb84d, transparent: true, opacity: 0.28 })
154  );
155  sunMesh.add(sunGlow);
156  scene.add(sunMesh);
157
158  const moonMesh = new THREE.Mesh(
159    new THREE.SphereGeometry(0.45, 16, 16),
160    new THREE.MeshStandardMaterial({ color: 0xdfe6ee, emissive: 0x5a6478, emissiveIntensity: 0.4 })
161  );
162  scene.add(moonMesh);
163
164  // Estrelas: campo de pontos fixo, opacidade acompanha a força da noite
165  const starCount = 260;
166  const starPos = new Float32Array(starCount * 3);
167  for (let i = 0; i < starCount; i++) {
168    const r = 55 + Math.random() * 10;
169    const theta = Math.random() * Math.PI * 2;
170    const phi = Math.random() * Math.PI * 0.5;
171    starPos[i * 3] = r * Math.sin(phi) * Math.cos(theta);
172    starPos[i * 3 + 1] = 4 + r * Math.cos(phi) * 0.6;
173    starPos[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta) - 10;
174  }
175  const starGeo = new THREE.BufferGeometry();
176  starGeo.setAttribute("position", new THREE.BufferAttribute(starPos, 3));
177  const starMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.14, transparent: true, opacity: 0 });
178  scene.add(new THREE.Points(starGeo, starMat));
179
180  // ---------------------------------------------------------------------
181  // Marcador de hotspot (ponto clicável): círculo com glow, sempre de
182  // frente pra câmera (Sprite), pulsando — pra ficar óbvio que dá pra
183  // clicar nele.
184  // ---------------------------------------------------------------------
185  function makeHotspotMarker() {
186    const cv = document.createElement("canvas");
187    cv.width = 128;
188    cv.height = 128;
189    const g = cv.getContext("2d");
190    g.beginPath();
191    g.arc(64, 64, 32, 0, Math.PI * 2);
192    g.fillStyle = "rgba(52,211,153,0.28)";
193    g.fill();
194    g.beginPath();
195    g.arc(64, 64, 15, 0, Math.PI * 2);
196    g.fillStyle = "rgba(223,245,234,0.96)";
197    g.fill();
198    g.lineWidth = 4;
199    g.strokeStyle = "#34d399";
200    g.stroke();
201    const tex = new THREE.CanvasTexture(cv);
202    const sprite = new THREE.Sprite(
203      new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false })
204    );
205    sprite.scale.set(0.55, 0.55, 1);
206    sprite.renderOrder = 999;
207    return sprite;
208  }
209
210  function makeTextLabel(text) {
211    const cv = document.createElement("canvas");
212    cv.width = 256;
213    cv.height = 64;
214    const g = cv.getContext("2d");
215    g.font = "bold 26px Space Grotesk, sans-serif";
216    g.textAlign = "center";
217    g.shadowColor = "rgba(0,0,0,.9)";
218    g.shadowBlur = 8;
219    g.fillStyle = "#dff5ea";
220    g.fillText(text, 128, 40);
221    const tex = new THREE.CanvasTexture(cv);
222    const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }));
223    sprite.scale.set(2.2, 0.55, 1);
224    return sprite;
225  }
226
227  /* Controle do vento (geração eólica). Nas cenas solares o visitante
228     controla o HORÁRIO (js/solar-day-cycle.js); na eólica, o que faz
229     sentido controlar é o VENTO — então esta geração tem seu próprio modo
230     manual, e o de horário fica desligado enquanto ela está ativa. */
231  const WIND_MIN = 0;
232  const WIND_MAX = 25; // acima disso a turbina sai de operação (cut-out)
233  const windControl = { manual: false, speed: 6.5 };
234
235  // =======================================================================
236  // GERAÇÃO 1 — Fotovoltaica (rastreadores/tracker)
237  // =======================================================================
238  function buildPvScene() {
239    const group = new THREE.Group();
240    const hotspots = [];
241
242    const panelMat = new THREE.MeshStandardMaterial({
243      color: 0x173f78,
244      metalness: 0.6,
245      roughness: 0.3,
246      emissive: 0x0a2b5e,
247      emissiveIntensity: 0.3,
248    });
249    const frameMat = new THREE.MeshStandardMaterial({ color: 0x22303c, metalness: 0.4, roughness: 0.6 });
250    const poleMat = new THREE.MeshStandardMaterial({ color: 0x3a4a58, metalness: 0.5, roughness: 0.5 });
251
252    function makeTrackerUnit(mat) {
253      const g = new THREE.Group();
254      const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 0.95, 10), poleMat);
255      pole.position.y = 0.475;
256      g.add(pole);
257
258      const tilt = new THREE.Group();
259      tilt.position.y = 0.98;
260      g.add(tilt);
261
262      const frame = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.05, 1.05), frameMat);
263      tilt.add(frame);
264      const panel = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.035, 0.96), mat);
265      panel.position.y = 0.043;
266      tilt.add(panel);
267      for (let i = 1; i < 5; i++) {
268        const strip = new THREE.Mesh(new THREE.BoxGeometry(0.016, 0.012, 0.96), frameMat);
269        strip.position.set(-1.0 + i * 0.4, 0.062, 0);
270        tilt.add(strip);
271      }
272      return { group: g, tilt };
273    }
274
275    const trackerTilts = [];
276    const TRACK_ROWS = 2;
277    const TRACK_COLS = 3;
278    const SX = 2.7;
279    const SZ = 2.3;
280    let sampleTrackerPos = null;
281    for (let r = 0; r < TRACK_ROWS; r++) {
282      for (let c = 0; c < TRACK_COLS; c++) {
283        const u = makeTrackerUnit(panelMat);
284        const pos = new THREE.Vector3((c - (TRACK_COLS - 1) / 2) * SX, 0, (r - (TRACK_ROWS - 1) / 2) * SZ + 0.5);
285        u.group.position.copy(pos);
286        group.add(u.group);
287        trackerTilts.push(u.tilt);
288        if (r === 0 && c === 1) sampleTrackerPos = pos;
289      }
290    }
291    const trackerLabel = makeTextLabel("TRACKER");
292    trackerLabel.position.set(0, 2.5, 1.7);
293    group.add(trackerLabel);
294
295    const trackerHotspot = makeHotspotMarker();
296    trackerHotspot.position.set(sampleTrackerPos.x, 1.55, sampleTrackerPos.z);
297    group.add(trackerHotspot);
298    hotspots.push({
299      sprite: trackerHotspot,
300      title: "Rastreador solar (tracker)",
301      text: "Gira ao longo do dia pra manter o painel sempre de frente pro sol, aumentando a geração em relação a um painel fixo — o ganho é o que medimos na bancada do CMER.",
302    });
303
304    function applyExtra(detail) {
305      const tiltRad = THREE.MathUtils.degToRad(-detail.tilt);
306      trackerTilts.forEach((tGroup) => {
307        tGroup.rotation.z = tiltRad;
308      });
309    }
310
311    function animate() {
312      /* só o ângulo do tracker muda, e isso já é feito em applyExtra
313         (sincronizado ao horário) — nada pra animar quadro a quadro aqui. */
314    }
315
316    return { group, hotspots, applyExtra, animate };
317  }
318
319  // =======================================================================
320  // GERAÇÃO 2 — Eólica (aerogeradores com yaw e velocidade do vento
321  // simulados — independente do horário solar, é o próprio vento que
322  // move essa geração)
323  // =======================================================================
324  function buildWindScene() {
325    const group = new THREE.Group();
326    const hotspots = [];
327
328    const poleMat = new THREE.MeshStandardMaterial({ color: 0xe7eef5, metalness: 0.35, roughness: 0.5 });
329    const nacelleMat = new THREE.MeshStandardMaterial({ color: 0xd8e0e8, metalness: 0.4, roughness: 0.45 });
330    const bladeMat = new THREE.MeshStandardMaterial({ color: 0xf5f8fb, roughness: 0.4, metalness: 0.15 });
331
332    function makeTurbine() {
333      const base = new THREE.Group(); // fixo no chão
334      const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.11, 3.6, 10), poleMat);
335      pole.position.y = 1.8;
336      base.add(pole);
337
338      const yaw = new THREE.Group(); // gira no eixo Y pra "encarar" o vento
339      yaw.position.y = 3.6;
340      base.add(yaw);
341
342      const nacelle = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.2, 0.85), nacelleMat);
343      nacelle.position.z = 0.1;
344      yaw.add(nacelle);
345
346      const hub = new THREE.Group();
347      hub.position.z = 0.55;
348      yaw.add(hub);
349      for (let b = 0; b < 3; b++) {
350        const bg = new THREE.Group();
351        bg.rotation.z = (b * Math.PI * 2) / 3;
352        const blade = new THREE.Mesh(new THREE.BoxGeometry(0.11, 1.05, 0.025), bladeMat);
353        blade.position.y = 0.53;
354        bg.add(blade);
355        hub.add(bg);
356      }
357      return { base, yaw, hub };
358    }
359
360    const turbines = [];
361    [
362      [0, -6],
363      [3.6, -8.6],
364      [-3.8, -9.2],
365    ].forEach(([x, z]) => {
366      const tb = makeTurbine();
367      tb.base.position.set(x, 0, z);
368      group.add(tb.base);
369      turbines.push(tb);
370    });
371
372    // Rótulo dinâmico: velocidade e direção do vento em tempo real — só
373    // redesenha o texto ~4x/s (texturas de canvas são caras de atualizar
374    // todo frame).
375    function makeDynamicLabel(initial) {
376      const cv = document.createElement("canvas");
377      cv.width = 340;
378      cv.height = 80;
379      const g = cv.getContext("2d");
380      const tex = new THREE.CanvasTexture(cv);
381      const sprite = new THREE.Sprite(
382        new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false })
383      );
384      sprite.scale.set(2.8, 0.66, 1);
385      function setText(text) {
386        g.clearRect(0, 0, cv.width, cv.height);
387        g.textAlign = "center";
388        g.font = "bold 30px Space Grotesk, sans-serif";
389        g.shadowColor = "rgba(0,0,0,.9)";
390        g.shadowBlur = 8;
391        g.fillStyle = "#dff5ea";
392        g.fillText(text, cv.width / 2, cv.height / 2 + 10);
393        tex.needsUpdate = true;
394      }
395      setText(initial);
396      return { sprite, setText };
397    }
398
399    const windLabel = makeDynamicLabel("VENTO — calculando…");
400    windLabel.sprite.position.set(0, 5.6, -6);
401    group.add(windLabel.sprite);
402
403    // "Linhas de fluxo": pequenos traços que atravessam o campo na direção
404    // do vento — dá pra "ver o ar se mover", não só as pás girando.
405    const FLOW_N = 42;
406    const flowMat = new THREE.LineBasicMaterial({ color: 0xbfe9ff, transparent: true, op
406acity: 0.4 });
407    const flowLines = [];
408    for (let i = 0; i < FLOW_N; i++) {
409      const geo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]);
410      const line = new THREE.Line(geo, flowMat);
411      group.add(line);
412      flowLines.push({
413        line,
414        x: (Math.random() - 0.5) * 32,
415        z: -2 - Math.random() * 16,
416        y: 0.4 + Math.random() * 4.6,
417        speedMul: 0.7 + Math.random() * 0.6,
418      });
419    }
420
421    const towerHotspot = makeHotspotMarker();
422    towerHotspot.position.set(turbines[0].base.position.x, 1.4, turbines[0].base.position.z + 1);
423    group.add(towerHotspot);
424    hotspots.push({
425      sprite: towerHotspot,
426      title: "Torre eólica",
427      text: "Eleva as pás até uma altura onde o vento é mais forte e constante. O litoral do Piauí, como Luís Correia, tem um dos melhores potenciais eólicos do Brasil.",
428    });
429
430    const nacelleHotspot = makeHotspotMarker();
431    nacelleHotspot.position.set(turbines[0].base.position.x, 3.95, turbines[0].base.position.z + 1.3);
432    group.add(nacelleHotspot);
433    hotspots.push({
434      sprite: nacelleHotspot,
435      title: "Nacele e gerador",
436      text: "Abriga o gerador que transforma o giro do rotor em eletricidade. A nacele gira sozinha (yaw) pra manter as pás sempre de frente pro vento, maximizando a geração.",
437    });
438
439    const bladeHotspot = makeHotspotMarker();
440    bladeHotspot.position.set(turbines[1].base.position.x, 3.4, turbines[1].base.position.z + 1.5);
441    group.add(bladeHotspot);
442    hotspots.push({
443      sprite: bladeHotspot,
444      title: "Pás do rotor",
445      text: "Capturam a energia cinética do vento e giram o rotor — quanto mais forte o vento, mais rápido giram e mais eletricidade é gerada, até o limite de segurança da turbina.",
446    });
447
448    let simTime = 0;
449    let lastLabelUpdate = 0;
450    // Velocidade atual do vento (m/s), exposta pro painel de dados e
451    // controlável pelo usuário (ver windControl mais abaixo): nesta geração
452    // é o VENTO que o visitante manipula, não o horário do sol.
453    let currentSpeed = 6.5;
454
455    function animate(dt) {
456      simTime += dt;
457      // Sem controle manual: velocidade "de verdade" (não puramente
458      // aleatória) — soma de senoides com períodos diferentes, pra variar de
459      // forma orgânica sem ficar nem constante nem tremida demais.
460      const autoSpeed = 6.5 + Math.sin(simTime * 0.15) * 2.4 + Math.sin(simTime * 0.41) * 1.1;
461      const target = windControl.manual ? windControl.speed : autoSpeed;
462      // aproximação suave: mudar o vento não é instantâneo
463      currentSpeed += (target - currentSpeed) * Math.min(1, dt * 2.2);
464      const speed = currentSpeed;
465      const dir = 0.4 + Math.sin(simTime * 0.05) * 0.5;
466
467      turbines.forEach((tb) => {
468        tb.yaw.rotation.y += (dir - tb.yaw.rotation.y) * Math.min(1, dt * 0.6);
469        tb.hub.rotation.z += dt * (0.6 + speed * 0.22);
470      });
471
472      if (simTime - lastLabelUpdate > 0.25) {
473        lastLabelUpdate = simTime;
474        const kmh = (speed * 3.6).toFixed(0);
475        windLabel.setText(`VENTO — ${speed.toFixed(1)} m/s (${kmh} km/h)`);
476      }
477
478      const dx = Math.sin(dir);
479      const dz = Math.cos(dir);
480      flowLines.forEach((f) => {
481        f.x += dx * dt * speed * 0.5 * f.speedMul;
482        f.z += dz * dt * speed * 0.5 * f.speedMul;
483        if (f.x > 16) f.x = -16;
484        if (f.x < -16) f.x = 16;
485        if (f.z > 4) f.z = -18;
486        if (f.z < -18) f.z = 4;
487        const len = 0.5 + speed * 0.05;
488        f.line.geometry.setFromPoints([
489          new THREE.Vector3(f.x - dx * len, f.y, f.z - dz * len),
490          new THREE.Vector3(f.x + dx * len, f.y, f.z + dz * len),
491        ]);
492      });
493    }
494
495    function applyExtra() {
496      /* a eólica não depende do horário solar (a luz ambiente/céu já é
497         sincronizada em applyState, compartilhada por todas as gerações) —
498         só o vento simulado em animate() move esta cena. */
499    }
500
501    return { group, hotspots, applyExtra, animate, getWindSpeed: () => currentSpeed };
502  }
503
504  // =======================================================================
505  // GERAÇÃO 3 — CSP + TES (torre solar: heliostatos, torre, tanques)
506  // =======================================================================
507  function buildCspScene() {
508    const group = new THREE.Group();
509    const hotspots = [];
510
511    const TOWER_H = 7.2;
512    const receiverPos = new THREE.Vector3(0, TOWER_H + 0.6, 0);
513
514    const towerMat = new THREE.MeshStandardMaterial({ color: 0x4a5568, metalness: 0.45, roughness: 0.55 });
515    const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.56, TOWER_H, 12), towerMat);
516    tower.position.y = TOWER_H / 2;
517    group.add(tower);
518
519    const receiverMat = new THREE.MeshStandardMaterial({
520      color: 0x2a2a2a,
521      metalness: 0.35,
522      roughness: 0.4,
523      emissive: 0x552200,
524      emissiveIntensity: 0.35,
525    });
526    const receiver = new THREE.Mesh(new THREE.CylinderGeometry(0.62, 0.62, 1.25, 12), receiverMat);
527    receiver.position.copy(receiverPos);
528    group.add(receiver);
529
530    const towerHotspot = makeHotspotMarker();
531    towerHotspot.position.set(0, TOWER_H * 0.55, 0.75);
532    group.add(towerHotspot);
533    hotspots.push({
534      sprite: towerHotspot,
535      title: "Torre central",
536      text: "Recebe a luz concentrada de dezenas de heliostatos e aquece o fluido de trabalho a temperaturas acima de 500°C, no topo da torre.",
537    });
538
539    // Campo de heliostatos — espelhos motorizados em 2 eixos que refletem
540    // o sol sempre pro mesmo ponto (o receptor). A normal de cada espelho
541    // é a bissetriz entre a direção do sol e a direção da torre — mira
542    // real, não decorativa.
543    const mirrorMat = new THREE.MeshStandardMaterial({
544      color: 0xdfe9f5,
545      metalness: 0.85,
546      roughness: 0.18,
547      emissive: 0x0a1620,
548      emissiveIntensity: 0.15,
549      side: THREE.DoubleSide,
550    });
551    const heliostatBaseMat = new THREE.MeshStandardMaterial({ color: 0x33414d, metalness: 0.4, roughness: 0.6 });
552
553    function makeHeliostat() {
554      const g = new THREE.Group();
555      const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 0.55, 8), heliostatBaseMat);
556      pole.position.y = 0.275;
557      g.add(pole);
558      const pivot = new THREE.Group();
559      pivot.position.y = 0.55;
560      g.add(pivot);
561      const mirror = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.03, 0.42), mirrorMat);
562      pivot.add(mirror);
563      return { group: g, pivot };
564    }
565
566    const heliostats = [];
567    const RING_DEFS = [
568      { r: 3.2, n: 8 },
569      { r: 5.2, n: 12 },
570      { r: 7.4, n: 16 },
571    ];
572    RING_DEFS.forEach(({ r, n }) => {
573      for (let i = 0; i < n; i++) {
574        const a = (i / n) * Math.PI * 2;
575        const h = makeHeliostat();
576        h.group.position.set(Math.sin(a) * r, 0, Math.cos(a) * r);
577        h.targetQuat = new THREE.Quaternion();
578        group.add(h.group);
579        heliostats.push(h);
580      }
581    });
582
583    const helioHotspot = makeHotspotMarker();
584    const sampleH = heliostats[10];
585    helioHotspot.position.set(sampleH.group.position.x, 0.75, sampleH.group.position.z);
586    group.add(helioHotspot);
587    hotspots.push({
588      sprite: helioHotspot,
589      title: "Heliostato",
590      text: "Espelho motorizado que gira em dois eixos pra seguir o sol e refletir a luz sempre pro mesmo ponto no topo da torre — a mira é recalculada ao longo do dia.",
591    });
592
593    // Feixes refletidos: uma linha por heliostato até o receptor. As
594    // pontas não mudam (posições fixas) — só a opacidade acompanha se é
595    // dia ou noite.
596    const beamMat = new THREE.LineBasicMaterial({
597      color: 0xffd27a,
598      transparent: true,
599      opacity: 0,
600      blending: THREE.AdditiveBlending,
601      depthWrite: false,
602    });
603    const beams = heliostats.map((h) => {
604      const from = h.group.position.clone().setY(0.58);
605      const geo = new THREE.BufferGeometry().setFromPoints([from, receiverPos]);
606      const line = new THREE.Line(geo, beamMat.clone());
607      group.add(line);
608      return line;
609    });
610
611    const towerLabel = makeTextLabel("TORRE CSP");
612    towerLabel.position.set(0, TOWER_H + 2, 0);
613    group.add(towerLabel);
614
615    // Tanques de sal fundido (quente / frio) + bloco de potência
616    const hotTankMat = new THREE.MeshStandardMaterial({
617      color: 0x8a2e12,
618      metalness: 0.4,
619      roughness: 0.5,
620      emissive: 0xff4d1f,
621      emissiveIntensity: 0.35,
622    });
623    const coldTankMat = new THREE.MeshStandardMaterial({
624      color: 0x1e5a8c,
625      metalness: 0.4,
626      roughness: 0.5,
627      emissive: 0x1c4a70,
628      emissiveIntensity: 0.15,
629    });
630    function makeTank(mat) {
631      const g = new THREE.Group();
632      const body = new THREE.Mesh(new THREE.CylinderGeometry(0.62, 0.62, 1.5, 18), mat);
633      body.position.y = 0.75;
634      g.add(body);
635      const cap = new THREE.Mesh(
636        new THREE.SphereGeometry(0.62, 18, 9, 0, Math.PI * 2, 0, Math.PI / 2),
637        mat
638      );
639      cap.position.y = 1.5;
640      g.add(cap);
641      return g;
642    }
643    const hotTank = makeTank(hotTankMat);
644    hotTank.position.set(-2.7, 0, 3.6);
645    group.add(hotTank);
646    const coldTank = makeTank(coldTankMat);
647    coldTank.position.set(-1.15, 0, 3.6);
648    group.add(coldTank);
649
650    const powerBlock = new THREE.Mesh(
651      new THREE.BoxGeometry(1.4, 0.85, 1.05),
652      new THREE.MeshStandardMaterial({ color: 0x2a3542, metalness: 0.3, roughness: 0.7 })
653    );
654    powerBlock.position.set(0.5, 0.43, 4.3);
655    group.add(powerBlock);
656
657    const hotHotspot = makeHotspotMarker();
658    hotHotspot.position.set(-2.7, 1.85, 3.6);
659    group.add(hotHotspot);
660    hotspots.push({
661      sprite: hotHotspot,
662      title: "Tanque quente",
663      text: "Armazena o sal fundido já aquecido pela torre. É essa energia térmica guardada que permite gerar eletricidade mesmo depois do pôr do sol.",
664    });
665
666    const coldHotspot = makeHotspotMarker();
667    coldHotspot.position.set(-1.15, 1.85, 3.6);
668    group.add(coldHotspot);
669    hotspots.push({
670      sprite: coldHotspot,
671      title: "Tanque frio",
672      text: "Recebe o sal depois de ceder seu calor pra gerar vapor e mover a turbina. Volta a ser bombeado pra torre e reaquecido, fechando o ciclo.",
673    });
674
675    const upVec = new THREE.Vector3(0, 1, 0);
676    function applyExtra(detail) {
677      const sunU = clamp((detail.p - 0.25) * 2, 0, 1);
678      const az = (sunU - 0.5) * Math.PI * 1.1;
679      const elev = Math.max(0.04, detail.sunEl);
680      const sunDir = new THREE.Vector3(Math.sin(az), elev, -Math.cos(az) * 0.45).normalize();
681      const isDay = detail.sunEl > 0.02;
682
683      heliostats.forEach((h) => {
684        const toTarget = receiverPos.clone().sub(h.group.position).normalize();
685        const normal = toTarget.add(sunDir).normalize();
686        h.targetQuat.setFromUnitVectors(upVec, normal);
687      });
688
689      beams.forEach((line) => {
690        line.material.opacity = isDay ? 0.14 + elev * 0.34 : 0;
691      });
692
693      receiverMat.emissive.setHex(isDay ? 0xff6a2b : 0x552200);
694      receiverMat.emissiveIntensity = isDay ? 0.5 + elev * 1.7 : 0.15;
695
696      // O tanque quente guarda calor: continua "aceso" à noite (é
697      // exatamente esse o ponto do TES), só menos intenso que em pleno sol.
698      hotTankMat.emissiveIntensity = isDay ? 0.35 + elev * 0.3 : 0.28;
699    }
700
701    function animate(dt) {
702      heliostats.forEach((h) => h.pivot.quaternion.slerp(h.targetQuat, Math.min(1, dt * 1.6)));
703      receiverMat.emissiveIntensity += Math.sin(performance.now() / 260) * 0.03;
704    }
705
706    return { group, hotspots, applyExtra, animate };
707  }
708
709  const pvScene = buildPvScene();
710  const windScene = buildWindScene();
711  const cspScene = buildCspScene();
712  scene.add(pvScene.group, windScene.group, cspScene.group);
713  windScene.group.visible = false;
714  cspScene.group.visible = false;
715
716  const SCENES = { pv: pvScene, wind: windScene, csp: cspScene };
717  let activeKey = "pv";
718  let lastDetail = null;
719
720  const COPY = {
721    pv: {
722      heading: "Do sol ao painel,<br />rastreamento em ação.",
723      paragraph:
724        "Uma amostra do que estudamos no Complexo de Energias Renováveis: rastreadores solares que giram ao longo do dia pra manter o painel sempre de frente pro sol, aumentando a geração — monitorados em tempo real pelos nossos grupos de pesquisa no CMER.",
725    },
726    wind: {
727      heading: "Do vento,<br />energia limpa e constante.",
728      paragraph:
729        "O litoral do Piauí, como Luís Correia, tem um dos melhores potenciais eólicos do Brasil. Veja a nacele girar pra encarar o vento e as pás transformarem esse movimento em eletricidade — em tempo real, com velocidade e direção simuladas.",
730    },
731    csp: {
732      heading: "Sol concentrado,<br />energia guardada.",
733      paragraph:
734        "Heliostatos motorizados refletem a luz solar para o topo da torre, aquecendo sal fundido a mais de 500°C. Esse calor fica nos tanques (TES) e vira eletricidade mesmo depois do pôr do sol — geração despachável, 24 horas por dia.",
735    },
736  };
737
738  // ---------------------------------------------------------------------
739  // Sincronização com o horário (js/solar-day-cycle.js via evento
740  // "solar:render" — nenhuma lógica de relógio/clique duplicada aqui)
741  // ---------------------------------------------------------------------
742  function applyState(detail) {
743    if (!detail) return;
744    lastDetail = detail;
745
746    const sunU = clamp((detail.p - 0.25) * 2, 0, 1);
747    const az = (sunU - 0.5) * Math.PI * 1.1;
748    const elev = Math.max(0, detail.sunEl);
749    const R = 26;
750    sunMesh.position.set(Math.sin(az) * R, 1 + elev * R * 0.55, -Math.cos(az) * R * 0.4 - 6);
751    sunMesh.visible = detail.sunOp > 0.02;
752    sunLight.position.copy(sunMesh.position);
753    sunLight.target.position.set(0, 1, 0);
754    sunLight.intensity = 0.1 + elev * 1.35;
755    sunLight.color.copy(parseRgb(detail.sky.horizon, 0xffe9c0)).lerp(new THREE.Color(0xffe9c0), 0.5);
756
757    const moonAz = az + Math.PI;
758    const moonElev = Math.max(0, detail.moonEl);
759    moonMesh.position.set(Math.sin(moonAz) * R, 1 + moonElev * R * 0.55, -Math.cos(moonAz) * R * 0.4 - 6);
760    moonMesh.visible = detail.moonOp > 0.02;
761
762    // Piso mínimo de luz mesmo em plena noite (sunEl bem negativo): sem isso
763    // a cena vira um retângulo preto — nada de errado tecnicamente (é a
764    // exposição real), mas péssimo numa seção de vitrine do site.
765    ambient.intensity = 0.4 + elev * 0.35;
766    hemi.intensity = 0.42 + elev * 0.3;
767
768    const skyTop = parseRgb(detail.sky.top, 0x04141a);
769    const skyMid = parseRgb(detail.sky.mid, 0x062f2a);
770    renderer.setClearColor(skyMid);
771    scene.fog.color.copy(skyTop);
772
773    groundMat.color.copy(parseRgb(detail.land.sand, 0xb9a074));
774    seaMat.color.copy(parseRgb(detail.land.seaTop, 0x1e8c9b));
775
776    starMat.opacity = detail.night * 0.85;
777
778    SCENES[activeKey].applyExtra(detail);
779  }
780
781  if (window.__solarState) applyState(window.__solarState);
782  wrap.addEventListener("solar:render", (e) => {
783    applyState(e.detail);
784    // Cobre também o caso prefers-reduced-motion, onde o loop de animação
785    // (que normalmente atualiza o painel) nem chega a rodar.
786    if (typeof updateDataPanel === "function") updateDataPanel();
787  });
788
789  // ---------------------------------------------------------------------
790  // UI: alternar geração, tela cheia, cartão de explicação dos hotspots
791  // ---------------------------------------------------------------------
792  const controls = document.getElementById("v2-3d-controls");
793  const hotspotHint = document.getElementById("v2-3d-hotspot-hint");
794  const btnPv = document.getElementById("v2-3d-scene-pv");
795  const btnWind = document.getElementById("v2-3d-scene-wind");
796  const btnCsp = document.getElementById("v2-3d-scene-csp");
797  const btnFullscreen = document.getElementById("v2-3d-fullscreen-btn");
798  const headingEl = document.getElementById("v2-3d-heading");
799  const paragraphEl = document.getElementById("v2-3d-paragraph");
800  const infoCard = document.getElementById("v2-3d-info-card");
801  const infoTitle = document.getElementById("v2-3d-info-title");
802  const infoText = document.getElementById("v2-3d-info-text");
803  const infoClose = document.getElementById("v2-3d-info-close");
804
805  function closeInfoCard() {
806    if (infoCard) infoCard.classList.remove("is-open");
807  }
808  function openInfoCard(hotspot) {
809    if (!infoCard || !infoTitle || !infoText) return;
810    infoTitle.textContent = hotspot.title;
811    infoText.textContent = hotspot.text;
812    infoCard.hidden = false;
813    requestAnimationFrame(() => infoCard.classList.add("is-open"));
814    if (hotspotHint) hotspotHint.hidden = true;
815  }
816  if (infoClose) infoClose.addEventListener("click", closeInfoCard);
817
818  function switchScene(key) {
819    if (key === activeKey || !SCENES[key]) return;
820    activeKey = key;
821    pvScene.group.visible = key === "pv";
822    windScene.group.visible = key === "wind";
823    cspScene.group.visible = key === "csp";
824    startCameraTransition(CAMERA_PRESETS[key]);
825    closeInfoCard();
826
827    if (headingEl) headingEl.innerHTML = COPY[key].heading;
828    if (paragraphEl) paragraphEl.textContent = COPY[key].paragraph;
829    if (btnPv) btnPv.classList.toggle("is-active", key === "pv");
830    if (btnWind) btnWind.classList.toggle("is-active", key === "wind");
831    if (btnCsp) btnCsp.classList.toggle("is-active", key === "csp");
832
833    // Publica a geração ativa no wrap: js/solar-day-cycle.js lê isso pra
834    // NÃO assumir o controle do horário quando a eólica está no ar (ali a
835    // interação do visitante é o vento, não o sol).
836    wrap.dataset.scene3d = key;
837    if (key !== "wind") {
838      windControl.manual = false;
839      wrap.classList.remove("wind-manual");
840    }
841    updateInteractionHint();
842
843    if (lastDetail) SCENES[key].applyExtra(lastDetail);
844    // Sem isto o painel continuaria mostrando as métricas da geração
845    // anterior (rótulos trocados) até o próximo tick do loop de animação —
846    // e, com prefers-reduced-motion, esse tick nunca vem.
847    updateDataPanel();
848  }
849  if (btnPv) btnPv.addEventListener("click", () => switchScene("pv"));
850  if (btnWind) btnWind.addEventListener("click", () => switchScene("wind"));
851  if (btnCsp) btnCsp.addEventListener("click", () => switchScene("csp"));
852
853  // =====================================================================
854  // CONTROLE DO VENTO (só na geração eólica)
855  // Clicar liga/desliga o modo manual; arrastar na horizontal define a
856  // velocidade. Esquerda = calmaria, direita = vendaval (cut-out).
857  // =====================================================================
858  const timeHintEl = document.getElementById("solar-time-hint");
859
860  function updateInteractionHint() {
861    if (!timeHintEl) return;
862    if (activeKey === "wind") {
863      timeHintEl.textContent = windControl.manual
864        ? "Arraste na horizontal para mudar o vento"
865        : "Clique na cena para controlar o vento";
866    } else {
867      timeHintEl.textContent = "Clique na cena para controlar o horário";
868    }
869  }
870
871  function windSpeedFromPointer(e) {
872    const rect = wrap.getBoundingClientRect();
873    const x = clamp((e.clientX - rect.left) / rect.width, 0, 1);
874    return WIND_MIN + x * (WIND_MAX - WIND_MIN);
875  }
876
877  wrap.addEventListener("click", (e) => {
878    if (activeKey !== "wind") return;
879    // não confundir com cliques nos controles/cartões da interface
880    if (e.target.closest(".v2-3d-controls, .v2-3d-info-card, .v2-3d-overlay")) return;
881    windControl.manual = !windControl.manual;
882    wrap.classList.toggle("wind-manual", windControl.manual);
883    if (windControl.manual) windControl.speed = windSpeedFromPointer(e);
884    updateInteractionHint();
885  });
886
887  wrap.addEventListener("pointermove", (e) => {
888    if (activeKey !== "wind" || !windControl.manual) return;
889    windControl.speed = windSpeedFromPointer(e);
890  });
891
892  // =====================================================================
893  // PAINEL DE DADOS AO VIVO — números que reagem à interação
894  // Modelos simplificados, mas com a física real por trás (não são valores
895  // decorativos): é isso que dá o valor didático do painel.
896  // =====================================================================
897  const dataEl = document.getElementById("v2-3d-data");
898  const M = [1, 2, 3].map((i) => ({
899    v: document.getElementById(`v2-3d-m${i}v`),
900    l: document.getElementById(`v2-3d-m${i}l`),
901  }));
902
903  const PV_KWP = 3.3; // 6 módulos de 550 Wp
904  const RHO = 1.225; // densidade do ar (kg/m³)
905  const ROTOR_R = 6; // m
906  const ROTOR_A = Math.PI * ROTOR_R * ROTOR_R;
907  const CP = 0.4; // coeficiente de potência (limite de Betz = 0,593)
908  const N_TURBINES = 3;
909  const WIND_CUT_IN = 3;
910  const WIND_RATED = 12;
911  const HELIOSTAT_AREA = 36 * 6; // 36 espelhos de 6 m²
912  const FIELD_EFF = 0.6; // perdas ópticas do campo
913  const CYCLE_EFF = 0.4; // rendimento do bloco de potência
914  const TES_DISCHARGE_KW = 3; // potência do bloco puxando do estoque à noite
915
916  /** Irradiância direta aproximada (W/m²) a partir da elevação solar. */
917  function irradiance(detail) {
918    return Math.max(0, detail ? detail.sunEl : 0) * 1000;
919  }
920
921  /** Carga do armazenamento térmico (%): enche de dia, esvazia à noite. */
922  function tesCharge(detail) {
923    if (!detail) return 0;
924    const p = detail.p;
925    if (p >= 0.25 && p <= 0.75) {
926      // dia: 0,25 = nascer do sol, 0,75 = pôr do sol
927      return 15 + 85 * ((p - 0.25) / 0.5);
928    }
929    // noite: decai desde o pôr do sol até o amanhecer seguinte
930    const since = p > 0.75 ? p - 0.75 : p + 0.25;
931    return Math.max(0, 100 - 85 * (since / 0.5));
932  }
933
934  function windPower(v) {
935    if (v < WIND_CUT_IN || v >= WIND_MAX) return 0; // fora de operação
936    const vEff = Math.min(v, WIND_RATED); // acima do nominal a turbina limita
937    return (0.5 * RHO * ROTOR_A * Math.pow(vEff, 3) * CP * N_TURBINES) / 1000; // kW
938  }
939
940  function windRpm(v) {
941    if (v < WIND_CUT_IN || v >= WIND_MAX) return 0;
942    const vEff = Math.min(v, WIND_RATED);
943    const omega = (7 * vEff) / ROTOR_R; // razão de velocidade de ponta ~7
944    return (omega * 60) / (2 * Math.PI);
945  }
946
947  function setMetric(i, value, label) {
948    if (M[i].v) M[i].v.textContent = value;
949    if (M[i].l) M[i].l.textContent = label;
950  }
951
952  let lastDataUpdate = 0;
953  function updateDataPanel() {
954    if (!dataEl) return;
955    dataEl.hidden = false;
956    const d = lastDetail;
957
958    if (activeKey === "wind") {
959      const v = SCENES.wind.getWindSpeed ? SCENES.wind.getWindSpeed() : 0;
960      const kw = windPower(v);
961      setMetric(0, `${v.toFixed(1)} m/s`, "Velocidade do vento");
962      setMetric(1, `${windRpm(v).toFixed(0)} rpm`, "Rotação do rotor");
963      let status = "Gerando";
964      if (v < WIND_CUT_IN) status = "Vento fraco";
965      else if (v >= WIND_MAX) status = "Parada (segurança)";
966      else if (v >= WIND_RATED) status = "Potência nominal";
967      setMetric(2, `${kw.toFixed(1)} kW`, status);
968      return;
969    }
970
971    const g = irradiance(d);
972    if (activeKey === "pv") {
973      const kw = (PV_KWP * g) / 1000;
974      setMetric(0, `${g.toFixed(0)} W/m²`, "Irradiância");
975      setMetric(1, `${d ? d.tilt.toFixed(0) : 0}°`, "Ângulo do tracker");
976      setMetric(2, `${kw.toFixed(2)} kW`, g > 5 ? "Gerando" : "Sem sol");
977      return;
978    }
979
980    // CSP + TES
981    const charge = tesCharge(d);
982    const qTh = (g * HELIOSTAT_AREA * FIELD_EFF) / 1000; // kW térmicos
983    // Com armazenamento a usina segue gerando à noite. Numa usina real o
984    // bloco de potência roda em regime nominal até o estoque acabar — por
985    // isso a descarga é constante, e não proporcional à carga restante.
986    const kw = g > 5 ? qTh * CYCLE_EFF : charge > 2 ? TES_DISCHARGE_KW : 0;
987    setMetric(0, `${g.toFixed(0)} W/m²`, "Irradiância");
988    setMetric(1, `${charge.toFixed(0)}%`, "Carga do TES");
989    setMetric(2, `${kw.toFixed(1)} kW`, g > 5 ? "Gerando (sol)" : charge > 2 ? "Gerando (estoque)" : "Sem estoque");
990  }
991
992  if (btnFullscreen) {
993    btnFullscreen.addEventListener("click", () => {
994      if (document.fullscreenElement) {
995        document.exitFullscreen();
996      } else if (wrap.requestFullscreen) {
997        wrap.requestFullscreen().catch(() => {});
998      }
999    });
1000    document.addEventListener("fullscreenchange", () => {
1001      const isFs = document.fullscreenElement === wrap;
1002      btnFullscreen.setAttribute("aria-pressed", String(isFs));
1003      btnFullscreen.innerHTML = isFs
1004        ? '<i class="fas fa-compress" aria-hidden="true"></i>'
1005        : '<i class="fas fa-expand" aria-hidden="true"></i>';
1006      // O tamanho do canvas muda com a janela — força um resize imediato
1007      // em vez de esperar o próximo tick do ResizeObserver.
1008      resize();
1009    });
1010  }
1011
1012  // Raycasting: só os hotspots da geração ativa são testados. stopPropagation
1013  // no acerto evita que o clique também dispare o modo manual de horário
1014  // (ver guard em js/solar-day-cycle.js).
1015  const raycaster = new THREE.Raycaster();
1016  const pointerNdc = new THREE.Vector2();
1017  canvas.addEventListener("click", (e) => {
1018    const rect = canvas.getBoundingClientRect();
1019    pointerNdc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
1020    pointerNdc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
1021    raycaster.setFromCamera(pointerNdc, camera);
1022    const activeHotspots = SCENES[activeKey].hotspots;
1023    const hits = raycaster.intersectObjects(
1024      activeHotspots.map((h) => h.sprite),
1025      false
1026    );
1027    if (hits.length) {
1028      e.stopPropagation();
1029      const hit = activeHotspots.find((h) => h.sprite === hits[0].object);
1030      if (hit) openInfoCard(hit);
1031    }
1032  });
1033
1034  // ---------------------------------------------------------------------
1035  // Redimensionamento
1036  // ---------------------------------------------------------------------
1037  function resize() {
1038    const w = wrap.clientWidth || 1;
1039    const h = wrap.clientHeight || 1;
1040    camera.aspect = w / h;
1041    camera.updateProjectionMatrix();
1042    renderer.setSize(w, h, false);
1043  }
1044  resize();
1045  const resizeObserver = new ResizeObserver(resize);
1046  resizeObserver.observe(wrap);
1047
1048  // ---------------------------------------------------------------------
1049  // Só entra em cena depois do primeiro frame renderizado — evita mostrar
1050  // um canvas em branco antes de tudo estar no lugar.
1051  // ---------------------------------------------------------------------
1052  placeCamera();
1053  renderer.render(scene, camera);
1054  wrap.classList.add("three-ready");
1055  if (controls) controls.hidden = false;
1056  if (hotspotHint) hotspotHint.hidden = false;
1057  wrap.dataset.scene3d = activeKey;
1058  updateInteractionHint();
1059  updateDataPanel();
1060
1061  if (reducedMotion) return; // um frame estático já foi desenhado; sem loop
1062
1063  // Só anima enquanto a seção está visível.
1064  let active = false;
1065  const io = new IntersectionObserver((entries) => { active = entries[0].isIntersecting; }, { threshold: 0.1 });
1066  io.observe(wrap);
1067
1068  const clock = new THREE.Clock();
1069  function animate() {
1070    requestAnimationFrame(animate);
1071    if (!active) return;
1072    const dt = Math.min(0.05, clock.getDelta());
1073    camTheta += dt * 0.045; // órbita lenta e contínua da câmera
1074    updateCameraTransition();
1075    placeCamera();
1076
1077    SCENES.pv.animate(dt);
1078    SCENES.wind.animate(dt);
1079    SCENES.csp.animate(dt);
1080
1081    // pulso sutil nos marcadores de hotspot da geração ativa, pra chamar
1082    // atenção sem depender de hover (não existe "hover" de verdade em
1083    // touch)
1084    const t = performance.now() / 1000;
1085    SCENES[activeKey].hotspots.forEach((h, i) => {
1086      const s = 0.5 + Math.sin(t * 2 + i) * 0.06;
1087      h.sprite.scale.set(s, s, 1);
1088    });
1089
1090    // Painel de dados: ~4x por segundo. Atualizar a cada frame só faria o
1091    // texto tremer sem ganho de informação.
1092    if (t - lastDataUpdate > 0.25) {
1093      lastDataUpdate = t;
1094      updateDataPanel();
1095    }
1096
1097    renderer.render(scene, camera);
1098  }
1099  requestAnimationFrame(animate);
1100})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.