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

This commit is contained in:
2026-02-27 11:27:22 +01:00
parent 9e7f6ec76f
commit 82bb7240d5
9 changed files with 717 additions and 405 deletions

View 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 };
}

View 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>
);
}

View 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;

View 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;

View 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;

View 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;
}