rcnn/web/src/maplibre/geoUtils.ts
2026-04-25 23:15:46 +02:00

163 lines
5.3 KiB
TypeScript

import * as THREE from 'three'
// WGS-84 ellipsoid constants
const A = 6378137.0 // semi-major axis (m)
const E2 = 0.00669437999014 // first eccentricity squared
/**
* Convert geographic coordinates to ECEF (Earth-Centred Earth-Fixed) metres.
*/
export function lngLatAltToECEF(lon: number, lat: number, alt: number): THREE.Vector3 {
const lonR = lon * Math.PI / 180
const latR = lat * Math.PI / 180
const sinLat = Math.sin(latR)
const cosLat = Math.cos(latR)
const N = A / Math.sqrt(1 - E2 * sinLat * sinLat)
return new THREE.Vector3(
(N + alt) * cosLat * Math.cos(lonR),
(N + alt) * cosLat * Math.sin(lonR),
(N * (1 - E2) + alt) * sinLat,
)
}
/**
* Convert ECEF metres back to [longitude, latitude, altitude].
* Uses Bowring's iterative method (5 iterations → sub-mm accuracy).
*/
export function ecefToLngLatAlt(v: THREE.Vector3): [number, number, number] {
const p = Math.sqrt(v.x * v.x + v.y * v.y)
const lon = Math.atan2(v.y, v.x) * 180 / Math.PI
let lat = Math.atan2(v.z, p * (1 - E2))
for (let i = 0; i < 5; i++) {
const sinLat = Math.sin(lat)
const N = A / Math.sqrt(1 - E2 * sinLat * sinLat)
lat = Math.atan2(v.z + E2 * N * sinLat, p)
}
const sinLat = Math.sin(lat)
const N = A / Math.sqrt(1 - E2 * sinLat * sinLat)
const alt = p / Math.cos(lat) - N
return [lon, lat * 180 / Math.PI, alt]
}
/**
* Convert a point from ECEF to ENU coordinates relative to an origin.
* ENU: X=East, Y=North, Z=Up
*/
export function ecefToEnu(
point: THREE.Vector3,
origin: [number, number, number],
): THREE.Vector3 {
const [oLon, oLat, oAlt] = origin
const org = lngLatAltToECEF(oLon, oLat, oAlt)
const diff = point.clone().sub(org)
const oLonR = oLon * Math.PI / 180
const oLatR = oLat * Math.PI / 180
// ENU basis vectors in ECEF
const east = new THREE.Vector3(-Math.sin(oLonR), Math.cos(oLonR), 0)
const north = new THREE.Vector3(
-Math.sin(oLatR) * Math.cos(oLonR),
-Math.sin(oLatR) * Math.sin(oLonR),
Math.cos(oLatR),
)
const up = new THREE.Vector3(
Math.cos(oLatR) * Math.cos(oLonR),
Math.cos(oLatR) * Math.sin(oLonR),
Math.sin(oLatR),
)
return new THREE.Vector3(diff.dot(east), diff.dot(north), diff.dot(up))
}
/**
* Build a Three.js Matrix4 that positions and orients a local scene
* at the given geographic coordinate (ENU → ECEF transform + heading rotation).
*
* @param lon Longitude in degrees
* @param lat Latitude in degrees
* @param alt Altitude in metres above WGS-84 ellipsoid
* @param headingDeg Clockwise heading in degrees (0 = North)
*/
export function buildSplatWorldMatrix(
lon: number,
lat: number,
alt: number,
headingDeg: number,
): THREE.Matrix4 {
const lonR = lon * Math.PI / 180
const latR = lat * Math.PI / 180
// ENU basis vectors in ECEF
const east = new THREE.Vector3(-Math.sin(lonR), Math.cos(lonR), 0)
const north = new THREE.Vector3(
-Math.sin(latR) * Math.cos(lonR),
-Math.sin(latR) * Math.sin(lonR),
Math.cos(latR),
)
const up = new THREE.Vector3(
Math.cos(latR) * Math.cos(lonR),
Math.cos(latR) * Math.sin(lonR),
Math.sin(latR),
)
// Apply heading rotation (clockwise from North = negative rotation around Up/Z-ENU)
const hRad = -headingDeg * Math.PI / 180
const cosH = Math.cos(hRad)
const sinH = Math.sin(hRad)
// Rotated ENU: X' = cosH*East + sinH*North, Y' = -sinH*East + cosH*North, Z' = Up
const xAxis = east.clone().multiplyScalar(cosH).addScaledVector(north, sinH)
const yAxis = east.clone().multiplyScalar(-sinH).addScaledVector(north, cosH)
const zAxis = up.clone()
const pos = lngLatAltToECEF(lon, lat, alt)
// Column-major Matrix4 (Three.js): each column is [x, y, z, 0], last col is translation
return new THREE.Matrix4().set(
xAxis.x, yAxis.x, zAxis.x, pos.x,
xAxis.y, yAxis.y, zAxis.y, pos.y,
xAxis.z, yAxis.z, zAxis.z, pos.z,
0, 0, 0, 1,
)
}
/**
* Estimate MapLibre camera altitude above terrain in metres.
* Falls back to zoom-based approximation when transform internals are unavailable.
*/
export function getMapCameraAltitude(map: { getZoom(): number; getCenter(): { lat: number }; transform?: unknown }): number {
const t = (map as { transform?: { cameraToCenterDistance?: number; pixelsPerMeter?: number; altitude?: number } }).transform
if (t) {
if (typeof t.altitude === 'number') return t.altitude
if (t.cameraToCenterDistance && t.pixelsPerMeter) {
return t.cameraToCenterDistance / t.pixelsPerMeter
}
}
// Fallback: approximate from zoom (rough but covers our visibility thresholds)
const zoom = map.getZoom()
const lat = map.getCenter().lat
return (40075016.7 * Math.cos(lat * Math.PI / 180)) / Math.pow(2, zoom + 8) * 600
}
/**
* Remove MapLibre layers and sources without throwing if the map was already
* destroyed (map.remove() sets this.style to undefined internally).
*/
export function safeRemoveLayers(
map: {
getLayer: (id: string) => unknown
removeLayer: (id: string) => void
getSource: (id: string) => unknown
removeSource:(id: string) => void
},
layerIds: string[],
sourceIds: string[],
) {
try {
for (const id of layerIds) { if (map.getLayer(id)) map.removeLayer(id) }
for (const id of sourceIds) { if (map.getSource(id)) map.removeSource(id) }
} catch {
// Map was already destroyed before this cleanup ran — safe to ignore.
}
}