PageSourceSearch

https://gosimply.ai/src/plantWidget.js

js gosimply.ai collected 2026-10-05 08:53:01 UTC 73,368 bytes, 2,022 lines download raw bytes

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.