diff --git a/components/home/HeroIllustration.tsx b/components/home/HeroIllustration.tsx index 1b065bcb..e4b687ee 100644 --- a/components/home/HeroIllustration.tsx +++ b/components/home/HeroIllustration.tsx @@ -1,410 +1,8 @@ 'use client'; -import React, { useEffect, useState } from 'react'; - -// Isometric grid configuration - true 2:1 isometric projection -const CELL_WIDTH = 120; -const CELL_HEIGHT = 60; // Half of width for 2:1 isometric - -// Convert grid coordinates to isometric screen coordinates -function gridToScreen(col: number, row: number): { x: number; y: number } { - return { - x: (col - row) * (CELL_WIDTH / 2), - y: (col + row) * (CELL_HEIGHT / 2), - }; -} - -// Grid layout (10 columns x 8 rows) -const GRID = { - cols: 10, - rows: 8, -}; - -// Infrastructure positions -const INFRASTRUCTURE = { - solar: [ - { col: 0, row: 5 }, - { col: 1, row: 5 }, - { col: 0, row: 6 }, - { col: 1, row: 6 }, - { col: 2, row: 7 }, - { col: 3, row: 7 }, - { col: 2, row: 8 }, - { col: 3, row: 8 }, - ], - wind: [ - { col: 0, row: 1 }, - { col: 1, row: 2 }, - { col: 2, row: 1 }, - { col: 3, row: 0 }, - { col: 4, row: 1 }, - { col: 5, row: 0 }, - ], - substations: [ - { col: 3, row: 3, type: 'collection' }, - { col: 6, row: 4, type: 'distribution' }, - { col: 5, row: 7, type: 'distribution' }, - ], - towers: [ - { col: 4, row: 3 }, - { col: 5, row: 4 }, - { col: 4, row: 5 }, - { col: 5, row: 6 }, - ], - city: [ - { col: 8, row: 3, type: 'tall' }, - { col: 9, row: 4, type: 'medium' }, - { col: 8, row: 5, type: 'small' }, - { col: 9, row: 5, type: 'medium' }, - ], - city2: [ - { col: 6, row: 8, type: 'medium' }, - { col: 7, row: 7, type: 'tall' }, - { col: 7, row: 8, type: 'small' }, - ], - trees: [ - { col: 0, row: 3 }, - { col: 2, row: 6 }, - { col: 3, row: 1 }, - { col: 6, row: 2 }, - { col: 6, row: 6 }, - ], -}; - -const POWER_LINES = [ - { from: { col: 0, row: 1 }, to: { col: 1, row: 1 } }, - { from: { col: 1, row: 2 }, to: { col: 1, row: 1 } }, - { from: { col: 2, row: 1 }, to: { col: 1, row: 1 } }, - { from: { col: 1, row: 1 }, to: { col: 1, row: 3 } }, - { from: { col: 1, row: 3 }, to: { col: 3, row: 3 } }, - { from: { col: 3, row: 0 }, to: { col: 4, row: 0 } }, - { from: { col: 4, row: 0 }, to: { col: 4, row: 1 } }, - { from: { col: 5, row: 0 }, to: { col: 5, row: 1 } }, - { from: { col: 5, row: 1 }, to: { col: 4, row: 1 } }, - { from: { col: 4, row: 1 }, to: { col: 4, row: 3 } }, - { from: { col: 4, row: 3 }, to: { col: 3, row: 3 } }, - { from: { col: 0, row: 5 }, to: { col: 1, row: 5 } }, - { from: { col: 0, row: 6 }, to: { col: 0, row: 5 } }, - { from: { col: 1, row: 6 }, to: { col: 1, row: 5 } }, - { from: { col: 1, row: 5 }, to: { col: 1, row: 3 } }, - { from: { col: 2, row: 7 }, to: { col: 3, row: 7 } }, - { from: { col: 2, row: 8 }, to: { col: 2, row: 7 } }, - { from: { col: 3, row: 8 }, to: { col: 3, row: 7 } }, - { from: { col: 3, row: 7 }, to: { col: 3, row: 5 } }, - { from: { col: 3, row: 5 }, to: { col: 3, row: 3 } }, - { from: { col: 3, row: 3 }, to: { col: 4, row: 3 } }, - { from: { col: 4, row: 3 }, to: { col: 5, row: 3 } }, - { from: { col: 5, row: 3 }, to: { col: 5, row: 4 } }, - { from: { col: 5, row: 4 }, to: { col: 6, row: 4 } }, - { from: { col: 6, row: 4 }, to: { col: 7, row: 4 } }, - { from: { col: 7, row: 4 }, to: { col: 8, row: 4 } }, - { from: { col: 8, row: 4 }, to: { col: 8, row: 3 } }, - { from: { col: 8, row: 4 }, to: { col: 8, row: 5 } }, - { from: { col: 8, row: 3 }, to: { col: 9, row: 3 } }, - { from: { col: 9, row: 3 }, to: { col: 9, row: 4 } }, - { from: { col: 8, row: 5 }, to: { col: 9, row: 5 } }, - { from: { col: 3, row: 3 }, to: { col: 3, row: 5 } }, - { from: { col: 3, row: 5 }, to: { col: 4, row: 5 } }, - { from: { col: 4, row: 5 }, to: { col: 5, row: 5 } }, - { from: { col: 5, row: 5 }, to: { col: 5, row: 6 } }, - { from: { col: 5, row: 6 }, to: { col: 5, row: 7 } }, - { from: { col: 5, row: 7 }, to: { col: 6, row: 7 } }, - { from: { col: 6, row: 7 }, to: { col: 6, row: 8 } }, - { from: { col: 6, row: 7 }, to: { col: 7, row: 7 } }, - { from: { col: 7, row: 7 }, to: { col: 7, row: 8 } }, -]; +import React from 'react'; +import HeroWebGLScene from './hero-webgl/HeroWebGLScene'; export default function HeroIllustration() { - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => setIsMobile(window.innerWidth < 768); - checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); - }, []); - - const viewBox = isMobile ? '400 0 1000 1100' : '-400 -200 1800 1100'; - // Increase scale slightly and opacity significantly on mobile to fix the "thin" appearance - const scale = isMobile ? 1.6 : 1; - const opacity = isMobile ? 0.9 : 0.85; - - return ( -
- -
-
- ); + return ; } diff --git a/components/home/hero-webgl/Generator.ts b/components/home/hero-webgl/Generator.ts new file mode 100644 index 00000000..ac2c8754 --- /dev/null +++ b/components/home/hero-webgl/Generator.ts @@ -0,0 +1,307 @@ +import { createNoise2D } from 'simplex-noise'; +import { getTerrainHeight, generateClusteredPoints, generateCatenaryCurve, Vec2, Vec3, distance2D, distance3D } from './math'; + +export type NodeType = 'wind' | 'solar' | 'city_building' | 'substation' | 'tower' | 'tree'; + +export interface SceneNode { + id: string; + type: NodeType; + position: Vec3; + rotation: Vec3; + scale: Vec3; + clusterId?: string; + subType?: 'tall' | 'medium' | 'small' | string; +} + +export interface SceneEdge { + id: string; + source: string; + target: string; + type: 'underground' | 'transmission'; // transmission flies in the air, underground is flat on ground + path: Vec3[]; +} + +export interface SceneData { + nodes: SceneNode[]; + edges: SceneEdge[]; +} + +function rand(seed: number) { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); +} + +export function generateSceneData(seed: number): SceneData { + let rSeed = seed; + const rng = () => rand(rSeed++); + const noise2D = createNoise2D(() => rng()); + + const nodes: SceneNode[] = []; + const edges: SceneEdge[] = []; + const substations: SceneNode[] = []; + + // 1. Generation Areas (Wind Parks, Solar Farms, Cities) + // We want them spread across a large landscape (-400 to 400) + const mapSize = 800; + + // Choose random central points for clusters + const windParks: Vec2[] = []; + const solarFarms: Vec2[] = []; + const cities: Vec2[] = []; + + for (let i = 0; i < 4; i++) { + windParks.push([(rng() - 0.5) * mapSize, (rng() - 0.5) * mapSize]); + solarFarms.push([(rng() - 0.5) * mapSize, (rng() - 0.5) * mapSize]); + cities.push([(rng() - 0.5) * mapSize, (rng() - 0.5) * mapSize]); + } + + // Push substations away from the centers slightly + const placeSubstation = (center: Vec2, clusterId: string) => { + const angle = rng() * Math.PI * 2; + const r = 20 + rng() * 10; + const sx = center[0] + Math.cos(angle) * r; + const sz = center[1] + Math.sin(angle) * r; + const sy = getTerrainHeight(noise2D, sx, sz); + + const substation: SceneNode = { + id: `sub_${clusterId}`, + type: 'substation', + position: [sx, sy, sz], + rotation: [0, rng() * Math.PI, 0], + scale: [1, 1, 1], + clusterId + }; + nodes.push(substation); + substations.push(substation); + return substation; + }; + + // Generate Wind Parks + windParks.forEach((center, idx) => { + const cid = `wind_park_${idx}`; + const sub = placeSubstation(center, cid); + const count = 10 + Math.floor(rng() * 10); // 10-20 turbines + const points = generateClusteredPoints(rng, center, count, 60, 15); + + points.forEach((p, pIdx) => { + const y = getTerrainHeight(noise2D, p[0], p[1]); + const tid = `${cid}_t_${pIdx}`; + nodes.push({ + id: tid, + type: 'wind', + position: [p[0], y, p[1]], + rotation: [0, rng() * Math.PI, 0], + scale: [1 + rng() * 0.2, 1 + rng() * 0.2, 1 + rng() * 0.2], + clusterId: cid + }); + + // Underground cable to substation + edges.push({ + id: `edge_${tid}_${sub.id}`, + source: tid, + target: sub.id, + type: 'underground', + path: [[p[0], y, p[1]], sub.position] + }); + }); + }); + + // Generate Solar Farms + solarFarms.forEach((center, idx) => { + const cid = `solar_farm_${idx}`; + const sub = placeSubstation(center, cid); + const count = 30 + Math.floor(rng() * 20); + // Grid-like placement for solar + const points: Vec2[] = []; + for (let r = -3; r <= 3; r++) { + for (let c = -3; c <= 3; c++) { + if (rng() > 0.3) { + const sx = center[0] + r * 6 + (rng() - 0.5) * 2; + const sz = center[1] + c * 4 + (rng() - 0.5) * 2; + points.push([sx, sz]); + } + } + } + + points.forEach((p, pIdx) => { + const y = getTerrainHeight(noise2D, p[0], p[1]); + const tid = `${cid}_s_${pIdx}`; + nodes.push({ + id: tid, + type: 'solar', + position: [p[0], y, p[1]], + rotation: [0, 0, Math.PI * 0.1], // slight tilt up + scale: [1, 1, 1], + clusterId: cid + }); + + // Group cables not needed for every single solar panel, maybe just one underground per farm for visual + }); + // Add one main underground cable from center of grid to substation + const yC = getTerrainHeight(noise2D, center[0], center[1]); + edges.push({ + id: `edge_solar_main_${cid}_${sub.id}`, + source: cid, + target: sub.id, + type: 'underground', + path: [[center[0], yC, center[1]], sub.position] + }); + }); + + // Generate Cities + cities.forEach((center, idx) => { + const cid = `city_${idx}`; + const sub = placeSubstation(center, cid); + const count = 20 + Math.floor(rng() * 20); + const points = generateClusteredPoints(rng, center, count, 50, 6); + + points.forEach((p, pIdx) => { + const y = getTerrainHeight(noise2D, p[0], p[1]); + const tid = `${cid}_b_${pIdx}`; + + const distToCenter = distance2D(p, center); + // taller buildings in center + const typeRand = rng(); + let subType = 'small'; + if (distToCenter < 20 && typeRand > 0.4) subType = 'tall'; + else if (distToCenter < 35 && typeRand > 0.3) subType = 'medium'; + + const sY = subType === 'tall' ? 4 + rng() * 4 : subType === 'medium' ? 2 + rng() * 2 : 1 + rng(); + + nodes.push({ + id: tid, + type: 'city_building', + position: [p[0], y + sY / 2, p[1]], // elevate by half height so it sits on ground + rotation: [0, rng() * Math.PI, 0], + scale: [1 + rng(), sY, 1 + rng()], + clusterId: cid, + subType + }); + }); + }); + + // Connect Substations via high-voltage lines + // A simple minimum spanning tree (MST) or nearest neighbor chain + const unvisited = [...substations]; + const visited: SceneNode[] = []; + if (unvisited.length > 0) { + visited.push(unvisited.pop()!); + } + + const towersIdMap = new Map(); + let towerCount = 0; + + while (unvisited.length > 0) { + let bestDist = Infinity; + let bestFrom: SceneNode | null = null; + let bestTo: SceneNode | null = null; + let bestIdx = -1; + + for (const v of visited) { + for (let i = 0; i < unvisited.length; i++) { + const u = unvisited[i]; + const d = distance3D(v.position, u.position); + if (d < bestDist) { + bestDist = d; + bestFrom = v; + bestTo = u; + bestIdx = i; + } + } + } + + if (bestFrom && bestTo) { + visited.push(bestTo); + unvisited.splice(bestIdx, 1); + + // Interpolate towers between bestFrom and bestTo + const segLen = 60; + const steps = Math.max(1, Math.floor(bestDist / segLen)); + let prevPoint: SceneNode = bestFrom; + + for (let s = 1; s <= steps; s++) { + const t = s / (steps + 1); + const tx = bestFrom.position[0] + (bestTo.position[0] - bestFrom.position[0]) * t; + const tz = bestFrom.position[2] + (bestTo.position[2] - bestFrom.position[2]) * t; + const ty = getTerrainHeight(noise2D, tx, tz); + + const isLast = s === steps + 1; // wait, loop is to steps. Next is bestTo. + + const newTower: SceneNode = { + id: `tower_${++towerCount}`, + type: 'tower', + position: [tx, ty, tz], + rotation: [0, Math.atan2(bestTo.position[0] - bestFrom.position[0], bestTo.position[2] - bestFrom.position[2]), 0], + scale: [1, 1, 1] + }; + + nodes.push(newTower); + + // Cable from prevPoint to newTower + // Tower height offset + const h1 = prevPoint.type === 'tower' ? 12 : 3; + const h2 = newTower.type === 'tower' ? 12 : 3; + + const p1: Vec3 = [prevPoint.position[0], prevPoint.position[1] + h1, prevPoint.position[2]]; + const p2: Vec3 = [newTower.position[0], newTower.position[1] + h2, newTower.position[2]]; + + edges.push({ + id: `edge_hv_${prevPoint.id}_${newTower.id}`, + source: prevPoint.id, + target: newTower.id, + type: 'transmission', + path: generateCatenaryCurve(p1, p2, 5, 8) // sag of 5, 8 segments + }); + + prevPoint = newTower; + } + + // connect prevPoint to bestTo + const h1 = prevPoint.type === 'tower' ? 12 : 3; + const h2 = bestTo.type === 'tower' ? 12 : 3; + const p1: Vec3 = [prevPoint.position[0], prevPoint.position[1] + h1, prevPoint.position[2]]; + const p2: Vec3 = [bestTo.position[0], bestTo.position[1] + h2, bestTo.position[2]]; + + edges.push({ + id: `edge_hv_${prevPoint.id}_${bestTo.id}`, + source: prevPoint.id, + target: bestTo.id, + type: 'transmission', + path: generateCatenaryCurve(p1, p2, 5, 8) + }); + } + } + + // Generate Trees over empty areas + // 1000 trees using random noise clustering + for (let i = 0; i < 800; i++) { + const tx = (rng() - 0.5) * mapSize; + const tz = (rng() - 0.5) * mapSize; + + // Quick check to avoid intersecting cities/substations (simple distance) + let tooClose = false; + for (const sub of substations) { + if (distance2D([tx, tz], [sub.position[0], sub.position[2]]) < 40) { + tooClose = true; + break; + } + } + + if (!tooClose) { + const ty = getTerrainHeight(noise2D, tx, tz); + // Only place trees slightly lower on hills, not in water (if any) and clustered + const n = noise2D(tx * 0.005, tz * 0.005); + if (n > -0.2) { + nodes.push({ + id: `tree_${i}`, + type: 'tree', + position: [tx, ty, tz], + rotation: [0, rng() * Math.PI, 0], + scale: [1 + rng() * 0.5, 1 + rng() * 0.5, 1 + rng() * 0.5] + }); + } + } + } + + return { nodes, edges }; +} diff --git a/components/home/hero-webgl/HeroWebGLScene.tsx b/components/home/hero-webgl/HeroWebGLScene.tsx new file mode 100644 index 00000000..f5147b9d --- /dev/null +++ b/components/home/hero-webgl/HeroWebGLScene.tsx @@ -0,0 +1,54 @@ +import React, { useMemo, useRef } from 'react'; +import { Canvas, useFrame } from '@react-three/fiber'; +import { OrbitControls, PerspectiveCamera } from '@react-three/drei'; +import { generateSceneData } from './Generator'; +import ObjectInstances from './ObjectInstances'; +import TransmissionLines from './TransmissionLines'; +import Landscape from './Landscape'; +import * as THREE from 'three'; + +const WebGLContent: React.FC = () => { + // Generate the procedural data with a fixed seed so it's consistent + const sceneData = useMemo(() => generateSceneData(42), []); + + const groupRef = useRef(null!); + + // Very slow rotation for a cinematic feel + useFrame((state, delta) => { + if (groupRef.current) { + groupRef.current.rotation.y += delta * 0.05; + } + }); + + return ( + <> + + + + {/* Atmospheric Fog - blends the edges into the space background */} + + + + + + + + + + + + + ); +}; + +export default function HeroWebGLScene() { + return ( +
+ + + + {/* Decorative overlaid gradient exactly like space */} +
+
+ ); +} diff --git a/components/home/hero-webgl/Landscape.tsx b/components/home/hero-webgl/Landscape.tsx new file mode 100644 index 00000000..c8ce44ca --- /dev/null +++ b/components/home/hero-webgl/Landscape.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from 'react'; +import * as THREE from 'three'; +import { createNoise2D } from 'simplex-noise'; +import { getTerrainHeight } from './math'; + +export const Landscape: React.FC<{ seed: number }> = ({ seed }) => { + const geometry = useMemo(() => { + // Generate a very large plane with many segments + const size = 1200; + const segments = 128; + const geo = new THREE.PlaneGeometry(size, size, segments, segments); + geo.rotateX(-Math.PI / 2); // Lay flat on XZ plane + + const pos = geo.attributes.position; + const vertex = new THREE.Vector3(); + + // Recreate same noise instance the Generator used + let rSeed = seed; + const rng = () => { + const x = Math.sin(rSeed++) * 10000; + return x - Math.floor(x); + }; + const noise2D = createNoise2D(() => rng()); + + for (let i = 0; i < pos.count; i++) { + vertex.fromBufferAttribute(pos, i); + + // Main height from the math utility so objects match exactly + const h = getTerrainHeight(noise2D, vertex.x, vertex.z); + + // Curve down massively at edges to form a "planet" horizon + // If we are at dist R from center, we drop it down smoothly. + const dist = Math.sqrt(vertex.x * vertex.x + vertex.z * vertex.z); + const edgeRadius = 400; + let drop = 0; + if (dist > edgeRadius) { + // Drop quadratically beyond edgeRadius + const d = dist - edgeRadius; + drop = (d * d) * 0.05; + } + + pos.setY(i, h - drop); + } + + geo.computeVertexNormals(); + return geo; + }, [seed]); + + return ( + + + + ); +}; + +export default Landscape; diff --git a/components/home/hero-webgl/ObjectInstances.tsx b/components/home/hero-webgl/ObjectInstances.tsx new file mode 100644 index 00000000..14eb0aea --- /dev/null +++ b/components/home/hero-webgl/ObjectInstances.tsx @@ -0,0 +1,126 @@ +import React, { useMemo } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { SceneData, SceneNode } from './Generator'; +import * as THREE from 'three'; + +const ObjectInstances: React.FC<{ data: SceneData }> = ({ data }) => { + // We'll separate nodes by type to feed into distinct InstancedMeshes + const windNodes = useMemo(() => data.nodes.filter(n => n.type === 'wind'), [data]); + const solarNodes = useMemo(() => data.nodes.filter(n => n.type === 'solar'), [data]); + const cityNodes = useMemo(() => data.nodes.filter(n => n.type === 'city_building'), [data]); + const treeNodes = useMemo(() => data.nodes.filter(n => n.type === 'tree'), [data]); + const towerNodes = useMemo(() => data.nodes.filter(n => n.type === 'tower'), [data]); + const subNodes = useMemo(() => data.nodes.filter(n => n.type === 'substation'), [data]); + + const setMatrixAt = (mesh: THREE.InstancedMesh, i: number, node: SceneNode) => { + const obj = new THREE.Object3D(); + obj.position.set(node.position[0], node.position[1], node.position[2]); + obj.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2]); + obj.scale.set(node.scale[0], node.scale[1], node.scale[2]); + obj.updateMatrix(); + mesh.setMatrixAt(i, obj.matrix); + }; + + // Precompute instance matrices + const windMatrices = useMemo(() => { + const mesh = new THREE.InstancedMesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial(), windNodes.length); + windNodes.forEach((node, i) => setMatrixAt(mesh, i, node)); + mesh.instanceMatrix.needsUpdate = true; + return mesh.instanceMatrix; + }, [windNodes]); + + const solarMatrices = useMemo(() => { + const mesh = new THREE.InstancedMesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial(), solarNodes.length); + solarNodes.forEach((node, i) => setMatrixAt(mesh, i, node)); + mesh.instanceMatrix.needsUpdate = true; + return mesh.instanceMatrix; + }, [solarNodes]); + + const treeMatrices = useMemo(() => { + const mesh = new THREE.InstancedMesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial(), treeNodes.length); + treeNodes.forEach((node, i) => setMatrixAt(mesh, i, node)); + mesh.instanceMatrix.needsUpdate = true; + return mesh.instanceMatrix; + }, [treeNodes]); + + const towerMatrices = useMemo(() => { + const mesh = new THREE.InstancedMesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial(), towerNodes.length); + towerNodes.forEach((node, i) => setMatrixAt(mesh, i, node)); + mesh.instanceMatrix.needsUpdate = true; + return mesh.instanceMatrix; + }, [towerNodes]); + + const subMatrices = useMemo(() => { + const mesh = new THREE.InstancedMesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial(), subNodes.length); + subNodes.forEach((node, i) => setMatrixAt(mesh, i, node)); + mesh.instanceMatrix.needsUpdate = true; + return mesh.instanceMatrix; + }, [subNodes]); + + const cityMatrices = useMemo(() => { + const mesh = new THREE.InstancedMesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial(), cityNodes.length); + cityNodes.forEach((node, i) => setMatrixAt(mesh, i, node)); + mesh.instanceMatrix.needsUpdate = true; + return mesh.instanceMatrix; + }, [cityNodes]); + + return ( + + {/* Trees */} + {treeNodes.length > 0 && ( + + + + + + )} + + {/* Wind Turbines Towers */} + {windNodes.length > 0 && ( + + + + + + )} + + {/* Solar Panels */} + {solarNodes.length > 0 && ( + + + + + + )} + + {/* City Buildings */} + {cityNodes.length > 0 && ( + + + + + + )} + + {/* Substations */} + {subNodes.length > 0 && ( + + + + + + )} + + {/* Transition Towers (Grid) */} + {towerNodes.length > 0 && ( + + + + + + )} + + ); +}; + +export default ObjectInstances; diff --git a/components/home/hero-webgl/TransmissionLines.tsx b/components/home/hero-webgl/TransmissionLines.tsx new file mode 100644 index 00000000..468d9570 --- /dev/null +++ b/components/home/hero-webgl/TransmissionLines.tsx @@ -0,0 +1,59 @@ +import React, { useMemo, useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { SceneData, SceneEdge } from './Generator'; +import * as THREE from 'three'; +import { Line } from '@react-three/drei'; + +// A single CatmullRomCurve3 cable with particles moving along it +const Cable: React.FC<{ edge: SceneEdge }> = ({ edge }) => { + const points = useMemo(() => edge.path.map(p => new THREE.Vector3(p[0], p[1], p[2])), [edge.path]); + const curve = useMemo(() => new THREE.CatmullRomCurve3(points), [points]); + + // We extract the line points for rendering the static line + const linePoints = useMemo(() => curve.getPoints(20), [curve]); + const linePositions = useMemo(() => linePoints.flatMap(p => [p.x, p.y, p.z]), [linePoints]); + + const particleRef = useRef(null!); + const timeRef = useRef(Math.random()); // Random offset for particles + + useFrame((state, delta) => { + if (!particleRef.current) return; + + // Move particle along the curve. Speed based on edge type. + const speed = edge.type === 'transmission' ? 0.3 : 0.15; + timeRef.current = (timeRef.current + delta * speed) % 1; + + const pos = curve.getPointAt(timeRef.current); + particleRef.current.position.copy(pos); + }); + + return ( + + {/* The actual cable. Underground cables can just be transparent or slightly visible */} + {edge.type === 'transmission' && ( + + )} + {/* The glowing particle */} + + + + + + ); +}; + +const TransmissionLines: React.FC<{ data: SceneData }> = ({ data }) => { + return ( + + {data.edges.map(edge => ( + + ))} + + ); +}; + +export default TransmissionLines; diff --git a/components/home/hero-webgl/math.ts b/components/home/hero-webgl/math.ts new file mode 100644 index 00000000..d9803cb1 --- /dev/null +++ b/components/home/hero-webgl/math.ts @@ -0,0 +1,98 @@ +import { createNoise2D } from 'simplex-noise'; + +// Seeded random number generator +export function LCG(seed: number) { + return function () { + seed = Math.imul(48271, seed) | 0 % 2147483647; + return (seed & 2147483647) / 2147483648; + }; +} + +export type Vec2 = [number, number]; +export type Vec3 = [number, number, number]; + +export function distance2D(a: Vec2, b: Vec2) { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + return Math.sqrt(dx * dx + dy * dy); +} + +export function distance3D(a: Vec3, b: Vec3) { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + const dz = a[2] - b[2]; + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} + +// Generate points for a hanging cable between two points (catenary/sag) +export function generateCatenaryCurve( + p1: Vec3, + p2: Vec3, + sag: number, + segments: number = 10 +): Vec3[] { + const points: Vec3[] = []; + for (let i = 0; i <= segments; i++) { + const t = i / segments; + const x = p1[0] + (p2[0] - p1[0]) * t; + const z = p1[2] + (p2[2] - p1[2]) * t; + + // Linear interpolation of height + const baseHeight = p1[1] + (p2[1] - p1[1]) * t; + + // Add parabolic sag + // t ranges from 0 to 1. Parabola that is 0 at ends and 1 in middle is 4 * t * (1 - t) + const sagOffset = 4 * t * (1 - t) * sag; + + const y = baseHeight - sagOffset; + points.push([x, y, z]); + } + return points; +} + +// Simple terrain height function combining multiple frequencies +export function getTerrainHeight(noise2D: ReturnType, x: number, z: number): number { + // We want a gigantic, hilly planet. We'll use low frequency noise. + const n1 = noise2D(x * 0.002, z * 0.002) * 40; // Main hills + const n2 = noise2D(x * 0.01, z * 0.01) * 10; // Smaller details + return n1 + n2; +} + +// Helper: Place points within a radius using rejection sampling to avoid overlap +export function generateClusteredPoints( + rng: () => number, + center: Vec2, + count: number, + radius: number, + minDist: number +): Vec2[] { + const points: Vec2[] = []; + let attempts = 0; + const maxAttempts = count * 50; + + while (points.length < count && attempts < maxAttempts) { + attempts++; + const angle = rng() * Math.PI * 2; + // Square root for uniform distribution in a circle + const r = Math.sqrt(rng()) * radius; + + const p: Vec2 = [ + center[0] + Math.cos(angle) * r, + center[1] + Math.sin(angle) * r + ]; + + let valid = true; + for (const other of points) { + if (distance2D(p, other) < minDist) { + valid = false; + break; + } + } + + if (valid) { + points.push(p); + } + } + + return points; +} diff --git a/package.json b/package.json index f8337656..e75a3ed0 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "resend": "^3.5.0", "schema-dts": "^1.1.5", "sharp": "^0.34.5", + "simplex-noise": "^4.0.3", "svg-to-pdfkit": "^0.1.8", "tailwind-merge": "^3.4.0", "three": "^0.183.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b65c5132..568b0148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + simplex-noise: + specifier: ^4.0.3 + version: 4.0.3 svg-to-pdfkit: specifier: ^0.1.8 version: 0.1.8 @@ -7552,6 +7555,9 @@ packages: simple-wcswidth@1.1.2: resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} + simplex-noise@4.0.3: + resolution: {integrity: sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==} + sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} @@ -16826,6 +16832,8 @@ snapshots: simple-wcswidth@1.1.2: {} + simplex-noise@4.0.3: {} + sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.29