1const THREE_MODULE_URL = "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js"; 2 3const PHASES = [ 4 { 5 stage: "Base plant", 6 title: "District heating", 7 accent: "#ffb45e", 8 power: "14 MVA n+1", 9 stack: "Utility-grade", 10 coolingLabel: "Heating", 11 cooling: "District heating loop", 12 description: 13 "Existing district heating plant, dual redundant grid feeds, on-site generation, and below-grade thermal conduit paths offer utility-grade infrastructure to leverage.", 14 }, 15 { 16 stage: "Phase 1 - Online", 17 title: "First cluster", 18 accent: "#2eeb8f", 19 power: "100 kW", 20 stack: "H200 / B300 servers", 21 cooling: "Air cooled", 22 description: 23 "Phase 1 inference capacity is now online inside the existing plant, with H200 and B300 servers now operating in the ground-floor deployment.", 24 }, 25 { 26 stage: "Phase 2 - Feb 2027", 27 title: "Liquid ramp", 28 accent: "#a8f2ff", 29 power: "2 MW", 30 stack: "GB300 racks", 31 coolingLabel: "Cooling", 32 cooling: "Liquid thermal interlock", 33 description: 34 "GB300 racks thermally interlocked with the existing district heating loop improve performance, reduce time to market, and keep the community warm.", 35 }, 36 { 37 stage: "Phase 3", 38 title: "Full Foundation", 39 accent: "#a8f2ff", 40 power: "12 MW", 41 stack: "VR200 racks", 42 cooling: "Liquid + heat recovery", 43 description: 44 "The full Foundation build adds site-wide liquid cooling with rooftop dry cooling, expanded heat recovery, and VR200 racks.", 45 }, 46]; 47 48const COLORS = { 49 cyan: 0x5ee5ff, 50 cyanBright: 0xa8f2ff, 51 blue: 0x1e6dff, 52 red: 0xff3f48, 53 amber: 0xffb45e, 54 ink: 0x02060d, 55 panel: 0x07111d, 56 steel: 0x25384a, 57 darkSteel: 0x0a1f30, 58 concrete: 0x2f4050, 59 asphalt: 0x070c13, 60 copper: 0xff9d5a, 61 heat: 0xff5c52, 62 glass: 0x8edfff, 63}; 64 65function easeOutCubic(value) { 66 return 1 - Math.pow(1 - value, 3); 67} 68 69function clamp(value, min, max) { 70 return Math.max(min, Math.min(max, value)); 71} 72 73function createMaterial(THREE, color, options = {}) { 74 const material = new THREE.MeshStandardMaterial({ 75 color, 76 emissive: options.emissive ?? 0x000000, 77 emissiveIntensity: options.emissiveIntensity ?? 1, 78 roughness: options.roughness ?? 0.58, 79 metalness: options.metalness ?? 0.08, 80 transparent: options.transparent ?? (options.opacity ?? 1) < 1, 81 opacity: options.opacity ?? 1, 82 side: options.side ?? THREE.FrontSide, 83 flatShading: options.flatShading ?? false, 84 }); 85 material.depthWrite = options.depthWrite ?? material.opacity >= 0.75; 86 material.userData.baseOpacity = material.opacity; 87 return material; 88} 89 90function createBasicMaterial(THREE, color, options = {}) { 91 const material = new THREE.MeshBasicMaterial({ 92 color, 93 transparent: options.transparent ?? (options.opacity ?? 1) < 1, 94 opacity: options.opacity ?? 1, 95 side: options.side ?? THREE.FrontSide, 96 depthWrite: options.depthWrite ?? false, 97 blending: options.blending ?? THREE.NormalBlending, 98 }); 99 material.userData.baseOpacity = material.opacity; 100 return material; 101} 102 103function createLabelMaterial(THREE, text, options = {}) { 104 const canvas = document.createElement("canvas"); 105 canvas.width = options.width ?? 1024; 106 canvas.height = options.height ?? 256; 107 108 const context = canvas.getContext("2d"); 109 context.clearRect(0, 0, canvas.width, canvas.height); 110 context.fillStyle = options.background ?? "rgba(0, 0, 0, 0)"; 111 context.fillRect(0, 0, canvas.width, canvas.height); 112 context.fillStyle = options.color ?? "rgba(232, 248, 251, 0.72)"; 113 context.font = options.font ?? "700 88px Arial, sans-serif"; 114 context.letterSpacing = options.letterSpacing ?? "0px"; 115 context.textAlign = options.align ?? "center"; 116 context.textBaseline = "middle"; 117 context.fillText(text, canvas.width / 2, canvas.height / 2); 118 119 const texture = new THREE.CanvasTexture(canvas); 120 texture.needsUpdate = true; 121 122 const material = new THREE.MeshBasicMaterial({ 123 map: texture, 124 transparent: true, 125 opacity: options.opacity ?? 0.72, 126 side: THREE.DoubleSide, 127 depthWrite: false, 128 }); 129 material.userData.baseOpacity = material.opacity; 130 material.userData.texture = texture; 131 return material; 132} 133 134function setObjectOpacity(object, opacity) { 135 object.traverse((child) => { 136 if (!child.material) { 137 return; 138 } 139 if (Array.isArray(child.material)) { 140 child.material = child.material.map((material) => { 141 if (material.userData.opacityScoped) { 142 return material; 143 } 144 const cloned = material.clone(); 145 cloned.userData = { ...material.userData, opacityScoped: true }; 146 return cloned; 147 }); 148 } else if (!child.material.userData.opacityScoped) { 149 const cloned = child.material.clone(); 150 cloned.userData = { ...child.material.userData, opacityScoped: true }; 151 child.material = cloned; 152 } 153 154 const materials = Array.isArray(child.material) ? child.material : [child.material];
155 materials.forEach((material) => { 156 material.opacity = (material.userData.baseOpacity ?? 1) * opacity; 157 material.transparent = material.opacity < 1; 158 }); 159 }); 160} 161 162function makeBox(THREE, size, position, material) { 163 const mesh = new THREE.Mesh(new THREE.BoxGeometry(size[0], size[1], size[2]), material); 164 mesh.position.set(position[0], position[1], position[2]); 165 mesh.castShadow = material.opacity >= 0.75; 166 mesh.receiveShadow = material.opacity >= 0.75; 167 return mesh; 168} 169 170function makeCylinder(THREE, radiusTop, radiusBottom, height, position, material, segments = 32) { 171 const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments), material); 172 mesh.position.set(position[0], position[1], position[2]); 173 mesh.castShadow = material.opacity >= 0.75; 174 mesh.receiveShadow = material.opacity >= 0.75; 175 return mesh; 176} 177 178function makeCone(THREE, radius, height, position, material, segments = 4) { 179 const mesh = new THREE.Mesh(new THREE.ConeGeometry(radius, height, segments), material); 180 mesh.position.set(position[0], position[1], position[2]); 181 mesh.castShadow = material.opacity >= 0.75; 182 return mesh; 183} 184 185function makeSphere(THREE, radius, position, material, segments = 24) { 186 const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, segments, segments / 2), material); 187 mesh.position.set(position[0], position[1], position[2]); 188 mesh.castShadow = material.opacity >= 0.75; 189 return mesh; 190} 191 192function makePlane(THREE, size, position, material, rotation = [0, 0, 0]) { 193 const mesh = new THREE.Mesh(new THREE.PlaneGeometry(size[0], size[1]), material); 194 mesh.position.set(position[0], position[1], position[2]); 195 mesh.rotation.set(rotation[0], rotation[1], rotation[2]); 196 return mesh; 197} 198 199function makeLine(THREE, points, material) { 200 const geometry = new THREE.BufferGeometry().setFromPoints(points); 201 return new THREE.Line(geometry, material); 202} 203 204function makePipe(THREE, points, radius, material, segments = 72) { 205 const curve = new THREE.CatmullRomCurve3(points); 206 const mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, segments, radius, 10, false), material); 207 mesh.castShadow = material.opacity >= 0.75; 208 mesh.receiveShadow = material.opacity >= 0.75; 209 return mesh; 210} 211 212// Shared plan keeps the rounded curtain wall, roof and floor on the same footprint. 213// Dimensions are illustrative model units, inferred from exterior photographs. 214const HALL = { west: -5.8, east: 8.45, front: -3.45, rear: 3.3, radius: 2.7 }; 215 216function makeHallSlab(THREE, top, thickness, material) { 217 const { west, east, front, rear, radius } = HALL; 218 const shape = new THREE.Shape(); 219 shape.moveTo(west + radius, front); 220 shape.lineTo(east, front); 221 shape.lineTo(east, rear); 222 shape.lineTo(west, rear); 223 shape.lineTo(west, front + radius); 224 shape.absarc(west + radius, front + radius, radius, Math.PI, Math.PI * 1.5, false); 225 shape.closePath(); 226 const geometry = new THREE.ExtrudeGeometry(shape, { 227 depth: thickness, 228 bevelEnabled: false, 229 curveSegments: 32, 230 steps: 1, 231 }); 232 const mesh = new THREE.Mesh(geometry, material); 233 // Map the shape's x/y coordinates directly into the building's x/z plan. 234 mesh.rotation.x = Math.PI / 2; 235 mesh.position.y = top; 236 mesh.castShadow = material.opacity >= 0.75; 237 mesh.receiveShadow = true; 238 return mesh; 239} 240 241function makeBeamBetween(THREE, start, end, thickness, material) { 242 const midpoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5); 243 const direction = new THREE.Vector3().subVectors(end, start); 244 const beam = makeBox(THREE, [thickness, direction.length(), thickness], [midpoint.x, midpoint.y, midpoint.z], material); 245 beam.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.normalize()); 246 return beam; 247} 248 249function createSteamTexture(THREE) { 250 const size = 192; 251 const canvas = document.createElement("canvas"); 252 canvas.width = size; 253 canvas.height = size; 254 const context = canvas.getContext("2d"); 255 const gradient = context.createRadialGradient(size * 0.48, size * 0.48, 3, size * 0.5, size * 0.5, size * 0.48); 256 gradient.addColorStop(0, "rgba(255, 255, 255, 0.94)"); 257 gradient.addColorStop(0.2, "rgba(244, 250, 252, 0.72)"); 258 gradient.addColorStop(0.48, "rgba(214, 227, 232, 0.34)");
259 gradient.addColorStop(0.76, "rgba(190, 205, 212, 0.1)"); 260 gradient.addColorStop(1, "rgba(170, 190, 198, 0)"); 261 context.fillStyle = gradient; 262 context.fillRect(0, 0, size, size); 263 264 context.globalCompositeOperation = "destination-out"; 265 for (let index = 0; index < 24; index += 1) { 266 const x = size * (0.24 + ((index * 37) % 53) / 100); 267 const y = size * (0.22 + ((index * 29) % 57) / 100); 268 const radius = 5 + (index % 7) * 2.4; 269 const soft = context.createRadialGradient(x, y, 0, x, y, radius); 270 soft.addColorStop(0, "rgba(0, 0, 0, 0.15)"); 271 soft.addColorStop(1, "rgba(0, 0, 0, 0)"); 272 context.fillStyle = soft; 273 context.beginPath(); 274 context.arc(x, y, radius, 0, Math.PI * 2); 275 context.fill(); 276 } 277 278 const texture = new THREE.CanvasTexture(canvas); 279 texture.colorSpace = THREE.SRGBColorSpace; 280 texture.needsUpdate = true; 281 return texture; 282} 283 284function addRackRow(THREE, group, count, startX, z, y, material, accentMaterial, phase, options = {}) { 285 const width = options.width ?? 0.42; 286 const height = options.height ?? 1.65; 287 const depth = options.depth ?? 0.7; 288 const spacing = options.spacing ?? 0.72; 289 const slotMaterial = options.slotMaterial ?? material; 290 const statusMaterial = options.statusMaterial ?? accentMaterial; 291 292 for (let index = 0; index < count; index += 1) { 293 const rackGroup = new THREE.Group(); 294 rackGroup.position.set(startX + index * spacing, y, z); 295 rackGroup.rotation.y = options.rotationY ?? 0; 296 rackGroup.userData.phase = phase; 297 298 const body = makeBox(THREE, [width, height, depth], [0, 0, 0], material); 299 const front = makeBox( 300 THREE, 301 [width * 0.84, height * 0.84, 0.025], 302 [0, -0.01, -depth / 2 - 0.018], 303 options.frontMaterial ?? slotMaterial 304 ); 305 const topCap = makeBox(THREE, [width * 0.94, 0.045, depth + 0.035], [0, height / 2 + 0.026, 0], material); 306 const sideBus = makeBox( 307 THREE, 308 [0.035, height * 0.82, 0.04], 309 [width / 2 + 0.028, 0, -depth / 2 - 0.03], 310 accentMaterial 311 ); 312 313 rackGroup.add(body, front, topCap, sideBus); 314 315 for (let slot = 0; slot < 7; slot += 1) { 316 const serverBand = makeBox( 317 THREE, 318 [width * 0.65, 0.055, 0.03], 319 [-width * 0.04, -height * 0.31 + slot * (height * 0.092), -depth / 2 - 0.04], 320 slotMaterial 321 ); 322 rackGroup.add(serverBand); 323 } 324 325 for (let light = 0; light < 3; light += 1) { 326 const status = makeBox( 327 THREE, 328 [0.018, 0.018, 0.018], 329 [width * 0.3, height * 0.28 - light * 0.12, -depth / 2 - 0.046], 330 statusMaterial 331 ); 332 rackGroup.add(status); 333 } 334 335 if (options.liquid) { 336 const rearDoor = makeBox( 337 THREE, 338 [width * 0.88, height * 0.86, 0.055], 339 [0, 0, depth / 2 + 0.035], 340 options.coolingMaterial ?? accentMaterial 341 ); 342 const rearHeader = makeCylinder( 343 THREE, 344 0.025, 345 0.025, 346 height * 0.72, 347 [width * 0.38, 0, depth / 2 + 0.08], 348 options.coolingMaterial ?? accentMaterial, 349 12 350 ); 351 rackGroup.add(rearDoor, rearHeader); 352 } 353 354 group.add(rackGroup); 355 } 356} 357 358function addAxialFan(THREE, group, position, materials, rotors, options = {}) { 359 const axis = options.axis ?? "z"; 360 const radius = options.radius ?? 0.3; 361 const bladeCount = options.blades ?? 5; 362 const assembly = new THREE.Group(); 363 assembly.position.set(position[0], position[1], position[2]); 364 365 const recess = new THREE.Mesh( 366 new THREE.CircleGeometry(radius * 0.98, 36), 367 materials.recess 368 ); 369 const shroud = new THREE.Mesh( 370 new THREE.TorusGeometry(radius, Math.max(0.018, radius * 0.065), 10, 44), 371 materials.shroud 372 ); 373 if (axis === "y") { 374 recess.rotation.x = -Math.PI / 2; 375 shroud.rotation.x = Math.PI / 2; 376 } 377 assembly.add(recess, shroud); 378 379 const innerGuard = new THREE.Mesh( 380 new THREE.TorusGeometry(radius * 0.58, Math.max(0.006, radius * 0.025), 7, 32), 381 materials.shroud 382 ); 383 if (axis === "y") { 384 innerGuard.rotation.x = Math.PI / 2; 385 } 386 assembly.add(innerGuard); 387 for (let guardIndex = 0; guardIndex < 4; guardIndex += 1) { 388 const guard = makeBox( 389 THREE,
390 [radius * 1.82, Math.max(0.008, radius * 0.022), Math.max(0.008, radius * 0.022)], 391 [0, axis === "y" ? radius * 0.025 : 0, axis === "z" ? -radius * 0.025 : 0], 392 materials.shroud 393 ); 394 guard.rotation[axis] = guardIndex * (Math.PI / 4); 395 assembly.add(guard); 396 } 397 398 const rotor = new THREE.Group(); 399 if (axis === "y") { 400 rotor.position.y = radius * 0.018; 401 } else { 402 rotor.position.z = -radius * 0.018; 403 } 404 rotor.userData.spinAxis = axis; 405 rotor.userData.spinSpeed = options.speed ?? 1.9; 406 407 const bladeShape = new THREE.Shape(); 408 bladeShape.moveTo(radius * 0.18, -radius * 0.08); 409 bladeShape.quadraticCurveTo(radius * 0.52, -radius * 0.19, radius * 0.91, -radius * 0.07); 410 bladeShape.quadraticCurveTo(radius * 0.72, radius * 0.13, radius * 0.24, radius * 0.1); 411 bladeShape.closePath(); 412 const bladeGeometry = new THREE.ShapeGeometry(bladeShape, 6); 413 414 for (let bladeIndex = 0; bladeIndex < bladeCount; bladeIndex += 1) { 415 const pivot = new THREE.Group(); 416 pivot.rotation[axis] = bladeIndex * ((Math.PI * 2) / bladeCount); 417 const blade = new THREE.Mesh(bladeGeometry, materials.blade); 418 if (axis === "y") { 419 blade.rotation.x = -Math.PI / 2; 420 } 421 pivot.add(blade); 422 rotor.add(pivot); 423 } 424 425 const hub = makeCylinder( 426 THREE, 427 radius * 0.17, 428 radius * 0.2, 429 radius * 0.18, 430 [0, 0, 0], 431 materials.hub, 432 24 433 ); 434 if (axis === "z") { 435 hub.rotation.x = Math.PI / 2; 436 } 437 rotor.add(hub); 438 assembly.add(rotor); 439 group.add(assembly); 440 rotors.push(rotor); 441 return assembly; 442} 443 444function addDryCooler(THREE, group, position, materials, rotors, phase) { 445 const cooler = new THREE.Group(); 446 cooler.position.set(position[0], position[1], position[2]); 447 cooler.userData.phase = phase; 448 449 const base = makeBox(THREE, [2.05, 0.16, 1.2], [0, 0.08, 0], materials.frame); 450 const leftCoil = makeBox(THREE, [1.96, 0.72, 0.09], [0, 0.5, -0.42], materials.coil); 451 const rightCoil = makeBox(THREE, [1.96, 0.72, 0.09], [0, 0.5, 0.42], materials.coil); 452 leftCoil.rotation.x = -0.28; 453 rightCoil.rotation.x = 0.28; 454 cooler.add(base, leftCoil, rightCoil); 455 456 for (let rib = 0; rib < 10; rib += 1) { 457 const x = -0.88 + rib * 0.195; 458 cooler.add(makeBox(THREE, [0.018, 0.68, 0.05], [x, 0.5, -0.47], materials.fin)); 459 cooler.add(makeBox(THREE, [0.018, 0.68, 0.05], [x, 0.5, 0.47], materials.fin)); 460 } 461 462 const fanMaterials = { 463 recess: materials.recess, 464 shroud: materials.frame, 465 blade: materials.blade, 466 hub: materials.hub, 467 }; 468 addAxialFan(THREE, cooler, [-0.5, 0.88, 0], fanMaterials, rotors, { 469 axis: "y", 470 radius: 0.34, 471 blades: 6, 472 speed: 1.45, 473 }); 474 addAxialFan(THREE, cooler, [0.5, 0.88, 0], fanMaterials, rotors, { 475 axis: "y", 476 radius: 0.34, 477 blades: 6, 478 speed: 1.55, 479 }); 480 481 for (const x of [-0.88, 0.88]) { 482 cooler.add(makeBox(THREE, [0.06, 0.58, 0.06], [x, -0.22, -0.46], materials.frame)); 483 cooler.add(makeBox(THREE, [0.06, 0.58, 0.06], [x, -0.22, 0.46], materials.frame)); 484 } 485 group.add(cooler); 486 return cooler; 487} 488 489function addValveWheel(THREE, group, position, radius, material, rotation = [0, 0, 0]) { 490 const wheel = new THREE.Group(); 491 wheel.position.set(position[0], position[1], position[2]); 492 wheel.rotation.set(rotation[0], rotation[1], rotation[2]); 493 const rim = new THREE.Mesh(new THREE.TorusGeometry(radius, radius * 0.09, 8, 28), material); 494 wheel.add(rim); 495 for (let index = 0; index < 4; index += 1) { 496 const spoke = makeBox(THREE, [radius * 1.65, radius * 0.07, radius * 0.07], [0, 0, 0], material); 497 spoke.rotation.z = index * (Math.PI / 4); 498 wheel.add(spoke); 499 } 500 group.add(wheel); 501 return wheel; 502} 503 504function addPumpSkid(THREE, group, position, materials, scale = 1) { 505 const skid = new THREE.Group(); 506 skid.position.set(position[0], position[1], position[2]); 507 skid.scale.setScalar(scale); 508 skid.add(makeBox(THREE, [1.25, 0.11, 0.55], [0, 0.055, 0], materials.frame)); 509 const motor = makeCylinder(THREE, 0.23, 0.23, 0.58, [-0.26, 0.34, 0], materials.motor, 24); 510 motor.rotation.z = Math.PI / 2; 511 skid.add(motor); 512 const pump = makeCylinder(THREE, 0.27, 0.18, 0.34, [0.36, 0.32, 0], materials.pump, 24); 513 pump.rotation.z = Math.PI / 2; 514 skid.add(pump); 515 skid.add(makeCylinder(THREE, 0.065, 0.065, 0.62, [0.55, 0.59, 0], materials.pipe, 16)); 516 group.add(skid); 517 return skid; 518} 519 520function addStackTower(THREE, group, position, materials, steamTexture, steamPlumes, options = {}) { 521 const tower = new THREE.Group(); 522 tower.position.set(position[0], position[1], position[2]); 523 const towerHeight = options.towerHeight ?? 4.9; 524 const stackHeight = options.stackHeight ?? 5.75; 525 const halfWidth = options.halfWidth ?? 0.48; 526 527 for (const x of [-halfWidth, halfWidth]) { 528 for (const z of [-halfWidth, halfWidth]) { 529 tower.add(makeBox(THREE, [0.075, towerHeight, 0.075], [x, towerHeight / 2, z], materials.truss)); 530 } 531 } 532 533 const levels = Array.from({ length: 4 }, (_, index) => 0.12 + index * (towerHeight - 0.24) / 3);
534 levels.forEach((level) => { 535 for (const side of [-halfWidth, halfWidth]) { 536 tower.add(makeBeamBetween(THREE, new THREE.Vector3(-halfWidth, level, side), new THREE.Vector3(halfWidth, level, side), 0.065, materials.truss)); 537 tower.add(makeBeamBetween(THREE, new THREE.Vector3(side, level, -halfWidth), new THREE.Vector3(side, level, halfWidth), 0.065, materials.truss)); 538 } 539 }); 540 541 // The CDHI frames have paired X braces; the polished flues extend well above them. 542 for (let levelIndex = 0; levelIndex < levels.length - 1; levelIndex += 1) { 543 const y0 = levels[levelIndex] + 0.04; 544 const y1 = levels[levelIndex + 1] - 0.04; 545 for (const side of [-halfWidth, halfWidth]) { 546 for (const direction of [-1, 1]) { 547 tower.add(makeBeamBetween(THREE, 548 new THREE.Vector3(-halfWidth * direction, y0, side), 549 new THREE.Vector3(halfWidth * direction, y1, side), 0.045, materials.truss)); 550 tower.add(makeBeamBetween(THREE, 551 new THREE.Vector3(side, y0, -halfWidth * direction), 552 new THREE.Vector3(side, y1, halfWidth * direction), 0.045, materials.truss)); 553 } 554 } 555 } 556 557 const stack = makeCylinder(THREE, 0.245, 0.245, stackHeight, [0, stackHeight / 2 + 0.04, 0], materials.stack, 28); 558 tower.add(stack); 559 for (let ringIndex = 1; ringIndex < 8; ringIndex += 1) { 560 const flange = new THREE.Mesh(new THREE.TorusGeometry(0.248, 0.012, 8, 30), materials.stackRing); 561 flange.rotation.x = Math.PI / 2; 562 flange.position.y = ringIndex * stackHeight / 8; 563 tower.add(flange); 564 } 565 const rim = new THREE.Mesh(new THREE.TorusGeometry(0.245, 0.02, 8, 32), materials.stackRing); 566 rim.rotation.x = Math.PI / 2; 567 rim.position.y = stackHeight + 0.05; 568 tower.add(rim); 569 group.add(tower); 570 571 const plumeCount = options.plumeCount ?? 9; 572 for (let plumeIndex = 0; plumeIndex < plumeCount; plumeIndex += 1) { 573 const steamMaterial = new THREE.SpriteMaterial({ 574 map: steamTexture, 575 color: 0xe9f1f3, 576 transparent: true, 577 opacity: 0, 578 depthWrite: false, 579 depthTest: true, 580 blending: THREE.NormalBlending, 581 }); 582 steamMaterial.userData.baseOpacity = 0.24; 583 const plume = new THREE.Sprite(steamMaterial); 584 plume.userData.steam = { 585 baseX: position[0], 586 baseY: position[1] + stackHeight + 0.12, 587 baseZ: position[2], 588 offset: plumeIndex / plumeCount + (options.seed ?? 0) * 0.071, 589 driftX: 0.9 + ((plumeIndex * 17) % 7) * 0.08, 590 driftZ: -0.28 + ((plumeIndex * 11) % 9) * 0.055, 591 lift: 3.3 + (plumeIndex % 4) * 0.35, 592 opacity: 0.19 + (plumeIndex % 3) * 0.025, 593 size: 0.56 + (plumeIndex % 4) * 0.08, 594 spin: (plumeIndex % 2 === 0 ? 1 : -1) * (0.08 + (plumeIndex % 3) * 0.025), 595 }; 596 group.add(plume); 597 steamPlumes.push(plume); 598 } 599 600 return tower; 601} 602 603function addWindowGrid(THREE, group, options) { 604 const { 605 x, 606 y, 607 z, 608 width, 609 height, 610 columns, 611 rows, 612 mullionMaterial, 613 glassMaterial, 614 warmMaterial, 615 spandrelMaterial, 616 } = options; 617 618 group.add(makeBox(THREE, [width, height, 0.035], [x, y, z], glassMaterial)); 619 620 for (let column = 0; column <= columns; column += 1) { 621 const offset = -width / 2 + (column * width) / columns; 622 group.add(makeBox(THREE, [0.035, height, 0.07], [x + offset, y, z - 0.035], mullionMaterial)); 623 } 624 625 for (let row = 0; row <= rows; row += 1) { 626 const offset = -height / 2 + (row * height) / rows; 627 group.add(makeBox(THREE, [width, 0.035, 0.07], [x, y + offset, z - 0.035], mullionMaterial)); 628 } 629 630 if (spandrelMaterial) { 631 group.add(makeBox(THREE, [width, height * 0.23, 0.04], [x, y + height * 0.385, z + 0.02], spandrelMaterial)); 632 } 633 634 for (let column = 0; warmMaterial && column < columns; column += 2) { 635 const offset = -width / 2 + ((column + 0.5) * width) / columns; 636 group.add(makeBox(THREE, [width / columns - 0.1, 0.09, 0.08], [x + offset, y - height * 0.2, z - 0.08], warmMaterial)); 637 } 638} 639 640function addCurvedCurtainWall(THREE, group, options) { 641 const { center, radius, startAngle, endAngle, segments, y, height, 642 glassMaterial, mullionMaterial, spandrelMaterial, rows = 8 } = options; 643 const step = (endAngle - startAngle) / segments; 644 for (let index = 0; index < segments; index += 1) { 645 const a0 = startAngle + step * index; 646 const a1 = a0 + step;
647 const x0 = center[0] + Math.cos(a0) * radius; 648 const z0 = center[2] + Math.sin(a0) * radius; 649 const x1 = center[0] + Math.cos(a1) * radius; 650 const z1 = center[2] + Math.sin(a1) * radius; 651 const width = Math.hypot(x1 - x0, z1 - z0); 652 const angle = -Math.atan2(z1 - z0, x1 - x0); 653 const panel = makeBox(THREE, [width, height, 0.035], [(x0 + x1) / 2, y, (z0 + z1) / 2], glassMaterial); 654 panel.rotation.y = angle; 655 group.add(panel); 656 if (spandrelMaterial) { 657 const band = makeBox(THREE, [width, height * 0.23, 0.04], [(x0 + x1) / 2, y + height * 0.385, (z0 + z1) / 2], spandrelMaterial); 658 band.rotation.y = angle; 659 group.add(band); 660 } 661 for (let row = 0; row <= rows; row += 1) { 662 const band = makeBox(THREE, [width + 0.008, 0.035, 0.065], [(x0 + x1) / 2, y - height / 2 + row * height / rows, (z0 + z1) / 2], mullionMaterial); 663 band.rotation.y = angle; 664 group.add(band); 665 } 666 group.add(makeBox(THREE, [0.022, height, 0.022], [x0, y, z0], mullionMaterial)); 667 if (index === segments - 1) { 668 group.add(makeBox(THREE, [0.022, height, 0.022], [x1, y, z1], mullionMaterial)); 669 } 670 } 671} 672 673function createPlantScene(THREE, canvas, widget) { 674 const scene = new THREE.Scene(); 675 scene.fog = new THREE.Fog(0x02060d, 48, 108); 676 677 const renderer = new THREE.WebGLRenderer({ 678 canvas, 679 antialias: true, 680 alpha: true, 681 powerPreference: "high-performance", 682 }); 683 renderer.setClearColor(0x000000, 0); 684 renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); 685 renderer.outputColorSpace = THREE.SRGBColorSpace; 686 renderer.toneMapping = THREE.ACESFilmicToneMapping; 687 renderer.toneMappingExposure = 1.12; 688 renderer.shadowMap.enabled = window.matchMedia("(min-width: 720px)").matches; 689 renderer.shadowMap.type = THREE.PCFSoftShadowMap; 690 691 const camera = new THREE.PerspectiveCamera(36, 1, 0.1, 500); 692 const cameraState = { 693 yaw: 3.8, 694 pitch: 0.29, 695 distance: 34.5, 696 targetYaw: 3.8, 697 targetPitch: 0.29, 698 targetDistance: 34.5, 699 dragging: false, 700 lastX: 0, 701 lastY: 0, 702 }; 703 const cameraLookAt = new THREE.Vector3(0.6, 3.4, -0.15); 704 const cameraTargetLookAt = cameraLookAt.clone(); 705 706 const hemisphereLight = new THREE.HemisphereLight(0xd7e9ea, 0x263338, 1.65); 707 scene.add(hemisphereLight); 708 709 const keyLight = new THREE.DirectionalLight(0xfff3df, 2.35); 710 keyLight.position.set(-12, 24, -16); 711 keyLight.castShadow = renderer.shadowMap.enabled; 712 keyLight.shadow.mapSize.set(1024, 1024); 713 keyLight.shadow.camera.left = -18; 714 keyLight.shadow.camera.right = 18; 715 keyLight.shadow.camera.top = 18; 716 keyLight.shadow.camera.bottom = -18; 717 keyLight.shadow.camera.near = 2; 718 keyLight.shadow.camera.far = 70; 719 keyLight.shadow.bias = -0.0002; 720 keyLight.shadow.normalBias = 0.035; 721 scene.add(keyLight); 722 723 const skyFill = new THREE.DirectionalLight(0x7eb8d4, 1.15); 724 skyFill.position.set(-18, 12, 14); 725 scene.add(skyFill); 726 727 const warmInteriorLight = new THREE.PointLight(0xffb15e, 9.5, 18, 1.7); 728 warmInteriorLight.position.set(1.7, 2.0, -2.25); 729 scene.add(warmInteriorLight); 730 731 const warmInteriorFill = new THREE.PointLight(0xff6d40, 5.2, 12, 1.8); 732 warmInteriorFill.position.set(-3.8, 1.65, -0.6); 733 scene.add(warmInteriorFill); 734 735 const baseMaterials = { 736 slab: createMaterial(THREE, 0x11171d, { roughness: 0.92, metalness: 0 }), 737 asphalt: createMaterial(THREE, COLORS.asphalt, { roughness: 0.96, metalness: 0 }), 738 concrete: createMaterial(THREE, 0x59636a, { roughness: 0.9, metalness: 0 }), 739 concreteGhost: createMaterial(THREE, COLORS.concrete, { 740 emissive: 0x07131b, 741 roughness: 0.82, 742 metalness: 0, 743 opacity: 0.18, 744 side: THREE.DoubleSide, 745 depthWrite: false, 746 }), 747 building: createMaterial(THREE, 0xb9c1bf, { roughness: 0.68, metalness: 0.1 }), 748 blackBand: createMaterial(THREE, 0x0c1116, { roughness: 0.36, metalness: 0.24 }), 749 whitePanel: createMaterial(THREE, 0xc9ced0, { roughness: 0.38, metalness: 0.42 }), 750 redPanel: createMaterial(THREE, 0xa62b27, { roughness: 0.44, metalness: 0.16 }), 751 warmLight: createBasicMaterial(THREE, 0xffbf69, { 752 opacity: 0.62, 753 blending: THREE.AdditiveBlending, 754 }), 755 glass: new THREE.MeshPhysicalMaterial({ 756 color: 0x548e92, 757 roughness: 0.2, 758 metalness: 0.08, 759 transmission: 0.03, 760 transparent: true, 761 opacity: 0.34, 762 side: THREE.DoubleSide, 763 depthWrite: false, 764 }), 765 mullion: createMaterial(THREE, 0xb9c8cb, { roughness: 0.4, metalness: 0.55 }), 766 steel: createMaterial(THREE, 0x34434d, { roughness: 0.42, metalness: 0.68 }), 767 darkSteel: createMaterial(THREE, 0x111a21, { roughness: 0.5, metalness: 0.52 }), 768 stainless: createMaterial(THREE, 0xc6d2d5, { roughness: 0.3, metalness: 0.58 }), 769 stainlessDark: createMaterial(THREE, 0x67757b, { roughness: 0.28, metalness: 0.88 }), 770 truss: createMaterial(THREE, 0xe1e3dd, { roughness: 0.32, metalness: 0.56 }), 771 amber: createMaterial(THREE, COLORS.amber, { emissive: 0x3d2207, emissiveIntensity: 0.34, roughness: 0.46 }), 772 cyan: createMaterial(THREE, COLORS.cyan, { emissive: 0x0a5c70, emissiveIntensity: 0.42, roughness: 0.38, metalness: 0.3 }), 773 cyanBright: createMaterial(THREE, COLORS.cyanBright, { emissive: 0x164f59, emissiveIntensity: 0.45, roughness: 0.34, metalness: 0.34 }), 774 blue: createMaterial(THREE, 0x2774bc, { emissive: 0x071f56, emissiveIntensity: 0.22, roughness: 0.4, metalness: 0.35 }), 775 valveBlue: createMaterial(THREE, 0x1565ac, { roughness: 0.34, metalness: 0.54 }), 776 green: createMaterial(THREE, 0x36df8c, { emissive: 0x0b5a35, emissiveIntensity: 0.75, roughness: 0.34 }), 777 fanGreen: createMaterial(THREE, 0x2ca46f, { 778 emissive: 0x083c28, 779 emissiveIntensity: 0.38, 780 roughness: 0.4, 781 metalness: 0.42, 782 }), 783 greenGhost: createMaterial(THREE, 0x36df8c, { 784 emissive: 0x0b5a35, 785 emissiveIntensity: 0.42, 786 roughness: 0.5, 787 opacity: 0.13, 788 side: THREE.DoubleSide, 789 depthWrite: false, 790 }), 791 red: createMaterial(THREE, 0xbf302d, { emissive: 0x37080b, emissiveIntensity: 0.22, roughness: 0.42, metalness: 0.24 }), 792 heat: createMaterial(THREE, 0xe55a46, { emissive: 0x4c0c0a, emissiveIntensity: 0.24, roughness: 0.4, metalness: 0.28 }), 793 copper: createMaterial(THREE, 0xb66b3d, { roughness: 0.32, metalness: 0.72 }), 794 yellow: createMaterial(THREE, 0xe5a92b, { roughness: 0.48, metalness: 0.18 }), 795 fanRecess: createBasicMaterial(THREE, 0x03070a, { opacity: 0.94 }), 796 fin: createMaterial(THREE, 0x71848b, { roughness: 0.56, metalness: 0.74 }), 797 fxCyan: createBasicMaterial(THREE, COLORS.cyanBright, { 798 opacity: 0, 799 side: THREE.DoubleSide, 800 blending: THREE.AdditiveBlending, 801 }), 802 fxAmber: createBasicMaterial(THREE, COLORS.amber, { 803 opacity: 0, 804 side: THREE.DoubleSide, 805 blending: THREE.AdditiveBlending, 806 }), 807 fxHeat: createBasicMaterial(THREE, COLORS.heat, { 808 opacity: 0, 809 side: THREE.DoubleSide, 810 blending: THREE.AdditiveBlending, 811 }), 812 ghost: createMaterial(THREE, COLORS.cyanBright, { 813 emissive: 0x0d2e79, 814 emissiveIntensity: 0.4, 815 roughness: 0.5, 816 metalness: 0.15, 817 opacity: 0.1, 818 side: THREE.DoubleSide, 819 depthWrite: false, 820 }), 821 }; 822 baseMaterials.glass.userData.baseOpacity = baseMaterials.glass.opacity; 823 824 const groups = { 825 base: new THREE.Group(), 826 phase1: new THREE.Group(), 827 phase2: new THREE.Group(), 828 phase3: new THREE.Group(), 829 }; 830
831 Object.values(groups).forEach((group) => scene.add(group)); 832 const steamPlumes = []; 833 const animatedFlowMaterials = []; 834 const rotors = []; 835 const steamTexture = createSteamTexture(THREE); 836 837 const grid = new THREE.GridHelper(52, 26, 0x315368, 0x172b38); 838 grid.material.transparent = true; 839 grid.material.opacity = 0.1; 840 grid.position.y = -0.2; 841 scene.add(grid); 842 843 const groundMaterial = baseMaterials.slab.clone(); 844 groundMaterial.userData = { ...baseMaterials.slab.userData }; 845 const streetMaterial = baseMaterials.asphalt.clone(); 846 streetMaterial.userData = { ...baseMaterials.asphalt.userData }; 847 const sidewalkMaterial = baseMaterials.concrete.clone(); 848 sidewalkMaterial.userData = { ...baseMaterials.concrete.userData }; 849 const ground = makeBox(THREE, [24.5, 0.18, 15.2], [0, -0.13, 0], groundMaterial); 850 const street = makeBox(THREE, [24.5, 0.08, 3.15], [0, -0.03, -6.45], streetMaterial); 851 const sidewalk = makeBox(THREE, [24.5, 0.12, 1.8], [0, 0.01, -4.35], sidewalkMaterial); 852 const serviceYard = makeBox(THREE, [8.4, 0.1, 5.6], [-7.15, 0.01, 0.75], baseMaterials.asphalt); 853 groups.base.add(ground, street, sidewalk, serviceYard); 854 855 for (let index = 0; index < 12; index += 1) { 856 groups.base.add(makeBox(THREE, [0.024, 0.012, 1.72], [-11.2 + index * 2.05, 0.08, -4.35], baseMaterials.darkSteel)); 857 } 858 859 const conduitVault = new THREE.Group(); 860 conduitVault.position.set(-0.6, -1.03, -4.9); 861 groups.base.add(conduitVault); 862 conduitVault.add(makeBox(THREE, [20.6, 1.45, 1.72], [0, 0, 0], baseMaterials.concreteGhost)); 863 conduitVault.add(makeBox(THREE, [20.8, 0.12, 1.9], [0, -0.78, 0], baseMaterials.concrete)); 864 conduitVault.add(makeBox(THREE, [20.8, 0.12, 0.12], [0, -0.04, -0.94], baseMaterials.concrete)); 865 conduitVault.add(makeBox(THREE, [20.8, 0.12, 0.12], [0, -0.04, 0.94], baseMaterials.concrete)); 866 867 const conduitPaths = [ 868 { z: -5.42, radius: 0.07, material: baseMaterials.cyan }, 869 { z: -5.15, radius: 0.07, material: baseMaterials.blue }, 870 { z: -4.88, radius: 0.055, material: baseMaterials.amber }, 871 { z: -4.62, radius: 0.045, material: baseMaterials.cyanBright }, 872 ]; 873 conduitPaths.forEach((pathConfig) => { 874 groups.base.add( 875 makePipe( 876 THREE, 877 [ 878 new THREE.Vector3(-12.2, -1.02, pathConfig.z), 879 new THREE.Vector3(-7.8, -1.04, pathConfig.z), 880 new THREE.Vector3(-3.2, -0.98, pathConfig.z + 0.28), 881 new THREE.Vector3(0.2, -0.72, pathConfig.z + 1.3), 882 new THREE.Vector3(2.2, -0.25, pathConfig.z + 2.5), 883 ], 884 pathConfig.radius, 885 pathConfig.material, 886 88 887 ) 888 ); 889 }); 890 891 // Exterior references (photographic approximation, not a surveyed/as-built model): 892 // https://calgarydistrictheating.com/ — current sign and four flues in white frames. 893 // https://gga-arch.com/projects-item/enmax-district-energy/ — curved curtain wall, 894 // thin parapet, silver service wing and red-edged lower glazing. 895 // Future AI equipment remains the existing illustrative phase layout. 896 const hallFloor = makeHallSlab(THREE, 0.18, 0.16, baseMaterials.concrete); 897 const hallRear = makeBox(THREE, [14.25, 3.2, 0.18], [1.325, 1.78, 3.3], baseMaterials.building); 898 const eastWall = makeBox(THREE, [0.16, 3.2, 6.75], [8.45, 1.78, -0.075], baseMaterials.whitePanel); 899 const roof = makeHallSlab(THREE, 3.58, 0.16, baseMaterials.blackBand); 900 const warmHallFloor = makeBox(THREE, [10.7, 0.025, 4.7], [2.15, 0.19, -0.1], baseMaterials.warmLight); 901 groups.base.add(hallFloor, hallRear, eastWall, roof, warmHallFloor); 902 903 // The low white service wing extends from the short return, behind the rounded corner. 904 const annex = makeBox(THREE, [2.55, 1.7, 3.0], [-7.1, 1.03, 1.65], baseMaterials.whitePanel); 905 const annexRoof = makeBox(THREE, [2.65, 0.12, 3.1], [-7.1, 1.94, 1.65], baseMaterials.stainlessDark); 906 groups.base.add(annex, annexRoof); 907 for (let seam = 0; seam < 8; seam += 1) { 908 groups.base.add(makeBox(THREE, [0.02, 1.58, 0.012], [-8.386, 1.04, 0.27 + seam * 0.4], baseMaterials.stainlessDark)); 909 } 910 groups.base.add(makeBox(THREE, [0.025, 0.2, 2.35], [-8.4, 1.45, 1.65], baseMaterials.blackBand)); 911
912 const facadeHeight = 3.16; 913 const facadeY = 1.8; 914 const straightStart = HALL.west + HALL.radius; 915 const frontWidth = HALL.east - straightStart; 916 addWindowGrid(THREE, groups.base, { 917 x: (straightStart + HALL.east) / 2, y: facadeY, z: HALL.front, 918 width: frontWidth, height: facadeHeight, columns: 16, rows: 8, 919 mullionMaterial: baseMaterials.mullion, glassMaterial: baseMaterials.glass, 920 spandrelMaterial: baseMaterials.blackBand, 921 }); 922 addCurvedCurtainWall(THREE, groups.base, { 923 center: [straightStart, 0, HALL.front + HALL.radius], radius: HALL.radius, 924 startAngle: Math.PI, endAngle: Math.PI * 1.5, segments: 18, 925 y: facadeY, height: facadeHeight, rows: 8, 926 glassMaterial: baseMaterials.glass, mullionMaterial: baseMaterials.mullion, 927 spandrelMaterial: baseMaterials.blackBand, 928 }); 929 const westGlazing = new THREE.Group(); 930 westGlazing.position.set(HALL.west, 0, (HALL.front + HALL.radius + HALL.rear) / 2); 931 westGlazing.rotation.y = Math.PI / 2; 932 addWindowGrid(THREE, westGlazing, { 933 x: 0, y: facadeY, z: 0, width: HALL.rear - HALL.front - HALL.radius, 934 height: facadeHeight, columns: 6, rows: 8, 935 mullionMaterial: baseMaterials.mullion, glassMaterial: baseMaterials.glass, 936 spandrelMaterial: baseMaterials.blackBand, 937 }); 938 groups.base.add(westGlazing); 939 940 // A projecting white frame around glazing, rather than a solid wall hiding the plant. 941 const frontBay = new THREE.Group(); 942 frontBay.position.set(3.1, 0, -3.7); 943 frontBay.add(makeBox(THREE, [5.8, 0.22, 0.32], [0, 1.91, 0], baseMaterials.whitePanel)); 944 frontBay.add(makeBox(THREE, [5.8, 0.24, 0.32], [0, 0.31, 0], baseMaterials.whitePanel)); 945 for (const x of [-2.79, 2.79]) { 946 frontBay.add(makeBox(THREE, [0.22, 1.6, 0.32], [x, 1.11, 0], baseMaterials.whitePanel)); 947 } 948 for (const x of [-2.66, 2.66]) { 949 frontBay.add(makeBox(THREE, [0.035, 1.37, 0.055], [x, 1.11, -0.185], baseMaterials.redPanel)); 950 } 951 frontBay.add(makeBox(THREE, [5.34, 0.035, 0.055], [0, 1.79, -0.185], baseMaterials.redPanel)); 952 addWindowGrid(THREE, frontBay, { 953 x: 0, y: 1.1, z: -0.1, width: 5.28, height: 1.34, columns: 8, rows: 2, 954 mullionMaterial: baseMaterials.mullion, glassMaterial: baseMaterials.glass, 955 }); 956 groups.base.add(frontBay); 957 958 const loadingBay = makeBox(THREE, [2.15, 1.96, 0.25], [7.42, 1.17, -3.49], baseMaterials.whitePanel); 959 const loadingDoor = makeBox(THREE, [1.38, 1.62, 0.04], [7.32, 1.12, -3.64], baseMaterials.glass); 960 groups.base.add(loadingBay, loadingDoor); 961 // A warm translucent roller door is visible in the architect's street elevation. 962 groups.base.add(makeBox(THREE, [1.28, 1.52, 0.025], [7.32, 1.12, -3.66], baseMaterials.warmLight)); 963 for (let row = 0; row <= 5; row += 1) { 964 groups.base.add(makeBox(THREE, [1.38, 0.025, 0.035], [7.32, 0.31 + row * 0.324, -3.7], baseMaterials.mullion)); 965 } 966 967 // Current operator signage on the projecting silver box below the stack row. 968 const sign = new THREE.Group(); 969 sign.position.set(HALL.west - 0.06, 3.24, 1.23); 970 sign.rotation.y = Math.PI / 2; 971 sign.add(makeBox(THREE, [2.1, 0.88, 0.32], [0, 0, 0], baseMaterials.whitePanel)); 972 for (const [label, y, size, font] of [ 973 ["CALGARY", 0.13, [1.83, 0.36], "700 112px Arial, sans-serif"], 974 ["district heating", -0.17, [1.83, 0.24], "500 76px Arial, sans-serif"], 975 ]) { 976 sign.add(makePlane(THREE, size, [0, y, -0.17], createLabelMaterial(THREE, label, { 977 color: "#f6f8f4", font, opacity: 1, 978 }), [0, Math.PI, 0])); 979 } 980 groups.base.add(sign); 981 982 for (let index = 0; index < 3; index += 1) { 983 const boilerX = -0.3 + index * 2.45; 984 const boiler = makeBox(THREE, [1.9, 1.38, 1.34], [boilerX, 0.9, 1.45], baseMaterials.redPanel); 985 const boilerFace = makeCylinder(THREE, 0.45, 0.45, 0.12, [boilerX, 0.92, 0.72], baseMaterials.stainlessDark, 30); 986 boilerFace.rotation.x = Math.PI / 2; 987 const sightGlass = makeCylinder(THREE, 0.17, 0.17, 0.135, [boilerX, 0.92, 0.64], baseMaterials.warmLight, 24); 988 sightGlass.rotation.x = Math.PI / 2; 989 groups.base.add(boiler, boilerFace, sightGlass); 990 } 991 992 const polishedHeader = makePipe( 993 THREE, 994 [
995 new THREE.Vector3(-2.55, 2.46, 1.48), 996 new THREE.Vector3(0.3, 2.5, 1.48), 997 new THREE.Vector3(3.15, 2.46, 1.44), 998 new THREE.Vector3(6.65, 2.2, 0.78), 999 ], 1000 0.14, 1001 baseMaterials.stainless, 1002 84 1003 ); 1004 const districtReturnHeader = makePipe( 1005 THREE, 1006 [ 1007 new THREE.Vector3(-2.4, 2.08, 1.12), 1008 new THREE.Vector3(0.4, 2.12, 1.12), 1009 new THREE.Vector3(3.3, 2.06, 1.08), 1010 new THREE.Vector3(6.52, 1.72, 0.42), 1011 ], 1012 0.105, 1013 baseMaterials.stainlessDark, 1014 84 1015 ); 1016 groups.base.add(polishedHeader, districtReturnHeader); 1017 1018 for (let index = 0; index < 3; index += 1) { 1019 const riserX = -0.3 + index * 2.45; 1020 const riser = makePipe( 1021 THREE, 1022 [ 1023 new THREE.Vector3(riserX, 1.5, 1.42), 1024 new THREE.Vector3(riserX, 2.26, 1.42), 1025 new THREE.Vector3(riserX + 0.12, 2.46, 1.48), 1026 ], 1027 0.095, 1028 baseMaterials.stainless, 1029 30 1030 ); 1031 groups.base.add(riser); 1032 addPumpSkid( 1033 THREE, 1034 groups.base, 1035 [riserX - 0.25, 0.2, -0.6], 1036 { 1037 frame: baseMaterials.yellow, 1038 motor: baseMaterials.red, 1039 pump: baseMaterials.stainlessDark, 1040 pipe: baseMaterials.stainless, 1041 }, 1042 0.76 1043 ); 1044 addValveWheel(THREE, groups.base, [riserX + 0.35, 1.72, 1.28], 0.18, baseMaterials.valveBlue); 1045 } 1046 1047 for (const columnX of [-2.75, -0.05, 2.65, 5.35]) { 1048 groups.base.add(makeBox(THREE, [0.23, 3.0, 0.23], [columnX, 1.56, -1.7], baseMaterials.concrete)); 1049 } 1050 groups.base.add(makeBox(THREE, [9.8, 0.1, 0.14], [2.0, 0.26, -1.16], baseMaterials.yellow)); 1051 1052 for (let index = 0; index < 2; index += 1) { 1053 const tank = makeCylinder(THREE, 0.52, 0.52, 1.82, [-4.75, 1.0, 0.3 + index * 1.3], baseMaterials.stainless, 32); 1054 groups.base.add(tank); 1055 addValveWheel(THREE, groups.base, [-4.75, 1.28, -0.24 + index * 1.3], 0.16, baseMaterials.valveBlue); 1056 } 1057 1058 const substation = makeBox(THREE, [2.4, 1.5, 1.6], [-8.05, 0.85, 4.5], baseMaterials.amber); 1059 const backup = makeBox(THREE, [1.7, 1.1, 1.1], [-10.25, 0.62, 4.5], baseMaterials.steel); 1060 const generator = makeCylinder(THREE, 0.55, 0.55, 2.4, [-7.05, 0.72, 1.65], baseMaterials.stainlessDark, 24); 1061 generator.rotation.z = Math.PI / 2; 1062 groups.base.add(substation, backup, generator); 1063 const generatorBand = makeCylinder(THREE, 0.57, 0.57, 0.18, [-7.05, 0.72, 1.65], baseMaterials.amber, 24); 1064 generatorBand.rotation.z = Math.PI / 2; 1065 groups.base.add(generatorBand); 1066 1067 const stackTowerMaterials = { 1068 truss: baseMaterials.truss, 1069 stack: baseMaterials.stainless, 1070 stackRing: baseMaterials.stainlessDark, 1071 }; 1072 const roofStackPositions = [-2.05, -0.65, 0.75, 2.15].map((x) => [x, 3.58, 1.65]); 1073 roofStackPositions.forEach((position, index) => { 1074 addStackTower(THREE, groups.base, position, stackTowerMaterials, steamTexture, steamPlumes, { 1075 towerHeight: 2.9, 1076 stackHeight: 5.5, 1077 halfWidth: 0.48, 1078 seed: index, 1079 }); 1080 }); 1081 const roofServiceWalk = makeBox(THREE, [5.65, 0.1, 1.35], [0.05, 3.67, 1.65], baseMaterials.steel); 1082 groups.base.add(roofServiceWalk); 1083 for (const z of [0.96, 2.34]) { 1084 groups.base.add(makeBox(THREE, [5.65, 0.055, 0.055], [0.05, 3.96, z], baseMaterials.truss)); 1085 } 1086 1087 const cornerPlaza = makeBox(THREE, [3.0, 0.12, 4.3], [-7.35, 0.02, -2.2], sidewalkMaterial); 1088 groups.base.add(cornerPlaza); 1089 // Three flagpoles mark the street-corner entrance in the exterior photos. 1090 for (let index = 0; index < 3; index += 1) { 1091 const poleX = -8.15 + index * 0.82; 1092 groups.base.add(makeCylinder(THREE, 0.025, 0.035, 3.1, [poleX, 1.63, -3.62], baseMaterials.stainless, 12)); 1093 groups.base.add(makeBox(THREE, [0.47, 0.28, 0.012], [poleX + 0.25, 2.95, -3.62], index === 0 ? baseMaterials.redPanel : baseMaterials.whitePanel)); 1094 } 1095 1096 const curb = makeBox(THREE, [22.5, 0.12, 0.16], [0, 0.03, -4.78], baseMaterials.concrete); 1097 groups.base.add(curb); 1098 for (let index = 0; index < 8; index += 1) { 1099 groups.base.add(makeBox(THREE, [1.45, 0.015, 0.055], [-10.4 + index * 3.05, 0.035, -6.4], baseMaterials.whitePanel)); 1100 } 1101 groups.base.add(makeBox(THREE, [22.8, 0.015, 0.045], [0, 0.035, -7.28], baseMaterials.yellow)); 1102 1103 for (let index = 0; index < 6; index += 1) { 1104 const bollard = makeCylinder( 1105 THREE, 1106 0.07, 1107 0.08, 1108 0.72, 1109 [4.95 + index * 0.54, 0.42, -4.05], 1110 baseMaterials.stainlessDark, 1111 16 1112 ); 1113 groups.base.add(bollard); 1114 } 1115 1116 for (let index = 0; index < 9; index += 1) { 1117 groups.base.add(makeBox(THREE, [0.035, 0.65, 0.035], [-8.5 + index * 0.58, 0.4, -4.58], baseMaterials.stainless)); 1118 } 1119 groups.base.add(makeBox(THREE, [4.64, 0.035, 0.035], [-6.18, 0.72, -4.58], baseMaterials.stainless)); 1120 1121 const powerFeed = makeLine( 1122 THREE, 1123 [ 1124 new THREE.Vector3(-13, 0.1, 3.3), 1125 new THREE.Vector3(-9.2, 0.12, 3.3), 1126 new THREE.Vector3(-6.4, 0.15, 2.0), 1127 new THREE.Vector3(-3.4, 0.25, 1.35), 1128 ], 1129 new THREE.LineBasicMaterial({ color: COLORS.amber, transparent: true, op
1129acity: 0.85 }) 1130 ); 1131 groups.base.add(powerFeed); 1132 1133 const fiberFeed = makeLine( 1134 THREE, 1135 [ 1136 new THREE.Vector3(-13, 0.14, -3.2), 1137 new THREE.Vector3(-8.8, 0.14, -3.2), 1138 new THREE.Vector3(-4.8, 0.2, -2.2), 1139 new THREE.Vector3(-2.4, 0.28, -1.2), 1140 ], 1141 new THREE.LineBasicMaterial({ color: COLORS.cyan, transparent: true, opacity: 0.86 }) 1142 ); 1143 groups.base.add(fiberFeed); 1144 1145 const groundHall = makeBox(THREE, [8.5, 0.045, 4.45], [2.0, 0.25, -0.22], baseMaterials.greenGhost); 1146 const firstNodePad = makeBox(THREE, [4.35, 0.08, 3.55], [-0.78, 0.31, -0.3], baseMaterials.darkSteel); 1147 groups.phase1.add(groundHall, firstNodePad); 1148 1149 const phase1RackOptions = { 1150 spacing: 0.7, 1151 width: 0.46, 1152 height: 1.66, 1153 depth: 0.78, 1154 frontMaterial: baseMaterials.steel, 1155 slotMaterial: baseMaterials.darkSteel, 1156 statusMaterial: baseMaterials.green, 1157 }; 1158 addRackRow( 1159 THREE, 1160 groups.phase1, 1161 5, 1162 -2.45, 1163 -1.2, 1164 1.2, 1165 baseMaterials.steel, 1166 baseMaterials.stainlessDark, 1167 1, 1168 phase1RackOptions 1169 ); 1170 addRackRow( 1171 THREE, 1172 groups.phase1, 1173 5, 1174 -2.45, 1175 0.72, 1176 1.2, 1177 baseMaterials.steel, 1178 baseMaterials.stainlessDark, 1179 1, 1180 { ...phase1RackOptions, rotationY: Math.PI } 1181 ); 1182 1183 for (const frameX of [-2.82, 0.9]) { 1184 for (const frameZ of [-1.68, 1.2]) { 1185 groups.phase1.add(makeBox(THREE, [0.055, 2.12, 0.055], [frameX, 1.37, frameZ], baseMaterials.stainlessDark)); 1186 } 1187 } 1188 groups.phase1.add(makeBox(THREE, [3.82, 0.055, 0.055], [-0.96, 2.42, -1.68], baseMaterials.stainlessDark)); 1189 groups.phase1.add(makeBox(THREE, [3.82, 0.055, 0.055], [-0.96, 2.42, 1.2], baseMaterials.stainlessDark)); 1190 groups.phase1.add(makeBox(THREE, [3.78, 0.14, 0.18], [-0.96, 2.25, -0.28], baseMaterials.copper)); 1191 groups.phase1.add(makeBox(THREE, [3.72, 0.08, 0.38], [-0.96, 2.05, -0.28], baseMaterials.stainlessDark)); 1192 for (let rung = 0; rung < 11; rung += 1) { 1193 groups.phase1.add(makeBox(THREE, [0.03, 0.03, 0.4], [-2.62 + rung * 0.34, 2.1, -0.28], baseMaterials.stainless)); 1194 } 1195 1196 const phase1Label = makePlane( 1197 THREE, 1198 [2.9, 0.42], 1199 [-0.95, 2.68, -1.9], 1200 createLabelMaterial(THREE, "PHASE 1 • ONLINE", { 1201 width: 1024, 1202 height: 180, 1203 color: "rgba(112, 255, 178, 0.92)", 1204 font: "700 58px Arial, sans-serif", 1205 opacity: 0.95, 1206 }), 1207 [0, Math.PI, 0] 1208 ); 1209 groups.phase1.add(phase1Label); 1210 const phase1Light = new THREE.PointLight(0x58ffac, 5.8, 7.5, 1.9); 1211 phase1Light.position.set(-1.0, 1.45, -1.35); 1212 phase1Light.userData.baseIntensity = phase1Light.intensity; 1213 groups.phase1.add(phase1Light); 1214 1215 for (let index = 0; index < 3; index += 1) { 1216 const fanX = 2.55 + index * 1.18; 1217 const airHandler = makeBox(THREE, [0.94, 0.76, 0.42], [fanX, 1.12, -3.38], baseMaterials.stainlessDark); 1218 const inlet = makeBox(THREE, [0.74, 0.59, 0.025], [fanX, 1.12, -3.83], baseMaterials.fanRecess); 1219 groups.phase1.add(airHandler, inlet); 1220 addAxialFan( 1221 THREE, 1222 groups.phase1, 1223 [fanX, 1.12, -3.86], 1224 { 1225 recess: baseMaterials.fanRecess, 1226 shroud: baseMaterials.stainless, 1227 blade: baseMaterials.fanGreen, 1228 hub: baseMaterials.stainless, 1229 }, 1230 rotors, 1231 { 1232 axis: "z", 1233 radius: 0.27, 1234 blades: 5, 1235 speed: 2.3 + index * 0.08, 1236 } 1237 ); 1238 } 1239 1240 const basementFloor = makeBox(THREE, [11.35, 0.14, 5.7], [2.1, -1.52, -0.05], baseMaterials.concrete); 1241 const basementRear = makeBox(THREE, [11.35, 1.42, 0.16], [2.1, -0.78, 2.72], baseMaterials.concrete); 1242 const basementEast = makeBox(THREE, [0.16, 1.42, 5.7], [7.72, -0.78, -0.05], baseMaterials.concrete); 1243 const basementWest = makeBox(THREE, [0.16, 1.42, 5.7], [-3.52, -0.78, -0.05], baseMaterials.concreteGhost); 1244 const sectionLip = makeBox(THREE, [11.5, 0.09, 0.12], [2.1, 0.01, -2.93], baseMaterials.cyanBright); 1245 const aisleLight = makeBox(THREE, [9.4, 0.025, 0.32], [1.4, -1.42, -0.05], baseMaterials.ghost); 1246 const conduitTray = makeBox(THREE, [10.8, 0.14, 0.88], [0.5, -1.2, -4.08], baseMaterials.stainlessDark); 1247 const accessShaft = makeBox(THREE, [1.18, 1.64, 1.15], [6.72, -0.72, -2.15], baseMaterials.concreteGhost); 1248 const cutawayVault = makeBox(THREE, [9.6, 0.92, 1.25], [1.05, -0.48, -4.24], baseMaterials.concreteGhost); 1249 const vaultFloor = makeBox(THREE, [9.8, 0.08, 1.3], [1.05, -0.98, -4.24], baseMaterials.concrete); 1250 groups.phase2.add( 1251 basementFloor, 1252 basementRear, 1253 basementEast, 1254 basementWest, 1255 sectionLip, 1256 aisleLight, 1257 conduitTray, 1258 accessShaft, 1259 cutawayVault, 1260 vaultFloor 1261 ); 1262 1263 const sectionGalleryFloor = makeBox(THREE, [6.25, 0.1, 1.22], [0.45, -0.92, -4.22], baseMaterials.concrete); 1264 const sectionGalleryBack = makeBox(THREE, [6.25, 1.05, 0.08], [0.45, -0.4, -3.65], baseMaterials.concreteGhost); 1265 const sectionGalleryLeft = makeBox(THREE, [0.1, 1.05, 1.22], [-2.66, -0.4, -4.22], baseMaterials.concrete); 1266 const sectionGalleryRight = makeBox(THREE, [0.1, 1.05, 1.22], [3.56, -0.4, -4.22], baseMaterials.concrete); 1267 groups.phase2.add(sectionGalleryFloor, sectionGalleryBack, sectionGalleryLeft, sectionGalleryRight); 1268 addRackRow( 1269 THREE, 1270 groups.phase2, 1271 8, 1272 -2.25, 1273 -4.28, 1274 -0.38, 1275 baseMaterials.darkSteel, 1276 baseMaterials.stainlessDark, 1277 2, 1278 { 1279 width: 0.38, 1280 height: 0.96, 1281 depth: 0.48, 1282 spacing: 0.7, 1283 liquid: true, 1284 frontMaterial: baseMaterials.steel, 1285 slotMaterial: baseMaterials.darkSteel, 1286 statusMaterial: baseMaterials.cyanBright, 1287 coolingMaterial: baseMaterials.cyanBright, 1288 } 1289 ); 1290 groups.phase2.add(makeBox(THREE, [5.65, 0.07, 0.07], [0.45, 0.12, -4.0], baseMaterials.cyanBright)); 1291 groups.phase2.add(makeBox(THREE, [5.65, 0.06, 0.06], [0.45, -0.02, -4.0], baseMaterials.heat)); 1292 const phase2Light = new THREE.PointLight(0x7feaff, 5.2, 8.5, 1.8); 1293 phase2Light.position.set(0.5, 0.25, -4.55); 1294 phase2Light.userData.baseIntensity = phase2Light.intensity; 1295 groups.phase2.add(phase2Light); 1296 1297 for (let stepIndex = 0; stepIndex < 7; stepIndex += 1) { 1298 groups.phase2.add( 1299 makeBox( 1300 THREE, 1301 [0.86, 0.08, 0.32], 1302 [6.7, -0.08 - stepIndex * 0.2, -2.55 + stepIndex * 0.22], 1303 baseMaterials.stainlessDark 1304 ) 1305 ); 1306 } 1307 1308 const phase2RackOptions = { 1309 width: 0.47, 1310 height: 1.34, 1311 depth: 0.82, 1312 spacing: 0.7, 1313 liquid: true, 1314 frontMaterial: baseMaterials.steel, 1315 slotMaterial: baseMaterials.darkSteel, 1316 statusMaterial: baseMaterials.cyanBright, 1317 coolingMaterial: baseMaterials.cyanBright, 1318 }; 1319 addRackRow( 1320 THREE, 1321 groups.phase2, 1322 7, 1323 -1.2, 1324 -1.35, 1325 -0.8, 1326 baseMaterials.darkSteel, 1327 baseMaterials.stainlessDark, 1328 2, 1329 phase2RackOptions 1330 ); 1331 addRackRow( 1332 THREE, 1333 groups.phase2, 1334 7, 1335 -1.2, 1336 1.18, 1337 -0.8, 1338 baseMaterials.darkSteel, 1339 baseMaterials.stainlessDark, 1340 2, 1341 { ...phase2RackOptions, rotationY: Math.PI } 1342 ); 1343 1344 const exchangerSkid = new THREE.Group(); 1345 exchangerSkid.position.set(6.05, -1.42, 1.58); 1346 exchangerSkid.add(makeBox(THREE, [1.8, 0.12, 1.15], [0, 0.06, 0], baseMaterials.stainlessDark)); 1347 exchangerSkid.add(makeBox(THREE, [0.11, 1.08, 0.94], [-0.72, 0.64, 0], baseMaterials.stainlessDark)); 1348 exchangerSkid.add(makeBox(THREE, [0.11, 1.08, 0.94], [0.72, 0.64, 0], baseMaterials.stainlessDark)); 1349 for (let plateIndex = 0; plateIndex < 13; plateIndex += 1) { 1350 exchangerSkid.add( 1351 makeBox( 1352 THREE, 1353 [0.045, 0.9, 0.78], 1354 [-0.54 + plateIndex * 0.09, 0.64, 0], 1355 plateIndex % 2 === 0 ? baseMaterials.stainless : baseMaterials.copper 1356 ) 1357 ); 1358 } 1359 groups.phase2.add(exchangerSkid); 1360 addPumpSkid( 1361 THREE, 1362 groups.phase2, 1363 [5.5, -1.36, 0.28], 1364 { 1365 frame: baseMaterials.stainlessDark, 1366 motor: baseMaterials.red, 1367 pump: baseMaterials.stainless, 1368 pipe: baseMaterials.cyanBright, 1369 }, 1370 0.72 1371 ); 1372 addValveWheel(THREE, groups.phase2, [6.95, -0.65, 1.08], 0.18, baseMaterials.valveBlue); 1373 addValveWheel(THREE, groups.phase2, [6.95, -1.0, 2.06], 0.18, baseMaterials.valveBlue); 1374 1375 for (let index = 0; index < 5; index += 1) { 1376 const offset = -2.25 + index * 1.08; 1377 groups.phase2.add( 1378 makePipe( 1379 THREE, 1380 [
1381 new THREE.Vector3(-3.6, -0.18, offset), 1382 new THREE.Vector3(1.4, -0.16, offset), 1383 new THREE.Vector3(4.8, 0.1, offset * 0.68), 1384 new THREE.Vector3(6.55, -0.25, 1.55), 1385 ], 1386 0.05, 1387 index % 2 === 0 ? baseMaterials.cyanBright : baseMaterials.blue, 1388 76 1389 ) 1390 ); 1391 } 1392 1393 const manifold = makeCylinder(THREE, 0.18, 0.18, 5.7, [7.32, 0.36, -0.15], baseMaterials.cyanBright, 24); 1394 manifold.rotation.x = Math.PI / 2; 1395 const hotManifold = makeCylinder(THREE, 0.13, 0.13, 5.2, [7.62, 0.06, -0.15], baseMaterials.heat, 24); 1396 hotManifold.rotation.x = Math.PI / 2; 1397 groups.phase2.add(manifold, hotManifold); 1398 1399 const phase2HeatRecovery = makePipe( 1400 THREE, 1401 [ 1402 new THREE.Vector3(6.45, -0.32, 2.45), 1403 new THREE.Vector3(4.9, -0.64, 3.55), 1404 new THREE.Vector3(1.4, -1.02, 4.3), 1405 new THREE.Vector3(-2.4, -1.12, 4.05), 1406 new THREE.Vector3(-6.0, -1.06, 3.55), 1407 ], 1408 0.075, 1409 baseMaterials.heat, 1410 88 1411 ); 1412 groups.phase2.add(phase2HeatRecovery); 1413 1414 const visibleColdHeader = makePipe( 1415 THREE, 1416 [ 1417 new THREE.Vector3(-3.8, -0.08, -4.78), 1418 new THREE.Vector3(-0.4, -0.06, -4.78), 1419 new THREE.Vector3(3.7, -0.05, -4.66), 1420 new THREE.Vector3(6.55, -0.28, 1.55), 1421 ], 1422 0.065, 1423 baseMaterials.cyanBright, 1424 92 1425 ); 1426 const visibleHotHeader = makePipe( 1427 THREE, 1428 [ 1429 new THREE.Vector3(-3.7, -0.28, -4.52), 1430 new THREE.Vector3(-0.2, -0.25, -4.52), 1431 new THREE.Vector3(3.9, -0.24, -4.42), 1432 new THREE.Vector3(6.55, -0.38, 2.3), 1433 ], 1434 0.06, 1435 baseMaterials.heat, 1436 92 1437 ); 1438 groups.phase2.add(visibleColdHeader, visibleHotHeader); 1439 1440 for (let index = 0; index < 4; index += 1) { 1441 groups.phase2.add( 1442 makeBox(THREE, [0.18, 0.12, 0.75], [-1.8 + index * 1.25, -1.28, -4.08], baseMaterials.cyanBright) 1443 ); 1444 } 1445 1446 const coolerMaterials = { 1447 frame: baseMaterials.stainlessDark, 1448 coil: baseMaterials.fin, 1449 fin: baseMaterials.stainless, 1450 recess: baseMaterials.fanRecess, 1451 blade: baseMaterials.stainless, 1452 hub: baseMaterials.cyanBright, 1453 }; 1454 const coolerPositions = [-1.25, 0.95, 3.15, 5.35]; 1455 groups.phase3.add(makeBox(THREE, [9.25, 0.12, 1.62], [2.05, 3.73, -1.48], baseMaterials.steel)); 1456 coolerPositions.forEach((x, index) => { 1457 addDryCooler(THREE, groups.phase3, [x, 3.86, -1.48], coolerMaterials, rotors, 3); 1458 for (const supportX of [x - 0.83, x + 0.83]) { 1459 groups.phase3.add(makeBox(THREE, [0.065, 0.52, 0.065], [supportX, 3.55, -1.88], baseMaterials.stainlessDark)); 1460 groups.phase3.add(makeBox(THREE, [0.065, 0.52, 0.065], [supportX, 3.55, -1.08], baseMaterials.stainlessDark)); 1461 } 1462 if (index < coolerPositions.length - 1) { 1463 groups.phase3.add(makeBox(THREE, [0.78, 0.06, 0.08], [x + 1.49, 4.38, -1.48], baseMaterials.stainlessDark)); 1464 } 1465 }); 1466 1467 for (let railIndex = 0; railIndex < 11; railIndex += 1) { 1468 groups.phase3.add( 1469 makeBox(THREE, [0.04, 0.62, 0.04], [-2.55 + railIndex * 0.91, 4.05, -2.38], baseMaterials.stainless) 1470 ); 1471 } 1472 groups.phase3.add(makeBox(THREE, [9.1, 0.04, 0.04], [2.0, 4.34, -2.38], baseMaterials.stainless)); 1473 groups.phase3.add(makeBox(THREE, [9.1, 0.035, 0.035], [2.0, 4.02, -2.38], baseMaterials.stainless)); 1474 1475 const expandedHeatRecovery = makePipe( 1476 THREE, 1477 [ 1478 new THREE.Vector3(-3.2, 3.78, 2.92), 1479 new THREE.Vector3(1.0, 3.86, 3.45), 1480 new THREE.Vector3(5.7, 3.35, 2.85), 1481 new THREE.Vector3(8.35, 1.38, 2.35), 1482 new THREE.Vector3(7.0, -0.42, 2.15), 1483 new THREE.Vector3(3.1, -1.12, 3.85), 1484 new THREE.Vector3(-6.6, -1.08, 3.72), 1485 ], 1486 0.09, 1487 baseMaterials.heat, 1488 116 1489 ); 1490 const rooftopColdLoop = makePipe( 1491 THREE, 1492 [ 1493 new THREE.Vector3(-3.2, 3.76, -2.75), 1494 new THREE.Vector3(0.8, 3.8, -3.18), 1495 new THREE.Vector3(5.8, 3.38, -2.68), 1496 new THREE.Vector3(8.1, 1.22, -1.1), 1497 ], 1498 0.08, 1499 baseMaterials.cyanBright, 1500 96 1501 ); 1502 groups.phase3.add(expandedHeatRecovery, rooftopColdLoop); 1503 1504 const phase3RackOptions = { 1505 width: 0.47, 1506 height: 1.72, 1507 depth: 0.84, 1508 spacing: 0.7, 1509 liquid: true, 1510 frontMaterial: baseMaterials.steel, 1511 slotMaterial: baseMaterials.darkSteel, 1512 statusMaterial: baseMaterials.cyanBright, 1513 coolingMaterial: baseMaterials.blue, 1514 }; 1515 addRackRow( 1516 THREE, 1517 groups.phase3, 1518 6, 1519 3.25, 1520 -1.2, 1521 1.2, 1522 baseMaterials.darkSteel, 1523 baseMaterials.stainlessDark, 1524 3, 1525 phase3RackOptions 1526 ); 1527 addRackRow( 1528 THREE, 1529 groups.phase3, 1530 6, 1531 3.25, 1532 0.72, 1533 1.2, 1534 baseMaterials.darkSteel, 1535 baseMaterials.stainlessDark, 1536 3, 1537 { ...phase3RackOptions, rotationY: Math.PI } 1538 ); 1539 groups.phase3.add(makeBox(THREE, [4.25, 0.16, 0.18], [5.0, 2.3, -0.25], baseMaterials.copper)); 1540 groups.phase3.add(makeBox(THREE, [4.25, 0.08, 0.38], [5.0, 2.08, -0.25], baseMaterials.stainlessDark)); 1541 1542 const transitionRing = new THREE.Mesh( 1543 new THREE.RingGeometry(1.05, 1.16, 96), 1544 createBasicMaterial(THREE, COLORS.cyanBright, { 1545 opacity: 0, 1546 side: THREE.DoubleSide, 1547 blending: THREE.AdditiveBlending, 1548 }) 1549 ); 1550 transitionRing.rotation.x = -Math.PI / 2; 1551 transitionRing.position.set(1.6, 0.05, -0.35); 1552 transitionRing.visible = false;
1553 scene.add(transitionRing); 1554 1555 const scanPlane = makeBox( 1556 THREE, 1557 [0.08, 6.4, 13.4], 1558 [-7.2, 2.0, -0.2], 1559 createBasicMaterial(THREE, COLORS.cyanBright, { 1560 opacity: 0, 1561 side: THREE.DoubleSide, 1562 blending: THREE.AdditiveBlending, 1563 }) 1564 ); 1565 scanPlane.visible = false; 1566 scene.add(scanPlane); 1567 1568 const beaconMaterial = createBasicMaterial(THREE, COLORS.cyanBright, { 1569 opacity: 0, 1570 blending: THREE.AdditiveBlending, 1571 }); 1572 const phaseBeacons = new THREE.Group(); 1573 const beaconMeshes = []; 1574 for (let index = 0; index < 8; index += 1) { 1575 const beacon = makeSphere(THREE, 0.11, [0, 0, 0], beaconMaterial, 18); 1576 const halo = new THREE.Mesh( 1577 new THREE.RingGeometry(0.18, 0.22, 32), 1578 beaconMaterial 1579 ); 1580 halo.rotation.x = -Math.PI / 2; 1581 beacon.add(halo); 1582 beaconMeshes.push(beacon); 1583 phaseBeacons.add(beacon); 1584 } 1585 phaseBeacons.visible = false; 1586 scene.add(phaseBeacons); 1587 1588 const phaseBeaconPositions = [ 1589 [ 1590 [-7.9, 1.7, 2.2], 1591 [-5.7, 1.95, -1.7], 1592 [-2.6, 4.95, -1.4], 1593 [1.1, 5.2, 0.65], 1594 [2.2, -0.78, -4.82], 1595 [4.7, 1.32, -3.9], 1596 ], 1597 [ 1598 [-0.45, 1.95, -1.55], 1599 [1.0, 1.95, -1.55], 1600 [2.45, 1.95, -1.55], 1601 [-0.45, 1.95, 0.95], 1602 [1.0, 1.95, 0.95], 1603 [5.95, 1.34, -3.35], 1604 ], 1605 [ 1606 [-2.2, -0.05, -4.32], 1607 [-0.6, -0.05, -4.32], 1608 [1.1, -0.05, -4.32], 1609 [3.1, -0.05, -4.28], 1610 [6.55, -0.02, 1.95], 1611 [7.62, 0.36, -0.15], 1612 [-5.2, -0.65, 3.55], 1613 ], 1614 [ 1615 [-1.95, 4.52, -1.9], 1616 [0.2, 4.52, -1.9], 1617 [2.35, 4.52, -1.9], 1618 [4.5, 4.52, -1.9], 1619 [6.65, 4.52, -1.9], 1620 [1.0, 4.4, 3.35], 1621 [6.8, 1.25, 2.35], 1622 ], 1623 ]; 1624 1625 const groupEntries = [ 1626 { group: groups.phase1, threshold: 1 }, 1627 { group: groups.phase2, threshold: 2 }, 1628 { group: groups.phase3, threshold: 3 }, 1629 ]; 1630 groupEntries.forEach((entry) => { 1631 entry.group.userData.progress = 0; 1632 entry.group.visible = false; 1633 setObjectOpacity(entry.group, 0); 1634 const materialSet = new Set(); 1635 const phaseLights = []; 1636 entry.group.traverse((child) => { 1637 if (child.isLight) { 1638 phaseLights.push(child); 1639 } 1640 if (!child.material) { 1641 return; 1642 } 1643 const materials = Array.isArray(child.material) ? child.material : [child.material]; 1644 materials.forEach((material) => materialSet.add(material)); 1645 }); 1646 entry.materials = [...materialSet]; 1647 entry.lights = phaseLights; 1648 }); 1649 1650 let activePhase = 0; 1651 const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; 1652 let rafId = 0; 1653 let disposed = false; 1654 let widgetInView = true; 1655 let lastFrameTime = performance.now(); 1656 const phaseFx = { 1657 startTime: -Infinity, 1658 duration: 1050, 1659 from: 0, 1660 to: 0, 1661 }; 1662 1663 function setMaterialColor(material, color) { 1664 if (material.color) { 1665 material.color.set(color); 1666 } 1667 } 1668 1669 function triggerPhaseFx(nextPhase, previousPhase) { 1670 if (reducedMotion || nextPhase === previousPhase) { 1671 return; 1672 } 1673
1674 phaseFx.startTime = performance.now(); 1675 phaseFx.from = previousPhase; 1676 phaseFx.to = nextPhase; 1677 1678 const phaseColor = PHASES[nextPhase].accent; 1679 setMaterialColor(transitionRing.material, phaseColor); 1680 setMaterialColor(scanPlane.material, phaseColor); 1681 setMaterialColor(beaconMaterial, phaseColor); 1682 1683 const positions = phaseBeaconPositions[nextPhase]; 1684 beaconMeshes.forEach((beacon, index) => { 1685 const position = positions[index % positions.length]; 1686 beacon.position.set(position[0], position[1], position[2]); 1687 beacon.userData.baseY = position[1]; 1688 beacon.scale.setScalar(0.8); 1689 beacon.visible = index < positions.length; 1690 }); 1691 1692 transitionRing.visible = true; 1693 scanPlane.visible = true; 1694 phaseBeacons.visible = true; 1695 } 1696 1697 function updatePhaseFx(time) { 1698 const progress = clamp((time - phaseFx.startTime) / phaseFx.duration, 0, 1); 1699 if (progress >= 1) { 1700 transitionRing.visible = false; 1701 scanPlane.visible = false; 1702 phaseBeacons.visible = false; 1703 transitionRing.material.opacity = 0; 1704 scanPlane.material.opacity = 0; 1705 beaconMaterial.opacity = 0; 1706 return; 1707 } 1708 1709 const eased = easeOutCubic(progress); 1710 const intensity = Math.sin(progress * Math.PI); 1711 transitionRing.scale.setScalar(0.7 + eased * 5.4); 1712 transitionRing.position.y = 0.05 + intensity * 0.05; 1713 transitionRing.material.opacity = 0.2 * (1 - progress); 1714 1715 scanPlane.position.x = -7.4 + eased * 16.2; 1716 scanPlane.material.opacity = 0.075 * intensity; 1717 1718 beaconMaterial.opacity = 0.46 * intensity; 1719 beaconMeshes.forEach((beacon, index) => { 1720 const offset = index * 0.16; 1721 const localPulse = Math.max(0, Math.sin(progress * Math.PI + offset)); 1722 beacon.scale.setScalar(0.75 + localPulse * 2.1); 1723 beacon.position.y = (beacon.userData.baseY ?? beacon.position.y) + Math.sin(time * 0.006 + index) * 0.08; 1724 }); 1725 } 1726 1727 function resize() { 1728 const rect = canvas.getBoundingClientRect(); 1729 const width = Math.max(1, Math.floor(rect.width)); 1730 const height = Math.max(280, Math.floor(rect.height)); 1731 const aspect = width / height; 1732 renderer.setSize(width, height, false); 1733 camera.aspect = aspect; 1734 1735 camera.updateProjectionMatrix(); 1736 } 1737 1738 function updateCamera(time, delta) { 1739 const damping = reducedMotion ? 1 : 1 - Math.exp(-6.8 * delta); 1740 cameraState.yaw += (cameraState.targetYaw - cameraState.yaw) * damping; 1741 cameraState.pitch += (cameraState.targetPitch - cameraState.pitch) * damping; 1742 cameraState.distance += (cameraState.targetDistance - cameraState.distance) * damping; 1743 cameraLookAt.lerp(cameraTargetLookAt, damping); 1744 1745 const pitch = cameraState.pitch; 1746 const idleYaw = !cameraState.dragging && !reducedMotion ? Math.sin(time * 0.00018) * 0.018 : 0; 1747 const yaw = cameraState.yaw + idleYaw; 1748 const distance = cameraState.distance; 1749 const x = Math.sin(yaw) * Math.cos(pitch) * distance; 1750 const y = Math.sin(pitch) * distance; 1751 const z = Math.cos(yaw) * Math.cos(pitch) * distance; 1752 camera.position.set(cameraLookAt.x + x, cameraLookAt.y + y, cameraLookAt.z + z); 1753 camera.lookAt(cameraLookAt); 1754 } 1755 1756 function animate(time = 0) { 1757 rafId = 0; 1758 if (disposed || !widgetInView || document.hidden) { 1759 return; 1760 } 1761 1762 const delta = clamp((time - lastFrameTime) / 1000, 0, 0.05); 1763 lastFrameTime = time; 1764 updateCamera(time, delta); 1765 1766 const motionTime = reducedMotion ? 2400 : time; 1767 powerFeed.material.opacity = reducedMotion ? 0.68 : 0.6 + Math.sin(time * 0.0024) * 0.12; 1768 fiberFeed.material.opacity = reducedMotion ? 0.7 : 0.62 + Math.sin(time * 0.0024 + 1.7) * 0.12;
1769 steamPlumes.forEach((plume) => { 1770 const steam = plume.userData.steam; 1771 const cycle = (motionTime * 0.000115 + steam.offset) % 1; 1772 const drift = easeOutCubic(cycle); 1773 const fade = Math.pow(Math.sin(cycle * Math.PI), 0.82); 1774 const swirlX = Math.sin(motionTime * 0.00095 + steam.offset * Math.PI * 2) * (0.055 + drift * 0.16); 1775 const swirlZ = Math.cos(motionTime * 0.00072 + steam.offset * Math.PI * 3) * (0.035 + drift * 0.11); 1776 plume.position.set( 1777 steam.baseX + steam.driftX * drift + swirlX, 1778 steam.baseY + steam.lift * drift, 1779 steam.baseZ + steam.driftZ * drift + swirlZ 1780 ); 1781 const spread = steam.size * (0.82 + drift * 2.65); 1782 plume.scale.set(spread * (1 + drift * 0.28), spread * (0.82 + drift * 0.16), 1); 1783 plume.material.opacity = steam.opacity * fade * (1 - cycle * 0.34); 1784 plume.material.rotation = steam.spin * motionTime * 0.001 + steam.offset * Math.PI; 1785 }); 1786 animatedFlowMaterials.forEach((material, index) => { 1787 material.opacity = (material.userData.baseOpacity ?? 1) * ( 1788 reducedMotion ? 0.82 : 0.76 + Math.sin(time * 0.0024 + index * 0.9) * 0.12 1789 ); 1790 }); 1791 1792 groupEntries.forEach((entry) => { 1793 const target = activePhase >= entry.threshold ? 1 : 0; 1794 const phaseDamping = 1 - Math.exp(-7.4 * delta); 1795 if (reducedMotion) { 1796 entry.group.userData.progress = target; 1797 } else { 1798 entry.group.userData.progress += (target - entry.group.userData.progress) * phaseDamping; 1799 } 1800 if (Math.abs(target - entry.group.userData.progress) < 0.001) { 1801 entry.group.userData.progress = target; 1802 } 1803 const progress = easeOutCubic(entry.group.userData.progress); 1804 entry.group.visible = progress > 0.015; 1805 entry.group.scale.setScalar(reducedMotion ? 1 : 0.96 + progress * 0.04); 1806 entry.group.position.y = reducedMotion ? 0 : (1 - progress) * (entry.threshold === 3 ? 1.1 : -0.5); 1807 entry.materials.forEach((material) => { 1808 material.opacity = (material.userData.baseOpacity ?? 1) * progress; 1809 material.transparent = material.opacity < 1; 1810 }); 1811 entry.lights.forEach((light) => { 1812 light.intensity = (light.userData.baseIntensity ?? 1) * progress; 1813 }); 1814 }); 1815 1816 updatePhaseFx(time); 1817 1818 rotors.forEach((rotor) => { 1819 if (reducedMotion) { 1820 return; 1821 } 1822 const axis = rotor.userData.spinAxis ?? "z"; 1823 rotor.rotation[axis] += (rotor.userData.spinSpeed ?? 1.8) * delta; 1824 }); 1825 1826 renderer.render(scene, camera); 1827 rafId = requestAnimationFrame(animate); 1828 } 1829 1830 function syncAnimation() { 1831 const shouldAnimate = !disposed && widgetInView && !document.hidden; 1832 if (shouldAnimate && !rafId) { 1833 lastFrameTime = performance.now(); 1834 rafId = requestAnimationFrame(animate); 1835 } else if (!shouldAnimate && rafId) { 1836 cancelAnimationFrame(rafId); 1837 rafId = 0; 1838 } 1839 } 1840 1841 function setPhase(phase) { 1842 const previousPhase = activePhase; 1843 activePhase = clamp(phase, 0, PHASES.length - 1); 1844 const phaseData = PHASES[activePhase]; 1845 widget.style.setProperty("--phase-accent", phaseData.accent); 1846 triggerPhaseFx(activePhase, previousPhase); 1847 1848 const cameraTargets = [ 1849 { yaw: 3.8, pitch: 0.29, distance: 34.5, lookAt: [0.6, 3.4, 0.1] }, 1850 { yaw: 3.48, pitch: 0.23, distance: 30.5, lookAt: [0.6, 2.65, -0.6] }, 1851 { yaw: 3.55, pitch: 0.26, distance: 31.5, lookAt: [0.7, 1.9, -1.0] }, 1852 { yaw: 3.68, pitch: 0.36, distance: 33.5, lookAt: [1.1, 3.0, -0.2] }, 1853 ]; 1854 const target = cameraTargets[activePhase]; 1855 cameraState.targetYaw = target.yaw; 1856 cameraState.targetPitch = target.pitch; 1857 cameraState.targetDistance = target.distance; 1858 cameraTargetLookAt.set(target.lookAt[0], target.lookAt[1], target.lookAt[2]); 1859 baseMaterials.glass.opacity = activePhase === 0 ? 0.34 : 0.12; 1860 warmInteriorLight.intensity = activePhase === 0 ? 9.5 : 12.5; 1861 const sectionMode = activePhase === 2; 1862 [ 1863 [ground, 0.34],
1864 [street, 0.18], 1865 [sidewalk, 0.2], 1866 [cornerPlaza, 0.2], 1867 ].forEach(([surface, sectionOpacity]) => { 1868 surface.material.opacity = sectionMode ? sectionOpacity : 1; 1869 surface.material.transparent = sectionMode; 1870 surface.material.depthWrite = !sectionMode; 1871 surface.material.needsUpdate = true; 1872 }); 1873 } 1874 1875 function pointerDown(event) { 1876 cameraState.dragging = true; 1877 cameraState.lastX = event.clientX; 1878 cameraState.lastY = event.clientY; 1879 canvas.setPointerCapture?.(event.pointerId); 1880 } 1881 1882 function pointerMove(event) { 1883 if (!cameraState.dragging) { 1884 return; 1885 } 1886 const dx = event.clientX - cameraState.lastX; 1887 const dy = event.clientY - cameraState.lastY; 1888 cameraState.lastX = event.clientX; 1889 cameraState.lastY = event.clientY; 1890 cameraState.targetYaw += dx * 0.006; 1891 cameraState.targetPitch = clamp(cameraState.targetPitch + dy * 0.004, 0.12, 0.95); 1892 } 1893 1894 function pointerUp(event) { 1895 cameraState.dragging = false; 1896 canvas.releasePointerCapture?.(event.pointerId); 1897 } 1898 1899 function wheel(event) { 1900 event.preventDefault(); 1901 cameraState.targetDistance = clamp(cameraState.targetDistance + event.deltaY * 0.018, 20, 42); 1902 } 1903 1904 const resizeObserver = "ResizeObserver" in window ? new ResizeObserver(resize) : null; 1905 const intersectionObserver = "IntersectionObserver" in window 1906 ? new IntersectionObserver( 1907 ([entry]) => { 1908 widgetInView = entry.isIntersecting; 1909 syncAnimation(); 1910 }, 1911 { rootMargin: "160px 0px" } 1912 ) 1913 : null; 1914 resizeObserver?.observe(canvas.parentElement); 1915 intersectionObserver?.observe(widget); 1916 window.addEventListener("resize", resize); 1917 document.addEventListener("visibilitychange", syncAnimation); 1918 canvas.addEventListener("pointerdown", pointerDown); 1919 canvas.addEventListener("pointermove", pointerMove); 1920 canvas.addEventListener("pointerup", pointerUp); 1921 canvas.addEventListener("pointercancel", pointerUp); 1922 canvas.addEventListener("wheel", wheel, { passive: false }); 1923 1924 resize(); 1925 setPhase(0); 1926 syncAnimation(); 1927 1928 return { 1929 setPhase, 1930 dispose() { 1931 disposed = true; 1932 cancelAnimationFrame(rafId); 1933 resizeObserver?.disconnect(); 1934 intersectionObserver?.disconnect(); 1935 window.removeEventListener("resize", resize); 1936 document.removeEventListener("visibilitychange", syncAnimation); 1937 canvas.removeEventListener("pointerdown", pointerDown); 1938 canvas.removeEventListener("pointermove", pointerMove); 1939 canvas.removeEventListener("pointerup", pointerUp); 1940 canvas.removeEventListener("pointercancel", pointerUp); 1941 canvas.removeEventListener("wheel", wheel); 1942 const disposedGeometries = new Set(); 1943 const disposedMaterials = new Set(); 1944 const disposedTextures = new Set(); 1945 scene.traverse((object) => { 1946 if (object.geometry && !disposedGeometries.has(object.geometry)) { 1947 object.geometry.dispose(); 1948 disposedGeometries.add(object.geometry); 1949 } 1950 if (object.material) { 1951 const materials = Array.isArray(object.material) ? object.material : [object.material]; 1952 materials.forEach((material) => { 1953 if (disposedMaterials.has(material)) { 1954 return; 1955 } 1956 for (const texture of [material.map, material.userData.texture]) { 1957 if (texture && !disposedTextures.has(texture)) { 1958 texture.dispose(); 1959 disposedTextures.add(texture); 1960 } 1961 } 1962 material.dispose(); 1963 disposedMaterials.add(material); 1964 }); 1965 } 1966 }); 1967 renderer.dispose(); 1968 }, 1969 }; 1970} 1971 1972function updateReadout(widget, phaseIndex) { 1973 const phase = PHASES[phaseIndex]; 1974 widget.style.setProperty("--phase-accent", phase.accent); 1975 widget.querySelector("[data-plant-stage]").textContent = phase.stage; 1976 widget.querySelector("[data-plant-title]").textContent = phase.title; 1977 widget.querySelector("[data-plant-description]").textContent = phase.description; 1978 widget.querySelector("[data-plant-stat-power]").textContent = phase.power; 1979 widget.querySelector("[data-plant-stat-stack]").textContent = phase.stack; 1980 widget.querySelector("[data-plant-stat-cooling-label]").textContent = 1981 phase.coolingLabel ?? "Cooling"; 1982 widget.querySelector("[data-plant-stat-cooling]").textContent = phase.cooling; 1983 1984 widget.querySelectorAll("[data-plant-phase]").forEach((button) => { 1985 const active = Number(button.dataset.plantPhase) === phaseIndex; 1986 button.classList.toggle("is-active", active); 1987 button.setAttribute("aria-pressed", String(active)); 1988 }); 1989} 1990 1991export async function initPlantWidget() { 1992 const widget = document.querySelector("[data-plant-widget]"); 1993 if (!widget) { 1994 return; 1995 } 1996 1997 const canvas = widget.querySelector("[data-plant-canvas]"); 1998 if (!canvas) { 1999 return; 2000 } 2001 2002 let rendererApi; 2003 try { 2004 const THREE = await import(THREE_MODULE_URL); 2005 rendererApi = createPlantScene(THREE, canvas, widget); 2006 } catch (error) { 2007 widget.classList.add("plant-widget-fallback"); 2008 widget.querySelector("[data-plant-viewport]").innerHTML = 2009 '<p class="plant-fallback">3D phase renderer unavailable.</p>'; 2010 return; 2011 } 2012 2013 updateReadout(widget, 0); 2014 2015 widget.querySelectorAll("[data-plant-phase]").forEach((button) => { 2016 button.addEventListener("click", () => { 2017 const phaseIndex = Number(button.dataset.plantPhase); 2018 updateReadout(widget, phaseIndex); 2019 rendererApi.setPhase(phaseIndex); 2020 }); 2021 }); 2022}
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.