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.