// The angles the sky view is built on, and the east-north-up vectors they name. // // One home for these because three modules need them and they have to agree // exactly: SkyView composes a camera basis from an aim, DeviceAim decomposes a // device's orientation back into one, and SkyTargets turns an aim into a world // position to project. A second copy of the level basis is a sign error waiting // to happen — composing and decomposing must be each other's inverse. import { Cartesian3, Cartographic, Math as CesiumMath, Matrix3, Matrix4, Transforms } from "@cesium/engine"; /** Where the sky view looks up from — see CONTEXT.md, observer. */ export interface Observer { lat: number; lon: number; } /** * Which way the sky view is pointing: azimuth clockwise from north and pitch * above the horizontal, both in degrees, plus the roll about the view axis that * a handheld device supplies and a mouse does not. * * Pitch, not elevation. The two are equal whenever the camera is looking at * something — which is what makes the crosshair work — but they are different * roles: the camera has an attitude, a satellite has a position in the sky. This * codebase already spends "elevation" on the latter (see CONTEXT.md, pass, and * `?overpass=elevation`), and heights are called height. */ export interface Aim { azimuth: number; pitch: number; roll: number; } /** * The observer's local frame, built once and reused for every angle taken * against it. Owned by `SkyView`, which knows when the observer moves — building * one costs a 4x4 and a transpose, and three callers wanted it per frame. */ export interface ObserverFrame { position: Cartesian3; fixedToEnu: Matrix3; } export function observerFrame(position: Cartesian3): ObserverFrame { const enuToFixed = Matrix4.getMatrix3(Transforms.eastNorthUpToFixedFrame(position, undefined, new Matrix4()), new Matrix3()); return { position: Cartesian3.clone(position, new Cartesian3()), // Orthonormal, so the transpose is the inverse and no solve is needed. fixedToEnu: Matrix3.transpose(enuToFixed, new Matrix3()), }; } /** * The observer a local east/north offset away, in metres. * * Through the ellipsoid rather than by degrees per metre: the offset is applied * in the tangent plane at the observer and the result read back as coordinates, * which needs no wrapping at the antimeridian and does not stretch towards the * poles, where a fixed metres-per-degree of longitude is wrong by any factor you * like. The tangent plane's own error is the sagitta, d²/2R — 8 cm at a * kilometre, and a step is metres. */ export function offsetObserver(observer: Observer, east: number, north: number): Observer { const origin = Cartesian3.fromDegrees(observer.lon, observer.lat); const enuToFixed = Transforms.eastNorthUpToFixedFrame(origin, undefined, new Matrix4()); const moved = Matrix4.multiplyByPoint(enuToFixed, new Cartesian3(east, north, 0), new Cartesian3()); const carto = Cartographic.fromCartesian(moved); // Only the Earth's centre has no coordinates, which no offset from a point on // the surface reaches — but the observer standing still is the honest answer. return carto ? { lat: CesiumMath.toDegrees(carto.latitude), lon: CesiumMath.toDegrees(carto.longitude) } : observer; } /** Wrap an azimuth to [0, 360). */ export const normalizeAzimuth = (degrees: number): number => ((degrees % 360) + 360) % 360; /** The unit vector an azimuth and elevation point along, in east-north-up. */ export function enuDirection(azimuth: number, elevation: number, distance = 1): Cartesian3 { const az = CesiumMath.toRadians(azimuth); const el = CesiumMath.toRadians(elevation); const cosEl = Math.cos(el); return new Cartesian3(Math.sin(az) * cosEl * distance, Math.cos(az) * cosEl * distance, Math.sin(el) * distance); } /** * The up/right pair for an unrolled view along that direction, in east-north-up. * * `up` is where the view axis heads as elevation increases and `right` is level * with the horizon, so neither is a cross product against world up — which is * what keeps the pair defined at the zenith, where world up and the view axis * are the same line. */ export function levelBasis(azimuth: number, elevation: number): { up: Cartesian3; right: Cartesian3 } { const az = CesiumMath.toRadians(azimuth); const el = CesiumMath.toRadians(elevation); const sinAz = Math.sin(az); const cosAz = Math.cos(az); const sinEl = Math.sin(el); return { up: new Cartesian3(-sinAz * sinEl, -cosAz * sinEl, Math.cos(el)), right: new Cartesian3(cosAz, -sinAz, 0), }; } /** * Roll the level pair about the view axis. * * The inverse of `rollOf`, and the reason both live here: composing with one * sign and decomposing with the other mirrors the view, and the two were far * enough apart to hide it. */ export function rollBasis(azimuth: number, elevation: number, roll: number): { up: Cartesian3; right: Cartesian3 } { const { up: levelUp, right: levelRight } = levelBasis(azimuth, elevation); const radians = CesiumMath.toRadians(roll); const sin = Math.sin(radians); const cos = Math.cos(radians); return { up: new Cartesian3(levelUp.x * cos - levelRight.x * sin, levelUp.y * cos - levelRight.y * sin, levelUp.z * cos - levelRight.z * sin), right: new Cartesian3(levelRight.x * cos + levelUp.x * sin, levelRight.y * cos + levelUp.y * sin, levelRight.z * cos + levelUp.z * sin), }; } /** Recover the roll of an up vector about the view axis. The inverse of `rollBasis`. */ export function rollOf(azimuth: number, elevation: number, up: Cartesian3): number { const { up: levelUp, right: levelRight } = levelBasis(azimuth, elevation); return CesiumMath.toDegrees(Math.atan2(-Cartesian3.dot(up, levelRight), Cartesian3.dot(up, levelUp))); }