253 lines
7.3 KiB
JavaScript
253 lines
7.3 KiB
JavaScript
import * as THREE from "three";
|
||
|
||
const EARTH_RADIUS_KM = 6378.137;
|
||
const SURFACE_SCALE = 1.003;
|
||
const SURFACE_OFFSET = 0.72;
|
||
const CLUSTER_DIAMETER_KM_APPROX = 4500;
|
||
const CLUSTER_RADIUS_KM_BASE = CLUSTER_DIAMETER_KM_APPROX / 2;
|
||
const IRIDIUM_OVERLAY_COLOR = 0x5faeff;
|
||
const IRIDIUM_REFERENCE_ALTITUDE_KM = 780;
|
||
const FILL_RINGS = 12;
|
||
const FILL_SEGMENTS = 48;
|
||
const RING_SEGMENTS = 72;
|
||
|
||
function disposeMaterial(material) {
|
||
if (!material) return;
|
||
if (Array.isArray(material)) {
|
||
material.forEach(disposeMaterial);
|
||
return;
|
||
}
|
||
material.dispose();
|
||
}
|
||
|
||
function disposeObjectTree(object) {
|
||
if (!object) return;
|
||
object.traverse((child) => {
|
||
if (child.geometry) child.geometry.dispose();
|
||
if (child.material) disposeMaterial(child.material);
|
||
});
|
||
}
|
||
|
||
function createIridiumFillMaterial() {
|
||
return new THREE.ShaderMaterial({
|
||
transparent: true,
|
||
side: THREE.DoubleSide,
|
||
depthTest: true,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
uniforms: {
|
||
uColor: { value: new THREE.Color(IRIDIUM_OVERLAY_COLOR) },
|
||
uOpacity: { value: 0.55 },
|
||
},
|
||
vertexShader: `
|
||
attribute vec2 aUv;
|
||
varying vec2 vUv;
|
||
void main() {
|
||
vUv = aUv;
|
||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||
}
|
||
`,
|
||
fragmentShader: `
|
||
uniform vec3 uColor;
|
||
uniform float uOpacity;
|
||
varying vec2 vUv;
|
||
void main() {
|
||
float r2 = dot(vUv, vUv);
|
||
float glow = exp(-r2 * 1.4) * (1.0 - smoothstep(0.72, 1.0, r2));
|
||
float alpha = glow * uOpacity;
|
||
if (alpha <= 0.001) discard;
|
||
gl_FragColor = vec4(uColor, alpha);
|
||
}
|
||
`,
|
||
});
|
||
}
|
||
|
||
function createIridiumRingMaterial() {
|
||
return new THREE.LineBasicMaterial({
|
||
color: new THREE.Color(IRIDIUM_OVERLAY_COLOR),
|
||
transparent: true,
|
||
opacity: 0.75,
|
||
blending: THREE.AdditiveBlending,
|
||
depthTest: true,
|
||
depthWrite: false,
|
||
});
|
||
}
|
||
|
||
function projectOffsetToSurface(
|
||
centerNormal,
|
||
alongTrack,
|
||
crossTrack,
|
||
alongKm,
|
||
crossKm,
|
||
earthRadiusWorld,
|
||
) {
|
||
const worldUnitsPerKm = earthRadiusWorld / EARTH_RADIUS_KM;
|
||
const surfaceRadius = earthRadiusWorld * SURFACE_SCALE + SURFACE_OFFSET;
|
||
return centerNormal
|
||
.clone()
|
||
.multiplyScalar(earthRadiusWorld)
|
||
.addScaledVector(alongTrack, alongKm * worldUnitsPerKm)
|
||
.addScaledVector(crossTrack, crossKm * worldUnitsPerKm)
|
||
.normalize()
|
||
.multiplyScalar(surfaceRadius);
|
||
}
|
||
|
||
function computeClusterRadiusKm(altitudeKm) {
|
||
const altitudeScale = THREE.MathUtils.clamp(
|
||
(Number(altitudeKm) || IRIDIUM_REFERENCE_ALTITUDE_KM) / IRIDIUM_REFERENCE_ALTITUDE_KM,
|
||
0.88,
|
||
1.18,
|
||
);
|
||
return CLUSTER_RADIUS_KM_BASE * altitudeScale;
|
||
}
|
||
|
||
function buildFillGeometry() {
|
||
// Radial grid: center + FILL_RINGS rings × FILL_SEGMENTS points each.
|
||
// Positions are updated in world space each frame; indices are static.
|
||
const vertexCount = 1 + FILL_RINGS * FILL_SEGMENTS;
|
||
const positions = new Float32Array(vertexCount * 3);
|
||
const uvs = new Float32Array(vertexCount * 2);
|
||
|
||
// Center vertex: uv = (0,0)
|
||
// Edge vertices: uv on unit circle, r = ring/FILL_RINGS
|
||
|
||
const indices = [];
|
||
// Center to first ring: triangle fan
|
||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||
const a = 1 + s;
|
||
const b = 1 + (s + 1) % FILL_SEGMENTS;
|
||
indices.push(0, a, b);
|
||
}
|
||
// Ring to ring
|
||
for (let r = 0; r < FILL_RINGS - 1; r++) {
|
||
const ringBase = 1 + r * FILL_SEGMENTS;
|
||
const nextBase = ringBase + FILL_SEGMENTS;
|
||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||
const s1 = (s + 1) % FILL_SEGMENTS;
|
||
indices.push(ringBase + s, nextBase + s, ringBase + s1);
|
||
indices.push(nextBase + s, nextBase + s1, ringBase + s1);
|
||
}
|
||
}
|
||
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||
geometry.setAttribute("aUv", new THREE.BufferAttribute(uvs, 2));
|
||
geometry.setIndex(indices);
|
||
return geometry;
|
||
}
|
||
|
||
function buildRingGeometry() {
|
||
const positions = new Float32Array(RING_SEGMENTS * 3);
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||
return geometry;
|
||
}
|
||
|
||
export function createIridiumFootprintAdapter({
|
||
earthObj,
|
||
earthRadiusWorld,
|
||
renderOrder,
|
||
}) {
|
||
if (!earthObj) return null;
|
||
|
||
const group = new THREE.Group();
|
||
group.name = "iridium-footprint-overlay";
|
||
group.renderOrder = renderOrder;
|
||
group.userData = { earthRadiusWorld, fill: null, outerRing: null };
|
||
|
||
const fill = new THREE.Mesh(buildFillGeometry(), createIridiumFillMaterial());
|
||
fill.name = "iridium-cluster-fill";
|
||
fill.renderOrder = renderOrder;
|
||
fill.frustumCulled = false;
|
||
group.add(fill);
|
||
group.userData.fill = fill;
|
||
|
||
const outerRing = new THREE.LineLoop(buildRingGeometry(), createIridiumRingMaterial());
|
||
outerRing.name = "iridium-outer-ring";
|
||
outerRing.renderOrder = renderOrder;
|
||
outerRing.frustumCulled = false;
|
||
group.add(outerRing);
|
||
group.userData.outerRing = outerRing;
|
||
|
||
earthObj.add(group);
|
||
return group;
|
||
}
|
||
|
||
export function updateIridiumFootprintAdapter(
|
||
group,
|
||
{ position, alongTrack, crossTrack, altitudeKm },
|
||
) {
|
||
if (!group || !position || !alongTrack || !crossTrack) return;
|
||
|
||
const earthRadiusWorld = group.userData?.earthRadiusWorld || EARTH_RADIUS_KM;
|
||
const centerNormal = position.clone().normalize();
|
||
const clusterRadiusKm = computeClusterRadiusKm(altitudeKm);
|
||
const alongRadiusKm = clusterRadiusKm * 1.18;
|
||
const crossRadiusKm = clusterRadiusKm * 0.96;
|
||
|
||
const fill = group.userData?.fill;
|
||
if (fill) {
|
||
const posAttr = fill.geometry.attributes.position;
|
||
const uvAttr = fill.geometry.attributes.aUv;
|
||
|
||
// Center vertex
|
||
const center = projectOffsetToSurface(
|
||
centerNormal, alongTrack, crossTrack, 0, 0, earthRadiusWorld,
|
||
);
|
||
posAttr.setXYZ(0, center.x, center.y, center.z);
|
||
uvAttr.setXY(0, 0, 0);
|
||
|
||
// Ring vertices
|
||
for (let r = 1; r <= FILL_RINGS; r++) {
|
||
const t = r / FILL_RINGS;
|
||
const aKm = alongRadiusKm * t;
|
||
const cKm = crossRadiusKm * t;
|
||
for (let s = 0; s < FILL_SEGMENTS; s++) {
|
||
const angle = (s / FILL_SEGMENTS) * Math.PI * 2;
|
||
const cosA = Math.cos(angle);
|
||
const sinA = Math.sin(angle);
|
||
const pt = projectOffsetToSurface(
|
||
centerNormal, alongTrack, crossTrack,
|
||
aKm * cosA,
|
||
cKm * sinA,
|
||
earthRadiusWorld,
|
||
);
|
||
const vi = 1 + (r - 1) * FILL_SEGMENTS + s;
|
||
posAttr.setXYZ(vi, pt.x, pt.y, pt.z);
|
||
uvAttr.setXY(vi, t * cosA, t * sinA);
|
||
}
|
||
}
|
||
|
||
posAttr.needsUpdate = true;
|
||
uvAttr.needsUpdate = true;
|
||
fill.geometry.computeBoundingSphere();
|
||
}
|
||
|
||
const outerRing = group.userData?.outerRing;
|
||
if (outerRing) {
|
||
const posAttr = outerRing.geometry.attributes.position;
|
||
for (let k = 0; k < RING_SEGMENTS; k++) {
|
||
const angle = (k / RING_SEGMENTS) * Math.PI * 2;
|
||
const pt = projectOffsetToSurface(
|
||
centerNormal, alongTrack, crossTrack,
|
||
alongRadiusKm * Math.cos(angle),
|
||
crossRadiusKm * Math.sin(angle),
|
||
earthRadiusWorld,
|
||
);
|
||
posAttr.setXYZ(k, pt.x, pt.y, pt.z);
|
||
}
|
||
posAttr.needsUpdate = true;
|
||
outerRing.geometry.computeBoundingSphere();
|
||
}
|
||
}
|
||
|
||
export function disposeIridiumFootprintAdapter(group, earthObj) {
|
||
if (!group) return;
|
||
if (earthObj) {
|
||
earthObj.remove(group);
|
||
} else if (group.parent) {
|
||
group.parent.remove(group);
|
||
}
|
||
disposeObjectTree(group);
|
||
}
|