File size: 5,849 Bytes
9f21d0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// 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)));
}