chore(workspace): add gitea repository url to all packages
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 3m48s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 3m48s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
This commit is contained in:
@@ -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 (
|
||||
<div className="absolute inset-0 z-0 overflow-visible bg-primary w-full h-full">
|
||||
<svg
|
||||
viewBox={viewBox}
|
||||
className="w-full h-full transition-all duration-700"
|
||||
style={{ opacity, transform: `scale(${scale})` }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="energy-pulse" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#82ed20" stopOpacity="0" />
|
||||
<stop offset="30%" stopColor="#82ed20" stopOpacity="0.6" />
|
||||
<stop offset="50%" stopColor="#9bf14d" stopOpacity="1" />
|
||||
<stop offset="70%" stopColor="#82ed20" stopOpacity="0.6" />
|
||||
<stop offset="100%" stopColor="#82ed20" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="wind-flow" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0" />
|
||||
<stop offset="30%" stopColor="white" stopOpacity="0.4" />
|
||||
<stop offset="50%" stopColor="white" stopOpacity="0.6" />
|
||||
<stop offset="70%" stopColor="white" stopOpacity="0.4" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g transform="translate(900, 100)">
|
||||
{/* ISOMETRIC GRID */}
|
||||
<g opacity="0.1">
|
||||
{[...Array(GRID.rows + 1)].map((_, row) => {
|
||||
const start = gridToScreen(0, row);
|
||||
const end = gridToScreen(GRID.cols, row);
|
||||
return (
|
||||
<line
|
||||
key={`h-${row}`}
|
||||
x1={start.x}
|
||||
y1={start.y}
|
||||
x2={end.x}
|
||||
y2={end.y}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{[...Array(GRID.cols + 1)].map((_, col) => {
|
||||
const start = gridToScreen(col, 0);
|
||||
const end = gridToScreen(col, GRID.rows);
|
||||
return (
|
||||
<line
|
||||
key={`v-${col}`}
|
||||
x1={start.x}
|
||||
y1={start.y}
|
||||
x2={end.x}
|
||||
y2={end.y}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* POWER LINES */}
|
||||
<g stroke="white" strokeWidth="2" strokeOpacity="0.2">
|
||||
{POWER_LINES.map((line, i) => {
|
||||
const from = gridToScreen(line.from.col, line.from.row);
|
||||
const to = gridToScreen(line.to.col, line.to.row);
|
||||
return <line key={`cable-${i}`} x1={from.x} y1={from.y} x2={to.x} y2={to.y} />;
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* ANIMATED ENERGY FLOW */}
|
||||
<g filter="url(#glow)">
|
||||
{POWER_LINES.map((line, i) => {
|
||||
// Only animate a subset of lines to reduce main-thread work
|
||||
if (i % 2 !== 0) return null;
|
||||
const from = gridToScreen(line.from.col, line.from.row);
|
||||
const to = gridToScreen(line.to.col, line.to.row);
|
||||
const length = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2));
|
||||
return (
|
||||
<line
|
||||
key={`flow-${i}`}
|
||||
x1={from.x}
|
||||
y1={from.y}
|
||||
x2={to.x}
|
||||
y2={to.y}
|
||||
stroke="url(#energy-pulse)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${length * 0.2} ${length * 0.8}`}
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from={length}
|
||||
to={0}
|
||||
dur={`${1.5 + (i % 3) * 0.5}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* SOLAR PANELS */}
|
||||
{INFRASTRUCTURE.solar.map((panel, i) => {
|
||||
const pos = gridToScreen(panel.col, panel.row);
|
||||
return (
|
||||
<g key={`solar-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
<path
|
||||
d="M -20 0 L 0 -10 L 20 0 L 0 10 Z"
|
||||
fill="white"
|
||||
fillOpacity="0.05"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M -15 -5 L 0 -15 L 15 -5 L 0 5 Z"
|
||||
fill="white"
|
||||
fillOpacity="0.1"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
<circle r="3" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
|
||||
<animate
|
||||
attributeName="fillOpacity"
|
||||
values="0.2;0.5;0.2"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* WIND TURBINES */}
|
||||
{INFRASTRUCTURE.wind.map((turbine, i) => {
|
||||
const pos = gridToScreen(turbine.col, turbine.row);
|
||||
return (
|
||||
<g key={`wind-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="-60"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
<g transform="translate(0, -60)">
|
||||
{[0, 120, 240].map((angle, j) => (
|
||||
<line
|
||||
key={`blade-${i}-${j}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="-30"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.4"
|
||||
transform={`rotate(${angle})`}
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from={`${angle} 0 0`}
|
||||
to={`${angle + 360} 0 0`}
|
||||
dur={`${3 + i}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</line>
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* SUBSTATIONS */}
|
||||
{INFRASTRUCTURE.substations.map((sub, i) => {
|
||||
const pos = gridToScreen(sub.col, sub.row);
|
||||
const isCollection = sub.type === 'collection';
|
||||
return (
|
||||
<g key={`substation-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
<path
|
||||
d="M -25 0 L 0 -12 L 25 0 L 0 12 Z"
|
||||
fill="white"
|
||||
fillOpacity="0.05"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d={
|
||||
isCollection
|
||||
? 'M -18 0 L -18 -20 L 0 -32 L 18 -20 L 18 0'
|
||||
: 'M -22 0 L -22 -25 L 0 -37 L 22 -25 L 22 0'
|
||||
}
|
||||
fill="white"
|
||||
fillOpacity="0.08"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* TRANSMISSION TOWERS */}
|
||||
{INFRASTRUCTURE.towers.map((tower, i) => {
|
||||
const pos = gridToScreen(tower.col, tower.row);
|
||||
return (
|
||||
<g key={`tower-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
<path
|
||||
d="M -6 0 L -3 -45 M 6 0 L 3 -45"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
<line
|
||||
x1="-12"
|
||||
y1="-40"
|
||||
x2="12"
|
||||
y2="-40"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.2"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* CITY BUILDINGS */}
|
||||
{[...INFRASTRUCTURE.city, ...INFRASTRUCTURE.city2].map((building, i) => {
|
||||
const pos = gridToScreen(building.col, building.row);
|
||||
const heights = { tall: 70, medium: 45, small: 30 };
|
||||
const height = heights[building.type as keyof typeof heights] || 45;
|
||||
return (
|
||||
<g key={`building-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
<path
|
||||
d={`M -12 0 L -12 -${height} L 0 -${height + 6} L 0 -6 Z`}
|
||||
fill="white"
|
||||
fillOpacity="0.08"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d={`M 0 -6 L 0 -${height + 6} L 12 -${height} L 12 0 Z`}
|
||||
fill="white"
|
||||
fillOpacity="0.05"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeOpacity="0.15"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-primary/9 pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
return <HeroWebGLScene />;
|
||||
}
|
||||
|
||||
307
components/home/hero-webgl/Generator.ts
Normal file
307
components/home/hero-webgl/Generator.ts
Normal file
@@ -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<string, SceneNode>();
|
||||
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 };
|
||||
}
|
||||
54
components/home/hero-webgl/HeroWebGLScene.tsx
Normal file
54
components/home/hero-webgl/HeroWebGLScene.tsx
Normal file
@@ -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<THREE.Group>(null!);
|
||||
|
||||
// Very slow rotation for a cinematic feel
|
||||
useFrame((state, delta) => {
|
||||
if (groupRef.current) {
|
||||
groupRef.current.rotation.y += delta * 0.05;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PerspectiveCamera makeDefault position={[0, 150, 350]} fov={50} />
|
||||
<OrbitControls enableZoom={false} enablePan={false} autoRotate autoRotateSpeed={0.5} maxPolarAngle={Math.PI / 2.2} />
|
||||
|
||||
{/* Atmospheric Fog - blends the edges into the space background */}
|
||||
<fog attach="fog" args={['#061121', 100, 450]} />
|
||||
|
||||
<ambientLight intensity={0.4} />
|
||||
<directionalLight position={[100, 200, 50]} intensity={1.5} color="#e0f2fe" castShadow />
|
||||
<directionalLight position={[-100, 100, -50]} intensity={0.5} color="#3b82f6" />
|
||||
|
||||
<group ref={groupRef}>
|
||||
<Landscape seed={42} />
|
||||
<ObjectInstances data={sceneData} />
|
||||
<TransmissionLines data={sceneData} />
|
||||
</group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function HeroWebGLScene() {
|
||||
return (
|
||||
<div className="w-full h-full absolute inset-0 z-0 bg-gradient-to-t from-[#020a17] to-[#0d2a5a]">
|
||||
<Canvas shadows gl={{ antialias: true, alpha: true }}>
|
||||
<WebGLContent />
|
||||
</Canvas>
|
||||
{/* Decorative overlaid gradient exactly like space */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-[#020a17]/80 pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
components/home/hero-webgl/Landscape.tsx
Normal file
61
components/home/hero-webgl/Landscape.tsx
Normal file
@@ -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 (
|
||||
<mesh geometry={geometry} receiveShadow>
|
||||
<meshStandardMaterial
|
||||
color="#081830" // Dark blueish surface
|
||||
roughness={0.9}
|
||||
metalness={0.1}
|
||||
wireframe={false}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
export default Landscape;
|
||||
126
components/home/hero-webgl/ObjectInstances.tsx
Normal file
126
components/home/hero-webgl/ObjectInstances.tsx
Normal file
@@ -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 (
|
||||
<group>
|
||||
{/* Trees */}
|
||||
{treeNodes.length > 0 && (
|
||||
<instancedMesh args={[undefined, undefined, treeNodes.length]} castShadow receiveShadow>
|
||||
<coneGeometry args={[1.5, 5, 4]} />
|
||||
<meshStandardMaterial color="#0b2e1b" roughness={0.9} />
|
||||
<primitive object={treeMatrices} attach="instanceMatrix" />
|
||||
</instancedMesh>
|
||||
)}
|
||||
|
||||
{/* Wind Turbines Towers */}
|
||||
{windNodes.length > 0 && (
|
||||
<instancedMesh args={[undefined, undefined, windNodes.length]} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[0.3, 0.5, 12, 8]} />
|
||||
<meshStandardMaterial color="#ffffff" roughness={0.5} />
|
||||
<primitive object={windMatrices} attach="instanceMatrix" />
|
||||
</instancedMesh>
|
||||
)}
|
||||
|
||||
{/* Solar Panels */}
|
||||
{solarNodes.length > 0 && (
|
||||
<instancedMesh args={[undefined, undefined, solarNodes.length]} castShadow receiveShadow>
|
||||
<boxGeometry args={[4, 0.2, 2]} />
|
||||
<meshStandardMaterial color="#0055ff" roughness={0.2} metalness={0.8} />
|
||||
<primitive object={solarMatrices} attach="instanceMatrix" />
|
||||
</instancedMesh>
|
||||
)}
|
||||
|
||||
{/* City Buildings */}
|
||||
{cityNodes.length > 0 && (
|
||||
<instancedMesh args={[undefined, undefined, cityNodes.length]} castShadow receiveShadow>
|
||||
<boxGeometry args={[2, 1, 2]} />
|
||||
<meshStandardMaterial color="#1a2b4c" roughness={0.7} emissive="#002b49" emissiveIntensity={0.2} />
|
||||
<primitive object={cityMatrices} attach="instanceMatrix" />
|
||||
</instancedMesh>
|
||||
)}
|
||||
|
||||
{/* Substations */}
|
||||
{subNodes.length > 0 && (
|
||||
<instancedMesh args={[undefined, undefined, subNodes.length]} castShadow receiveShadow>
|
||||
<boxGeometry args={[6, 3, 6]} />
|
||||
<meshStandardMaterial color="#4a5568" roughness={0.8} metalness={0.2} />
|
||||
<primitive object={subMatrices} attach="instanceMatrix" />
|
||||
</instancedMesh>
|
||||
)}
|
||||
|
||||
{/* Transition Towers (Grid) */}
|
||||
{towerNodes.length > 0 && (
|
||||
<instancedMesh args={[undefined, undefined, towerNodes.length]} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[0.5, 1.5, 12, 4]} />
|
||||
<meshStandardMaterial color="#a0aec0" wireframe={true} roughness={0.8} metalness={0.6} />
|
||||
<primitive object={towerMatrices} attach="instanceMatrix" />
|
||||
</instancedMesh>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export default ObjectInstances;
|
||||
59
components/home/hero-webgl/TransmissionLines.tsx
Normal file
59
components/home/hero-webgl/TransmissionLines.tsx
Normal file
@@ -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<THREE.Mesh>(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 (
|
||||
<group>
|
||||
{/* The actual cable. Underground cables can just be transparent or slightly visible */}
|
||||
{edge.type === 'transmission' && (
|
||||
<Line
|
||||
points={linePoints}
|
||||
color="#334155"
|
||||
lineWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
{/* The glowing particle */}
|
||||
<mesh ref={particleRef}>
|
||||
<sphereGeometry args={[edge.type === 'transmission' ? 0.8 : 0.4, 8, 8]} />
|
||||
<meshBasicMaterial color="#82ed20" transparent opacity={0.8} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const TransmissionLines: React.FC<{ data: SceneData }> = ({ data }) => {
|
||||
return (
|
||||
<group>
|
||||
{data.edges.map(edge => (
|
||||
<Cable key={edge.id} edge={edge} />
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransmissionLines;
|
||||
98
components/home/hero-webgl/math.ts
Normal file
98
components/home/hero-webgl/math.ts
Normal file
@@ -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<typeof createNoise2D>, 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user