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