mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 11:54:02 +00:00
Add quaternions to src/geo
Ports Cesium's axis-angle, product, rotation-matrix and heading-pitch-roll quaternions, with rotations given as frame axes. They match Cesium bit for bit, including heading-pitch-roll frames on the polar axis. The model convention is documented: at heading 0 the forward axis points east.
This commit is contained in:
1 parent
fd1c09dbbc
commit
52d82b453c
4 files changed
+377
-4
No files matched your search
@@ -2360,6 +2360,7 @@
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/layers/flights/icons.js",
|
||||
"src/data/aircraftIconLayer.js",
|
||||
"src/data/aircraftModelLayer.js",
|
||||
@@ -5122,6 +5123,7 @@
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/agent/worldPacket.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/data/feedState.js",
|
||||
@@ -5144,7 +5146,8 @@
|
||||
"src/geo/vector.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/celestial.js"
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -5166,6 +5169,7 @@
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/render/stub/motion.js",
|
||||
"src/render/stub/camera.js",
|
||||
"src/render/stub/geodesic.js",
|
||||
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* @module geo
|
||||
* @description Renderer-neutral geodesy and vector maths: WGS84, ECEF and
|
||||
* geodetic conversions, east-north-up frames, and the sun and
|
||||
* moon. Plain numbers and `{x, y, z}` objects only, so code that needs maths
|
||||
* but not a renderer does not import one.
|
||||
* geodetic conversions, east-north-up frames, quaternions, and the sun and
|
||||
* moon. Plain numbers and `{x, y, z}` objects only, so code that needs
|
||||
* maths but not a renderer does not import one.
|
||||
*/
|
||||
export * from './vector.js';
|
||||
export * from './ellipsoid.js';
|
||||
export * from './frames.js';
|
||||
export * from './celestial.js';
|
||||
export * from './quaternion.js';
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @module geo/quaternion
|
||||
* @description Unit quaternions `{x, y, z, w}` and the model
|
||||
* heading-pitch-roll convention, in the east-north-up frame at a point: the
|
||||
* forward axis is the body's x, which at heading 0 points east; heading
|
||||
* turns it clockwise seen from above (heading π/2 points south), pitch
|
||||
* raises it, roll turns about it. (The camera's heading, from north, is a
|
||||
* different convention.)
|
||||
*
|
||||
* Each function follows Cesium's formula step by step, so results match it
|
||||
* to the last bit. A rotation's axes are given as a frame's `x`, `y`, `z`
|
||||
* columns (`geo/frames`), the form of Cesium's column-major `Matrix3`.
|
||||
*/
|
||||
import { eastNorthUp } from './frames.js';
|
||||
import { normalize, vec } from './vector.js';
|
||||
|
||||
/** @typedef {{x: number, y: number, z: number, w: number}} Quaternion */
|
||||
|
||||
export const quat = (x, y, z, w) => ({ x, y, z, w });
|
||||
|
||||
export const IDENTITY_QUATERNION = Object.freeze(quat(0, 0, 0, 1));
|
||||
|
||||
/**
|
||||
* Rotation by `angle` radians (right-handed) about `axis`, normalised first.
|
||||
* Throws a RangeError for an axis with no direction, where Cesium's debug
|
||||
* build throws.
|
||||
*/
|
||||
export function fromAxisAngle(axis, angle) {
|
||||
const halfAngle = angle / 2.0;
|
||||
const s = Math.sin(halfAngle);
|
||||
const n = normalize(axis);
|
||||
if (Number.isNaN(n.x) || Number.isNaN(n.y) || Number.isNaN(n.z))
|
||||
throw new RangeError('the axis has no direction');
|
||||
return quat(n.x * s, n.y * s, n.z * s, Math.cos(halfAngle));
|
||||
}
|
||||
|
||||
/** The product `left · right`: the rotation that applies `right`, then `left`. */
|
||||
export function multiplyQuaternions(left, right) {
|
||||
const { x: lx, y: ly, z: lz, w: lw } = left;
|
||||
const { x: rx, y: ry, z: rz, w: rw } = right;
|
||||
return quat(
|
||||
lw * rx + lx * rw + ly * rz - lz * ry,
|
||||
lw * ry - lx * rz + ly * rw + lz * rx,
|
||||
lw * rz + lx * ry - ly * rx + lz * rw,
|
||||
lw * rw - lx * rx - ly * ry - lz * rz,
|
||||
);
|
||||
}
|
||||
|
||||
export const conjugateQuaternion = (q) => quat(-q.x, -q.y, -q.z, q.w);
|
||||
|
||||
/** The rotation's axes: where it takes the unit x, y and z vectors. */
|
||||
export function toAxes(q) {
|
||||
const x2 = q.x * q.x;
|
||||
const xy = q.x * q.y;
|
||||
const xz = q.x * q.z;
|
||||
const xw = q.x * q.w;
|
||||
const y2 = q.y * q.y;
|
||||
const yz = q.y * q.z;
|
||||
const yw = q.y * q.w;
|
||||
const z2 = q.z * q.z;
|
||||
const zw = q.z * q.w;
|
||||
const w2 = q.w * q.w;
|
||||
return {
|
||||
x: vec(x2 - y2 - z2 + w2, 2.0 * (xy + zw), 2.0 * (xz - yw)),
|
||||
y: vec(2.0 * (xy - zw), -x2 + y2 - z2 + w2, 2.0 * (yz + xw)),
|
||||
z: vec(2.0 * (xz + yw), 2.0 * (yz - xw), -x2 - y2 + z2 + w2),
|
||||
};
|
||||
}
|
||||
|
||||
const NEXT = [1, 2, 0];
|
||||
|
||||
/**
|
||||
* The quaternion of a rotation given by its axes (orthonormal, right-handed:
|
||||
* any frame's `x`, `y`, `z`). Its `w` is positive when the trace is. When
|
||||
* the trace is not positive the result is Cesium's: the negative of the
|
||||
* textbook quaternion (the same rotation). Keep that negation.
|
||||
*/
|
||||
export function fromAxes({ x: c0, y: c1, z: c2 }) {
|
||||
// m[i][j]: row i of column j.
|
||||
const m = [
|
||||
[c0.x, c1.x, c2.x],
|
||||
[c0.y, c1.y, c2.y],
|
||||
[c0.z, c1.z, c2.z],
|
||||
];
|
||||
const trace = m[0][0] + m[1][1] + m[2][2];
|
||||
if (trace > 0.0) {
|
||||
let root = Math.sqrt(trace + 1.0);
|
||||
const w = 0.5 * root;
|
||||
root = 0.5 / root;
|
||||
return quat(
|
||||
(m[2][1] - m[1][2]) * root,
|
||||
(m[0][2] - m[2][0]) * root,
|
||||
(m[1][0] - m[0][1]) * root,
|
||||
w,
|
||||
);
|
||||
}
|
||||
let i = 0;
|
||||
if (m[1][1] > m[0][0]) i = 1;
|
||||
if (m[2][2] > m[0][0] && m[2][2] > m[1][1]) i = 2;
|
||||
const j = NEXT[i];
|
||||
const k = NEXT[j];
|
||||
let root = Math.sqrt(m[i][i] - m[j][j] - m[k][k] + 1.0);
|
||||
const q = [0, 0, 0];
|
||||
q[i] = 0.5 * root;
|
||||
root = 0.5 / root;
|
||||
// Cesium reads getElementIndex(column, row) here, i.e. m[row][column].
|
||||
const w = (m[j][k] - m[k][j]) * root;
|
||||
q[j] = (m[i][j] + m[j][i]) * root;
|
||||
q[k] = (m[i][k] + m[k][i]) * root;
|
||||
return quat(-q[0], -q[1], -q[2], w);
|
||||
}
|
||||
|
||||
const UNIT_X = vec(1, 0, 0);
|
||||
const UNIT_Y = vec(0, 1, 0);
|
||||
const UNIT_Z = vec(0, 0, 1);
|
||||
|
||||
/**
|
||||
* Heading, pitch and roll (radians) as a rotation of the local frame: roll
|
||||
* about x, then pitch about y, then heading about z. In an east-north-up
|
||||
* frame x is east, so heading 0 faces east; see `headingPitchRollFrame`.
|
||||
*/
|
||||
export function fromHeadingPitchRoll({ heading = 0, pitch = 0, roll = 0 }) {
|
||||
const rollQ = fromAxisAngle(UNIT_X, roll);
|
||||
const pitchQ = fromAxisAngle(UNIT_Y, -pitch);
|
||||
const headingQ = fromAxisAngle(UNIT_Z, -heading);
|
||||
return multiplyQuaternions(headingQ, multiplyQuaternions(pitchQ, rollQ));
|
||||
}
|
||||
|
||||
/**
|
||||
* The ECEF axes of a body at `origin` (ECEF metres) turned by heading, pitch
|
||||
* and roll in the east-north-up frame there: Cesium's
|
||||
* `Transforms.headingPitchRollToFixedFrame`, as a frame.
|
||||
*/
|
||||
export function headingPitchRollFrame(origin, hpr) {
|
||||
const enu = eastNorthUp(origin);
|
||||
const r = toAxes(fromHeadingPitchRoll(hpr));
|
||||
// enu · r, column by column, each sum in Cesium's Matrix4.multiply order,
|
||||
// its zero translation product included: it turns a -0 sum into +0.
|
||||
const col = (c) =>
|
||||
vec(
|
||||
enu.x.x * c.x + enu.y.x * c.y + enu.z.x * c.z + enu.origin.x * 0.0,
|
||||
enu.x.y * c.x + enu.y.y * c.y + enu.z.y * c.z + enu.origin.y * 0.0,
|
||||
enu.x.z * c.x + enu.y.z * c.y + enu.z.z * c.z + enu.origin.z * 0.0,
|
||||
);
|
||||
return { x: col(r.x), y: col(r.y), z: col(r.z), origin: enu.origin };
|
||||
}
|
||||
|
||||
/** Cesium's `Transforms.headingPitchRollQuaternion`. */
|
||||
export const headingPitchRollQuaternion = (origin, hpr) =>
|
||||
fromAxes(headingPitchRollFrame(origin, hpr));
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
conjugateQuaternion,
|
||||
fromAxes,
|
||||
fromAxisAngle,
|
||||
fromDegrees,
|
||||
fromHeadingPitchRoll,
|
||||
headingPitchRollFrame,
|
||||
headingPitchRollQuaternion,
|
||||
multiplyQuaternions,
|
||||
toAxes,
|
||||
toRadians,
|
||||
} from './index.js';
|
||||
|
||||
function random(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
const next = random(20261105);
|
||||
const angle = () => (next() * 2 - 1) * 2 * Math.PI;
|
||||
const axis = () => ({
|
||||
x: next() * 2 - 1,
|
||||
y: next() * 2 - 1,
|
||||
z: next() * 2 - 1,
|
||||
});
|
||||
|
||||
/** Random rotations, plus ones near a half turn (trace ≤ 0, the other branch). */
|
||||
function rotations(count = 400) {
|
||||
const out = [];
|
||||
for (let i = 0; i < count; i++) out.push([axis(), angle()]);
|
||||
for (const a of [
|
||||
{ x: 1, y: 0, z: 0 },
|
||||
{ x: 0, y: 1, z: 0 },
|
||||
{ x: 0, y: 0, z: 1 },
|
||||
{ x: 1, y: 1e-3, z: -2e-3 },
|
||||
])
|
||||
for (const t of [Math.PI, Math.PI - 1e-9, -Math.PI, 2.5, 0, 1e-12])
|
||||
out.push([a, t]);
|
||||
return out;
|
||||
}
|
||||
|
||||
const same = (a, b, message) => {
|
||||
for (const k of ['x', 'y', 'z', 'w'])
|
||||
if (k in b) assert.equal(a[k], b[k], `${message} ${k}`);
|
||||
};
|
||||
const columns = (m) =>
|
||||
[0, 1, 2].map((c) => Cesium.Matrix3.getColumn(m, c, new Cesium.Cartesian3()));
|
||||
|
||||
test("axis-angle, product and axes are bit for bit the default renderer's", () => {
|
||||
let previous = Cesium.Quaternion.IDENTITY;
|
||||
let previousOurs = { x: 0, y: 0, z: 0, w: 1 };
|
||||
let otherBranch = 0;
|
||||
for (const [a, t] of rotations()) {
|
||||
const ours = fromAxisAngle(a, t);
|
||||
const theirs = Cesium.Quaternion.fromAxisAngle(
|
||||
new Cesium.Cartesian3(a.x, a.y, a.z),
|
||||
t,
|
||||
);
|
||||
same(ours, theirs, `fromAxisAngle(${JSON.stringify(a)}, ${t})`);
|
||||
|
||||
same(
|
||||
multiplyQuaternions(ours, previousOurs),
|
||||
Cesium.Quaternion.multiply(theirs, previous, new Cesium.Quaternion()),
|
||||
'multiply',
|
||||
);
|
||||
previous = theirs;
|
||||
previousOurs = ours;
|
||||
|
||||
const m = Cesium.Matrix3.fromQuaternion(theirs);
|
||||
const axes = toAxes(ours);
|
||||
const [c0, c1, c2] = columns(m);
|
||||
same(axes.x, c0, 'toAxes x');
|
||||
same(axes.y, c1, 'toAxes y');
|
||||
same(axes.z, c2, 'toAxes z');
|
||||
|
||||
if (m[0] + m[4] + m[8] <= 0) otherBranch += 1;
|
||||
same(fromAxes(axes), Cesium.Quaternion.fromRotationMatrix(m), 'fromAxes');
|
||||
}
|
||||
assert.ok(otherBranch >= 20, `only ${otherBranch} rotations had trace ≤ 0`);
|
||||
});
|
||||
|
||||
test('fromAxes inverts toAxes up to sign', () => {
|
||||
for (const [a, t] of rotations(100)) {
|
||||
const q = fromAxisAngle(a, t);
|
||||
const back = fromAxes(toAxes(q));
|
||||
const s = Math.sign(
|
||||
back.w * q.w + back.x * q.x + back.y * q.y + back.z * q.z,
|
||||
);
|
||||
for (const k of ['x', 'y', 'z', 'w'])
|
||||
assert.ok(
|
||||
Math.abs(back[k] * s - q[k]) < 1e-12,
|
||||
`${k}: ${back[k]} vs ${q[k]}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('conjugateQuaternion undoes a rotation', () => {
|
||||
const q = fromAxisAngle({ x: 1, y: 2, z: 3 }, 0.7);
|
||||
const id = multiplyQuaternions(q, conjugateQuaternion(q));
|
||||
assert.ok(Math.abs(id.w - 1) < 1e-15 && Math.hypot(id.x, id.y, id.z) < 1e-15);
|
||||
});
|
||||
|
||||
test("heading-pitch-roll matches the default renderer's", () => {
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const hpr = { heading: angle(), pitch: angle() / 4, roll: angle() / 2 };
|
||||
same(
|
||||
fromHeadingPitchRoll(hpr),
|
||||
Cesium.Quaternion.fromHeadingPitchRoll(
|
||||
new Cesium.HeadingPitchRoll(hpr.heading, hpr.pitch, hpr.roll),
|
||||
),
|
||||
`fromHeadingPitchRoll(${JSON.stringify(hpr)})`,
|
||||
);
|
||||
|
||||
const lon = next() * 360 - 180;
|
||||
const lat = i < 4 ? [90, -90, 90, -90][i] : next() * 180 - 90;
|
||||
const height = next() * 2e7 - 1e3;
|
||||
const origin = fromDegrees(lon, lat, height);
|
||||
const cesiumHpr = new Cesium.HeadingPitchRoll(
|
||||
hpr.heading,
|
||||
hpr.pitch,
|
||||
hpr.roll,
|
||||
);
|
||||
const cesiumOrigin = Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
||||
const frame = headingPitchRollFrame(origin, hpr);
|
||||
const m = Cesium.Transforms.headingPitchRollToFixedFrame(
|
||||
cesiumOrigin,
|
||||
cesiumHpr,
|
||||
);
|
||||
const cols = [0, 1, 2].map((c) =>
|
||||
Cesium.Matrix4.getColumn(m, c, new Cesium.Cartesian4()),
|
||||
);
|
||||
for (const [name, ours, theirs] of [
|
||||
['x', frame.x, cols[0]],
|
||||
['y', frame.y, cols[1]],
|
||||
['z', frame.z, cols[2]],
|
||||
])
|
||||
for (const k of ['x', 'y', 'z'])
|
||||
assert.ok(
|
||||
Math.abs(ours[k] - theirs[k]) <= 1e-12,
|
||||
`frame ${name}.${k} at ${lon},${lat}: ${ours[k]} vs ${theirs[k]}`,
|
||||
);
|
||||
|
||||
const q = headingPitchRollQuaternion(origin, hpr);
|
||||
const qc = Cesium.Transforms.headingPitchRollQuaternion(
|
||||
cesiumOrigin,
|
||||
cesiumHpr,
|
||||
);
|
||||
// Either sign is the same rotation; near a zero trace the ENU frame's
|
||||
// last-bit differences can pick the other.
|
||||
const sign = q.x * qc.x + q.y * qc.y + q.z * qc.z + q.w * qc.w < 0 ? -1 : 1;
|
||||
for (const k of ['x', 'y', 'z', 'w'])
|
||||
assert.ok(
|
||||
Math.abs(q[k] * sign - qc[k]) <= 1e-12,
|
||||
`quaternion ${k}: ${q[k]} vs ${qc[k]}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('heading-pitch-roll frames are exact on the polar axis, origin included', () => {
|
||||
const hprs = [
|
||||
{ heading: 0, pitch: 0, roll: -Math.PI },
|
||||
{ heading: 1.1, pitch: -0.4, roll: 0.3 },
|
||||
{ heading: -2.5, pitch: 1.2, roll: 3 },
|
||||
{},
|
||||
];
|
||||
for (const z of [6356752.3142451793, -6356752.3142451793, 1e7, -7e6])
|
||||
for (const hpr of hprs) {
|
||||
const origin = { x: 0, y: 0, z };
|
||||
const cesiumHpr = new Cesium.HeadingPitchRoll(
|
||||
hpr.heading ?? 0,
|
||||
hpr.pitch ?? 0,
|
||||
hpr.roll ?? 0,
|
||||
);
|
||||
const cesiumOrigin = new Cesium.Cartesian3(0, 0, z);
|
||||
const frame = headingPitchRollFrame(origin, hpr);
|
||||
const m = Cesium.Transforms.headingPitchRollToFixedFrame(
|
||||
cesiumOrigin,
|
||||
cesiumHpr,
|
||||
);
|
||||
['x', 'y', 'z', 'origin'].forEach((name, c) =>
|
||||
['x', 'y', 'z'].forEach((k, r) =>
|
||||
assert.ok(
|
||||
Object.is(frame[name][k], m[c * 4 + r]),
|
||||
`${name}.${k} at z=${z} ${JSON.stringify(hpr)}: ${frame[name][k]} vs ${m[c * 4 + r]}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
same(
|
||||
headingPitchRollQuaternion(origin, hpr),
|
||||
Cesium.Transforms.headingPitchRollQuaternion(cesiumOrigin, cesiumHpr),
|
||||
`quaternion at z=${z}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the forward axis faces east at heading 0 and south at heading π/2', () => {
|
||||
const origin = fromDegrees(10, 45, 0);
|
||||
const at = (heading) => headingPitchRollFrame(origin, { heading }).x;
|
||||
const east = {
|
||||
x: -Math.sin(toRadians(10)),
|
||||
y: Math.cos(toRadians(10)),
|
||||
z: 0,
|
||||
};
|
||||
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
assert.ok(Math.abs(dot(at(0), east) - 1) < 1e-12);
|
||||
assert.ok(Math.abs(dot(at(Math.PI / 2), east)) < 1e-12);
|
||||
assert.ok(at(Math.PI / 2).z < 0, 'heading π/2 turns towards the equator');
|
||||
});
|
||||
|
||||
test('an axis with no direction throws', () => {
|
||||
assert.throws(() => fromAxisAngle({ x: 0, y: 0, z: 0 }, 1), RangeError);
|
||||
});
|
||||
Reference in new issue
Block a user