mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Move the stub's geodesy into a renderer-neutral src/geo
WGS84, ECEF and geodetic conversions, surface normals and east-north-up
frames, as plain {x, y, z} objects, exported as ./geo. A test checks
them against Cesium over random points and the poles. The stub's maths
re-exports them and keeps only the camera angle conventions.
This commit is contained in:
1 parent
fefdf242ee
commit
9f165e68f3
9 files changed
+453
-251
No files matched your search
@@ -6008,6 +6008,12 @@ in Node, plus camera traces recorded from Cesium's own `Camera` into
|
|||||||
checked against `goldens/visual/` on the `/dev/conformance/` page. See
|
checked against `goldens/visual/` on the `/dev/conformance/` page. See
|
||||||
[RENDERER-CONTRACT.md](RENDERER-CONTRACT.md).
|
[RENDERER-CONTRACT.md](RENDERER-CONTRACT.md).
|
||||||
|
|
||||||
|
`src/geo/` (export `./geo`) is the renderer-neutral maths: WGS84, ECEF and
|
||||||
|
geodetic conversions, surface normals and east-north-up frames, as plain
|
||||||
|
`{x, y, z}` objects. It follows Cesium's formulas step for step, and
|
||||||
|
`geo.test.mjs` checks it against Cesium. The stub renderer's maths is built
|
||||||
|
on it.
|
||||||
|
|
||||||
## Operational Notes
|
## Operational Notes
|
||||||
|
|
||||||
- **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND`
|
- **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND`
|
||||||
|
|||||||
@@ -295,6 +295,7 @@
|
|||||||
"./tools/services": "./src/tools/services.js",
|
"./tools/services": "./src/tools/services.js",
|
||||||
"./view": "./src/view/index.js",
|
"./view": "./src/view/index.js",
|
||||||
"./agent/world-packet": "./src/agent/worldPacket.js",
|
"./agent/world-packet": "./src/agent/worldPacket.js",
|
||||||
|
"./geo": "./src/geo/index.js",
|
||||||
"./render/contract": "./src/render/contract/index.js",
|
"./render/contract": "./src/render/contract/index.js",
|
||||||
"./render/stub": "./src/render/stub/index.js",
|
"./render/stub": "./src/render/stub/index.js",
|
||||||
"./render/conformance": "./src/render/conformance/index.js"
|
"./render/conformance": "./src/render/conformance/index.js"
|
||||||
|
|||||||
@@ -5127,6 +5127,16 @@
|
|||||||
"modules": ["src/queries/heights.js"],
|
"modules": ["src/queries/heights.js"],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
|
"geo": {
|
||||||
|
"exports": ["./geo"],
|
||||||
|
"modules": [
|
||||||
|
"src/geo/index.js",
|
||||||
|
"src/geo/vector.js",
|
||||||
|
"src/geo/ellipsoid.js",
|
||||||
|
"src/geo/frames.js"
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
|
},
|
||||||
"render": {
|
"render": {
|
||||||
"exports": ["./render/contract", "./render/stub", "./render/conformance"],
|
"exports": ["./render/contract", "./render/stub", "./render/conformance"],
|
||||||
"modules": [
|
"modules": [
|
||||||
@@ -5140,6 +5150,10 @@
|
|||||||
"src/render/conformance/expect.js",
|
"src/render/conformance/expect.js",
|
||||||
"src/render/conformance/checks/ports.js",
|
"src/render/conformance/checks/ports.js",
|
||||||
"src/render/stub/math.js",
|
"src/render/stub/math.js",
|
||||||
|
"src/geo/index.js",
|
||||||
|
"src/geo/vector.js",
|
||||||
|
"src/geo/ellipsoid.js",
|
||||||
|
"src/geo/frames.js",
|
||||||
"src/render/stub/motion.js",
|
"src/render/stub/motion.js",
|
||||||
"src/render/stub/camera.js",
|
"src/render/stub/camera.js",
|
||||||
"src/render/stub/geodesic.js",
|
"src/render/stub/geodesic.js",
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* @module geo/ellipsoid
|
||||||
|
* @description The WGS84 ellipsoid: geodetic and Earth-centred, Earth-fixed
|
||||||
|
* (ECEF) conversions, surface normals and projection onto the surface. The
|
||||||
|
* formulas follow the default renderer's step for step, so they agree with
|
||||||
|
* it to floating-point noise.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
add,
|
||||||
|
clone,
|
||||||
|
dot,
|
||||||
|
magnitude,
|
||||||
|
normalize,
|
||||||
|
scale,
|
||||||
|
sub,
|
||||||
|
toDegrees,
|
||||||
|
toRadians,
|
||||||
|
vec,
|
||||||
|
} from './vector.js';
|
||||||
|
|
||||||
|
export const WGS84 = Object.freeze({
|
||||||
|
radii: Object.freeze({ x: 6378137.0, y: 6378137.0, z: 6356752.3142451793 }),
|
||||||
|
radiiSquared: Object.freeze({
|
||||||
|
x: 6378137.0 * 6378137.0,
|
||||||
|
y: 6378137.0 * 6378137.0,
|
||||||
|
z: 6356752.3142451793 * 6356752.3142451793,
|
||||||
|
}),
|
||||||
|
oneOverRadii: Object.freeze({
|
||||||
|
x: 1 / 6378137.0,
|
||||||
|
y: 1 / 6378137.0,
|
||||||
|
z: 1 / 6356752.3142451793,
|
||||||
|
}),
|
||||||
|
oneOverRadiiSquared: Object.freeze({
|
||||||
|
x: 1 / (6378137.0 * 6378137.0),
|
||||||
|
y: 1 / (6378137.0 * 6378137.0),
|
||||||
|
z: 1 / (6356752.3142451793 * 6356752.3142451793),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const EPSILON12 = 1e-12;
|
||||||
|
const CENTER_TOLERANCE_SQUARED = 0.1;
|
||||||
|
|
||||||
|
export function geodeticSurfaceNormal(p) {
|
||||||
|
const o = WGS84.oneOverRadiiSquared;
|
||||||
|
return normalize(vec(p.x * o.x, p.y * o.y, p.z * o.z));
|
||||||
|
}
|
||||||
|
|
||||||
|
function geodeticSurfaceNormalCartographic(lon, lat) {
|
||||||
|
const cosLat = Math.cos(lat);
|
||||||
|
return normalize(
|
||||||
|
vec(cosLat * Math.cos(lon), cosLat * Math.sin(lon), Math.sin(lat)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Geodetic radians and metres to ECEF. */
|
||||||
|
export function fromRadians(lon, lat, height = 0) {
|
||||||
|
const n = geodeticSurfaceNormalCartographic(lon, lat);
|
||||||
|
const r = WGS84.radiiSquared;
|
||||||
|
let k = vec(r.x * n.x, r.y * n.y, r.z * n.z);
|
||||||
|
const gamma = Math.sqrt(dot(n, k));
|
||||||
|
k = scale(k, 1 / gamma);
|
||||||
|
return add(k, scale(n, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Geodetic degrees and metres to ECEF. */
|
||||||
|
export const fromDegrees = (lon, lat, height = 0) =>
|
||||||
|
fromRadians(toRadians(lon), toRadians(lat), height);
|
||||||
|
|
||||||
|
/** A position in either contract form, as ECEF. */
|
||||||
|
export function toEcef(position) {
|
||||||
|
if ('lon' in position && 'lat' in position)
|
||||||
|
return fromDegrees(position.lon, position.lat, position.height ?? 0);
|
||||||
|
return clone(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The point on the ellipsoid along the geodetic normal, or null at the centre. */
|
||||||
|
export function scaleToGeodeticSurface(p) {
|
||||||
|
const { x: positionX, y: positionY, z: positionZ } = p;
|
||||||
|
const oneOverRadiiX = WGS84.oneOverRadii.x;
|
||||||
|
const oneOverRadiiY = WGS84.oneOverRadii.y;
|
||||||
|
const oneOverRadiiZ = WGS84.oneOverRadii.z;
|
||||||
|
const x2 = positionX * positionX * oneOverRadiiX * oneOverRadiiX;
|
||||||
|
const y2 = positionY * positionY * oneOverRadiiY * oneOverRadiiY;
|
||||||
|
const z2 = positionZ * positionZ * oneOverRadiiZ * oneOverRadiiZ;
|
||||||
|
const squaredNorm = x2 + y2 + z2;
|
||||||
|
const ratio = Math.sqrt(1.0 / squaredNorm);
|
||||||
|
const intersection = scale(p, ratio);
|
||||||
|
if (squaredNorm < CENTER_TOLERANCE_SQUARED)
|
||||||
|
return Number.isFinite(ratio) ? intersection : null;
|
||||||
|
|
||||||
|
const oneOverRadiiSquaredX = WGS84.oneOverRadiiSquared.x;
|
||||||
|
const oneOverRadiiSquaredY = WGS84.oneOverRadiiSquared.y;
|
||||||
|
const oneOverRadiiSquaredZ = WGS84.oneOverRadiiSquared.z;
|
||||||
|
const gradient = vec(
|
||||||
|
intersection.x * oneOverRadiiSquaredX * 2.0,
|
||||||
|
intersection.y * oneOverRadiiSquaredY * 2.0,
|
||||||
|
intersection.z * oneOverRadiiSquaredZ * 2.0,
|
||||||
|
);
|
||||||
|
let lambda = ((1.0 - ratio) * magnitude(p)) / (0.5 * magnitude(gradient));
|
||||||
|
let correction = 0.0;
|
||||||
|
let func, denominator, xMultiplier, yMultiplier, zMultiplier;
|
||||||
|
let xMultiplier2, yMultiplier2, zMultiplier2;
|
||||||
|
let xMultiplier3, yMultiplier3, zMultiplier3;
|
||||||
|
do {
|
||||||
|
lambda -= correction;
|
||||||
|
xMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredX);
|
||||||
|
yMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredY);
|
||||||
|
zMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredZ);
|
||||||
|
xMultiplier2 = xMultiplier * xMultiplier;
|
||||||
|
yMultiplier2 = yMultiplier * yMultiplier;
|
||||||
|
zMultiplier2 = zMultiplier * zMultiplier;
|
||||||
|
xMultiplier3 = xMultiplier2 * xMultiplier;
|
||||||
|
yMultiplier3 = yMultiplier2 * yMultiplier;
|
||||||
|
zMultiplier3 = zMultiplier2 * zMultiplier;
|
||||||
|
func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0;
|
||||||
|
denominator =
|
||||||
|
x2 * xMultiplier3 * oneOverRadiiSquaredX +
|
||||||
|
y2 * yMultiplier3 * oneOverRadiiSquaredY +
|
||||||
|
z2 * zMultiplier3 * oneOverRadiiSquaredZ;
|
||||||
|
const derivative = -2.0 * denominator;
|
||||||
|
correction = func / derivative;
|
||||||
|
} while (Math.abs(func) > EPSILON12);
|
||||||
|
return vec(
|
||||||
|
positionX * xMultiplier,
|
||||||
|
positionY * yMultiplier,
|
||||||
|
positionZ * zMultiplier,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ECEF to geodetic `{lon, lat}` radians and `height` metres, or null at the centre. */
|
||||||
|
export function toCartographicRadians(p) {
|
||||||
|
const surface = scaleToGeodeticSurface(p);
|
||||||
|
if (!surface) return null;
|
||||||
|
const n = geodeticSurfaceNormal(surface);
|
||||||
|
const h = sub(p, surface);
|
||||||
|
return {
|
||||||
|
lon: Math.atan2(n.y, n.x),
|
||||||
|
lat: Math.asin(n.z),
|
||||||
|
height: Math.sign(dot(h, p)) * magnitude(h),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ECEF to `{lon, lat}` degrees and `height` metres. */
|
||||||
|
export function toLonLatH(p) {
|
||||||
|
const c = toCartographicRadians(p);
|
||||||
|
if (!c) return null;
|
||||||
|
return { lon: toDegrees(c.lon), lat: toDegrees(c.lat), height: c.height };
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* @module geo/frames
|
||||||
|
* @description Rigid frames (unit axes and an origin, local to ECEF), the
|
||||||
|
* east-north-up frame at a point, and rotation about an axis.
|
||||||
|
*/
|
||||||
|
import { geodeticSurfaceNormal } from './ellipsoid.js';
|
||||||
|
import {
|
||||||
|
EPSILON14,
|
||||||
|
add,
|
||||||
|
clone,
|
||||||
|
cross,
|
||||||
|
dot,
|
||||||
|
equalsEpsilon,
|
||||||
|
normalize,
|
||||||
|
sub,
|
||||||
|
vec,
|
||||||
|
} from './vector.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rigid frame: unit axes `x`, `y`, `z` (columns) and an `origin`, mapping
|
||||||
|
* local coordinates to ECEF.
|
||||||
|
* @typedef {{x: object, y: object, z: object, origin: object}} Frame
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const IDENTITY = Object.freeze({
|
||||||
|
x: Object.freeze(vec(1, 0, 0)),
|
||||||
|
y: Object.freeze(vec(0, 1, 0)),
|
||||||
|
z: Object.freeze(vec(0, 0, 1)),
|
||||||
|
origin: Object.freeze(vec(0, 0, 0)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isIdentity = (frame) =>
|
||||||
|
frame === IDENTITY ||
|
||||||
|
(frame.x.x === 1 &&
|
||||||
|
frame.y.y === 1 &&
|
||||||
|
frame.z.z === 1 &&
|
||||||
|
frame.x.y === 0 &&
|
||||||
|
frame.x.z === 0 &&
|
||||||
|
frame.y.x === 0 &&
|
||||||
|
frame.y.z === 0 &&
|
||||||
|
frame.z.x === 0 &&
|
||||||
|
frame.z.y === 0 &&
|
||||||
|
frame.origin.x === 0 &&
|
||||||
|
frame.origin.y === 0 &&
|
||||||
|
frame.origin.z === 0);
|
||||||
|
|
||||||
|
/** East-north-up at `origin`. */
|
||||||
|
export function eastNorthUp(origin) {
|
||||||
|
if (
|
||||||
|
equalsEpsilon(origin.x, 0, EPSILON14) &&
|
||||||
|
equalsEpsilon(origin.y, 0, EPSILON14)
|
||||||
|
) {
|
||||||
|
// At a pole: east is +y, north points away from the pole.
|
||||||
|
const sign = Math.sign(origin.z) || 1;
|
||||||
|
return {
|
||||||
|
x: vec(0, 1, 0),
|
||||||
|
y: vec(-sign, 0, 0),
|
||||||
|
z: vec(0, 0, sign),
|
||||||
|
origin: clone(origin),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const up = geodeticSurfaceNormal(origin);
|
||||||
|
const east = normalize(vec(-origin.y, origin.x, 0));
|
||||||
|
const north = cross(up, east);
|
||||||
|
return { x: east, y: north, z: up, origin: clone(origin) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const frameVector = (frame, v) =>
|
||||||
|
vec(
|
||||||
|
frame.x.x * v.x + frame.y.x * v.y + frame.z.x * v.z,
|
||||||
|
frame.x.y * v.x + frame.y.y * v.y + frame.z.y * v.z,
|
||||||
|
frame.x.z * v.x + frame.y.z * v.y + frame.z.z * v.z,
|
||||||
|
);
|
||||||
|
export const framePoint = (frame, p) =>
|
||||||
|
add(frameVector(frame, p), frame.origin);
|
||||||
|
export const inverseFrameVector = (frame, v) =>
|
||||||
|
vec(dot(frame.x, v), dot(frame.y, v), dot(frame.z, v));
|
||||||
|
export const inverseFramePoint = (frame, p) =>
|
||||||
|
inverseFrameVector(frame, sub(p, frame.origin));
|
||||||
|
|
||||||
|
/** Rotate `v` about unit `axis` by `angle` (right-handed). */
|
||||||
|
export function rotate(v, axis, angle) {
|
||||||
|
const c = Math.cos(angle);
|
||||||
|
const s = Math.sin(angle);
|
||||||
|
const d = dot(axis, v);
|
||||||
|
const k = cross(axis, v);
|
||||||
|
return vec(
|
||||||
|
v.x * c + k.x * s + axis.x * d * (1 - c),
|
||||||
|
v.y * c + k.y * s + axis.y * d * (1 - c),
|
||||||
|
v.z * c + k.z * s + axis.z * d * (1 - c),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
eastNorthUp,
|
||||||
|
fromDegrees,
|
||||||
|
geodeticSurfaceNormal,
|
||||||
|
scaleToGeodeticSurface,
|
||||||
|
toCartographicRadians,
|
||||||
|
toLonLatH,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
|
/** A seeded generator, so a failure names a reproducible point. */
|
||||||
|
function random(seed) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (s * 1664525 + 1013904223) >>> 0;
|
||||||
|
return s / 2 ** 32;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function points(count = 500) {
|
||||||
|
const next = random(20261105);
|
||||||
|
const out = [
|
||||||
|
{ lon: 0, lat: 90, height: 0 },
|
||||||
|
{ lon: 0, lat: -90, height: 1000 },
|
||||||
|
{ lon: 180, lat: 0, height: -400 },
|
||||||
|
{ lon: -97.7431, lat: 30.2672, height: 150 },
|
||||||
|
];
|
||||||
|
for (let i = 0; i < count; i++)
|
||||||
|
out.push({
|
||||||
|
lon: next() * 360 - 180,
|
||||||
|
lat: next() * 180 - 90,
|
||||||
|
height: next() * 4e7 - 1e4,
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = (actual, expected, tolerance, message) =>
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(actual - expected) <= tolerance,
|
||||||
|
`${message}: ${actual} vs ${expected} (± ${tolerance})`,
|
||||||
|
);
|
||||||
|
const closeVec = (a, b, tolerance, message) => {
|
||||||
|
close(a.x, b.x, tolerance, `${message} x`);
|
||||||
|
close(a.y, b.y, tolerance, `${message} y`);
|
||||||
|
close(a.z, b.z, tolerance, `${message} z`);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('geodetic to ECEF matches the default renderer', () => {
|
||||||
|
for (const p of points()) {
|
||||||
|
const ours = fromDegrees(p.lon, p.lat, p.height);
|
||||||
|
const theirs = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height);
|
||||||
|
closeVec(ours, theirs, 1e-6, `${p.lon},${p.lat},${p.height}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ECEF to geodetic matches the default renderer', () => {
|
||||||
|
for (const p of points()) {
|
||||||
|
const ecef = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height);
|
||||||
|
const ours = toCartographicRadians(ecef);
|
||||||
|
const theirs = Cesium.Cartographic.fromCartesian(ecef);
|
||||||
|
const where = `${p.lon},${p.lat},${p.height}`;
|
||||||
|
// Longitude is undefined at a pole.
|
||||||
|
if (Math.abs(p.lat) < 89.999)
|
||||||
|
close(ours.lon, theirs.longitude, 1e-12, `${where} lon`);
|
||||||
|
close(ours.lat, theirs.latitude, 1e-12, `${where} lat`);
|
||||||
|
close(ours.height, theirs.height, 1e-6, `${where} height`);
|
||||||
|
}
|
||||||
|
assert.equal(toCartographicRadians({ x: 0, y: 0, z: 0 }), null);
|
||||||
|
assert.equal(
|
||||||
|
Cesium.Cartographic.fromCartesian(Cesium.Cartesian3.ZERO),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const deg = toLonLatH(fromDegrees(-97.7431, 30.2672, 150));
|
||||||
|
close(deg.lon, -97.7431, 1e-9, 'degrees lon');
|
||||||
|
close(deg.lat, 30.2672, 1e-9, 'degrees lat');
|
||||||
|
close(deg.height, 150, 1e-6, 'degrees height');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('surface normals and projection match the default renderer', () => {
|
||||||
|
for (const p of points()) {
|
||||||
|
const ecef = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height);
|
||||||
|
const where = `${p.lon},${p.lat},${p.height}`;
|
||||||
|
closeVec(
|
||||||
|
geodeticSurfaceNormal(ecef),
|
||||||
|
Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(ecef),
|
||||||
|
1e-15,
|
||||||
|
`${where} normal`,
|
||||||
|
);
|
||||||
|
closeVec(
|
||||||
|
scaleToGeodeticSurface(ecef),
|
||||||
|
Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(ecef),
|
||||||
|
1e-6,
|
||||||
|
`${where} surface`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('east-north-up frames match the default renderer', () => {
|
||||||
|
const column = (m, i) => ({ x: m[i * 4], y: m[i * 4 + 1], z: m[i * 4 + 2] });
|
||||||
|
const origins = [
|
||||||
|
...points(200).map((p) =>
|
||||||
|
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height),
|
||||||
|
),
|
||||||
|
// Exactly on the axis, where east is chosen by convention.
|
||||||
|
new Cesium.Cartesian3(0, 0, 6356752.3142451793),
|
||||||
|
new Cesium.Cartesian3(0, 0, -7000000),
|
||||||
|
];
|
||||||
|
for (const origin of origins) {
|
||||||
|
const m = Cesium.Matrix4.toArray(
|
||||||
|
Cesium.Transforms.eastNorthUpToFixedFrame(origin),
|
||||||
|
);
|
||||||
|
const ours = eastNorthUp(origin);
|
||||||
|
const where = `${origin.x},${origin.y},${origin.z}`;
|
||||||
|
closeVec(ours.x, column(m, 0), 1e-12, `${where} east`);
|
||||||
|
closeVec(ours.y, column(m, 1), 1e-12, `${where} north`);
|
||||||
|
closeVec(ours.z, column(m, 2), 1e-12, `${where} up`);
|
||||||
|
closeVec(ours.origin, column(m, 3), 0, `${where} origin`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @module geo
|
||||||
|
* @description Renderer-neutral geodesy and vector maths: WGS84, ECEF and
|
||||||
|
* 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';
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* @module geo/vector
|
||||||
|
* @description Three-vectors as plain `{x, y, z}` objects, and the angle
|
||||||
|
* helpers the rest of `geo` uses. No engine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TWO_PI = Math.PI * 2;
|
||||||
|
export const PI_OVER_TWO = Math.PI / 2;
|
||||||
|
export const EPSILON14 = 1e-14;
|
||||||
|
|
||||||
|
export const vec = (x = 0, y = 0, z = 0) => ({ x, y, z });
|
||||||
|
export const clone = (a) => ({ x: a.x, y: a.y, z: a.z });
|
||||||
|
export const add = (a, b) => vec(a.x + b.x, a.y + b.y, a.z + b.z);
|
||||||
|
export const sub = (a, b) => vec(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||||
|
export const scale = (a, s) => vec(a.x * s, a.y * s, a.z * s);
|
||||||
|
export const negate = (a) => vec(-a.x, -a.y, -a.z);
|
||||||
|
export const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
||||||
|
export const cross = (a, b) =>
|
||||||
|
vec(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
|
||||||
|
export const magnitude = (a) => Math.sqrt(dot(a, a));
|
||||||
|
export const distance = (a, b) => magnitude(sub(a, b));
|
||||||
|
export function normalize(a) {
|
||||||
|
const m = magnitude(a);
|
||||||
|
return vec(a.x / m, a.y / m, a.z / m);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toRadians = (degrees) => (degrees * Math.PI) / 180;
|
||||||
|
export const toDegrees = (radians) => (radians * 180) / Math.PI;
|
||||||
|
export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||||
|
export const acosClamped = (v) => Math.acos(clamp(v, -1, 1));
|
||||||
|
export const equalsEpsilon = (a, b, eps) => Math.abs(a - b) <= eps;
|
||||||
|
|
||||||
|
/** An angle in [0, 2π). */
|
||||||
|
export function zeroToTwoPi(angle) {
|
||||||
|
if (angle >= 0 && angle <= TWO_PI) return angle;
|
||||||
|
const mod = ((angle % TWO_PI) + TWO_PI) % TWO_PI;
|
||||||
|
if (Math.abs(mod) < EPSILON14 && Math.abs(angle) > EPSILON14) return TWO_PI;
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An angle in [-π, π]. */
|
||||||
|
export function negativePiToPi(angle) {
|
||||||
|
if (angle >= -Math.PI && angle <= Math.PI) return angle;
|
||||||
|
return zeroToTwoPi(angle + Math.PI) - Math.PI;
|
||||||
|
}
|
||||||
+16
-251
@@ -1,264 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* @module render/stub/math
|
* @module render/stub/math
|
||||||
* @description The geodesy and vector maths the stub renderer poses its
|
* @description The maths the stub renderer poses its camera with: the shared
|
||||||
* camera with: WGS84, east-north-up frames, rigid transforms and the camera
|
* geodesy (`src/geo`) and the camera angle conventions of the contract
|
||||||
* angle conventions of the contract (heading clockwise from north, pitch
|
* (heading clockwise from north, pitch negative looking down).
|
||||||
* negative looking down). Plain objects and numbers, no engine.
|
|
||||||
*
|
*
|
||||||
* The formulas follow the default renderer's, step for step, so the stub's
|
* The formulas follow the default renderer's, step for step, so the stub's
|
||||||
* poses match its reference traces to floating-point noise.
|
* poses match its reference traces to floating-point noise.
|
||||||
*/
|
*/
|
||||||
|
import {
|
||||||
|
PI_OVER_TWO,
|
||||||
|
TWO_PI,
|
||||||
|
acosClamped,
|
||||||
|
clamp,
|
||||||
|
equalsEpsilon,
|
||||||
|
negate,
|
||||||
|
rotate,
|
||||||
|
scale,
|
||||||
|
vec,
|
||||||
|
zeroToTwoPi,
|
||||||
|
} from '../../geo/index.js';
|
||||||
|
|
||||||
export const WGS84 = Object.freeze({
|
export * from '../../geo/index.js';
|
||||||
radii: Object.freeze({ x: 6378137.0, y: 6378137.0, z: 6356752.3142451793 }),
|
|
||||||
radiiSquared: Object.freeze({
|
|
||||||
x: 6378137.0 * 6378137.0,
|
|
||||||
y: 6378137.0 * 6378137.0,
|
|
||||||
z: 6356752.3142451793 * 6356752.3142451793,
|
|
||||||
}),
|
|
||||||
oneOverRadii: Object.freeze({
|
|
||||||
x: 1 / 6378137.0,
|
|
||||||
y: 1 / 6378137.0,
|
|
||||||
z: 1 / 6356752.3142451793,
|
|
||||||
}),
|
|
||||||
oneOverRadiiSquared: Object.freeze({
|
|
||||||
x: 1 / (6378137.0 * 6378137.0),
|
|
||||||
y: 1 / (6378137.0 * 6378137.0),
|
|
||||||
z: 1 / (6356752.3142451793 * 6356752.3142451793),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const TWO_PI = Math.PI * 2;
|
|
||||||
export const PI_OVER_TWO = Math.PI / 2;
|
|
||||||
const EPSILON3 = 1e-3;
|
const EPSILON3 = 1e-3;
|
||||||
const EPSILON10 = 1e-10;
|
const EPSILON10 = 1e-10;
|
||||||
const EPSILON12 = 1e-12;
|
|
||||||
const EPSILON14 = 1e-14;
|
|
||||||
const CENTER_TOLERANCE_SQUARED = 0.1;
|
|
||||||
|
|
||||||
export const vec = (x = 0, y = 0, z = 0) => ({ x, y, z });
|
|
||||||
export const clone = (a) => ({ x: a.x, y: a.y, z: a.z });
|
|
||||||
export const add = (a, b) => vec(a.x + b.x, a.y + b.y, a.z + b.z);
|
|
||||||
export const sub = (a, b) => vec(a.x - b.x, a.y - b.y, a.z - b.z);
|
|
||||||
export const scale = (a, s) => vec(a.x * s, a.y * s, a.z * s);
|
|
||||||
export const negate = (a) => vec(-a.x, -a.y, -a.z);
|
|
||||||
export const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
|
||||||
export const cross = (a, b) =>
|
|
||||||
vec(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
|
|
||||||
export const magnitude = (a) => Math.sqrt(dot(a, a));
|
|
||||||
export const distance = (a, b) => magnitude(sub(a, b));
|
|
||||||
export function normalize(a) {
|
|
||||||
const m = magnitude(a);
|
|
||||||
return vec(a.x / m, a.y / m, a.z / m);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toRadians = (degrees) => (degrees * Math.PI) / 180;
|
|
||||||
export const toDegrees = (radians) => (radians * 180) / Math.PI;
|
|
||||||
export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
||||||
export const acosClamped = (v) => Math.acos(clamp(v, -1, 1));
|
|
||||||
export const equalsEpsilon = (a, b, eps) => Math.abs(a - b) <= eps;
|
|
||||||
|
|
||||||
/** An angle in [0, 2π). */
|
|
||||||
export function zeroToTwoPi(angle) {
|
|
||||||
if (angle >= 0 && angle <= TWO_PI) return angle;
|
|
||||||
const mod = ((angle % TWO_PI) + TWO_PI) % TWO_PI;
|
|
||||||
if (Math.abs(mod) < EPSILON14 && Math.abs(angle) > EPSILON14) return TWO_PI;
|
|
||||||
return mod;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An angle in [-π, π]. */
|
|
||||||
export function negativePiToPi(angle) {
|
|
||||||
if (angle >= -Math.PI && angle <= Math.PI) return angle;
|
|
||||||
return zeroToTwoPi(angle + Math.PI) - Math.PI;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Geodesy -----------------------------------------------------------------
|
|
||||||
|
|
||||||
export function geodeticSurfaceNormal(p) {
|
|
||||||
const o = WGS84.oneOverRadiiSquared;
|
|
||||||
return normalize(vec(p.x * o.x, p.y * o.y, p.z * o.z));
|
|
||||||
}
|
|
||||||
|
|
||||||
function geodeticSurfaceNormalCartographic(lon, lat) {
|
|
||||||
const cosLat = Math.cos(lat);
|
|
||||||
return normalize(
|
|
||||||
vec(cosLat * Math.cos(lon), cosLat * Math.sin(lon), Math.sin(lat)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Geodetic radians and metres to ECEF. */
|
|
||||||
export function fromRadians(lon, lat, height = 0) {
|
|
||||||
const n = geodeticSurfaceNormalCartographic(lon, lat);
|
|
||||||
const r = WGS84.radiiSquared;
|
|
||||||
let k = vec(r.x * n.x, r.y * n.y, r.z * n.z);
|
|
||||||
const gamma = Math.sqrt(dot(n, k));
|
|
||||||
k = scale(k, 1 / gamma);
|
|
||||||
return add(k, scale(n, height));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Geodetic degrees and metres to ECEF. */
|
|
||||||
export const fromDegrees = (lon, lat, height = 0) =>
|
|
||||||
fromRadians(toRadians(lon), toRadians(lat), height);
|
|
||||||
|
|
||||||
/** A position in either contract form, as ECEF. */
|
|
||||||
export function toEcef(position) {
|
|
||||||
if ('lon' in position && 'lat' in position)
|
|
||||||
return fromDegrees(position.lon, position.lat, position.height ?? 0);
|
|
||||||
return clone(position);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The point on the ellipsoid along the geodetic normal, or null at the centre. */
|
|
||||||
export function scaleToGeodeticSurface(p) {
|
|
||||||
const { x: positionX, y: positionY, z: positionZ } = p;
|
|
||||||
const oneOverRadiiX = WGS84.oneOverRadii.x;
|
|
||||||
const oneOverRadiiY = WGS84.oneOverRadii.y;
|
|
||||||
const oneOverRadiiZ = WGS84.oneOverRadii.z;
|
|
||||||
const x2 = positionX * positionX * oneOverRadiiX * oneOverRadiiX;
|
|
||||||
const y2 = positionY * positionY * oneOverRadiiY * oneOverRadiiY;
|
|
||||||
const z2 = positionZ * positionZ * oneOverRadiiZ * oneOverRadiiZ;
|
|
||||||
const squaredNorm = x2 + y2 + z2;
|
|
||||||
const ratio = Math.sqrt(1.0 / squaredNorm);
|
|
||||||
const intersection = scale(p, ratio);
|
|
||||||
if (squaredNorm < CENTER_TOLERANCE_SQUARED)
|
|
||||||
return Number.isFinite(ratio) ? intersection : null;
|
|
||||||
|
|
||||||
const oneOverRadiiSquaredX = WGS84.oneOverRadiiSquared.x;
|
|
||||||
const oneOverRadiiSquaredY = WGS84.oneOverRadiiSquared.y;
|
|
||||||
const oneOverRadiiSquaredZ = WGS84.oneOverRadiiSquared.z;
|
|
||||||
const gradient = vec(
|
|
||||||
intersection.x * oneOverRadiiSquaredX * 2.0,
|
|
||||||
intersection.y * oneOverRadiiSquaredY * 2.0,
|
|
||||||
intersection.z * oneOverRadiiSquaredZ * 2.0,
|
|
||||||
);
|
|
||||||
let lambda = ((1.0 - ratio) * magnitude(p)) / (0.5 * magnitude(gradient));
|
|
||||||
let correction = 0.0;
|
|
||||||
let func, denominator, xMultiplier, yMultiplier, zMultiplier;
|
|
||||||
let xMultiplier2, yMultiplier2, zMultiplier2;
|
|
||||||
let xMultiplier3, yMultiplier3, zMultiplier3;
|
|
||||||
do {
|
|
||||||
lambda -= correction;
|
|
||||||
xMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredX);
|
|
||||||
yMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredY);
|
|
||||||
zMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredZ);
|
|
||||||
xMultiplier2 = xMultiplier * xMultiplier;
|
|
||||||
yMultiplier2 = yMultiplier * yMultiplier;
|
|
||||||
zMultiplier2 = zMultiplier * zMultiplier;
|
|
||||||
xMultiplier3 = xMultiplier2 * xMultiplier;
|
|
||||||
yMultiplier3 = yMultiplier2 * yMultiplier;
|
|
||||||
zMultiplier3 = zMultiplier2 * zMultiplier;
|
|
||||||
func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0;
|
|
||||||
denominator =
|
|
||||||
x2 * xMultiplier3 * oneOverRadiiSquaredX +
|
|
||||||
y2 * yMultiplier3 * oneOverRadiiSquaredY +
|
|
||||||
z2 * zMultiplier3 * oneOverRadiiSquaredZ;
|
|
||||||
const derivative = -2.0 * denominator;
|
|
||||||
correction = func / derivative;
|
|
||||||
} while (Math.abs(func) > EPSILON12);
|
|
||||||
return vec(
|
|
||||||
positionX * xMultiplier,
|
|
||||||
positionY * yMultiplier,
|
|
||||||
positionZ * zMultiplier,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ECEF to geodetic `{lon, lat}` radians and `height` metres, or null at the centre. */
|
|
||||||
export function toCartographicRadians(p) {
|
|
||||||
const surface = scaleToGeodeticSurface(p);
|
|
||||||
if (!surface) return null;
|
|
||||||
const n = geodeticSurfaceNormal(surface);
|
|
||||||
const h = sub(p, surface);
|
|
||||||
return {
|
|
||||||
lon: Math.atan2(n.y, n.x),
|
|
||||||
lat: Math.asin(n.z),
|
|
||||||
height: Math.sign(dot(h, p)) * magnitude(h),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ECEF to `{lon, lat}` degrees and `height` metres. */
|
|
||||||
export function toLonLatH(p) {
|
|
||||||
const c = toCartographicRadians(p);
|
|
||||||
if (!c) return null;
|
|
||||||
return { lon: toDegrees(c.lon), lat: toDegrees(c.lat), height: c.height };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Rigid frames ------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A rigid frame: unit axes `x`, `y`, `z` (columns) and an `origin`, mapping
|
|
||||||
* local coordinates to ECEF.
|
|
||||||
* @typedef {{x: object, y: object, z: object, origin: object}} Frame
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const IDENTITY = Object.freeze({
|
|
||||||
x: Object.freeze(vec(1, 0, 0)),
|
|
||||||
y: Object.freeze(vec(0, 1, 0)),
|
|
||||||
z: Object.freeze(vec(0, 0, 1)),
|
|
||||||
origin: Object.freeze(vec(0, 0, 0)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const isIdentity = (frame) =>
|
|
||||||
frame === IDENTITY ||
|
|
||||||
(frame.x.x === 1 &&
|
|
||||||
frame.y.y === 1 &&
|
|
||||||
frame.z.z === 1 &&
|
|
||||||
frame.x.y === 0 &&
|
|
||||||
frame.x.z === 0 &&
|
|
||||||
frame.y.x === 0 &&
|
|
||||||
frame.y.z === 0 &&
|
|
||||||
frame.z.x === 0 &&
|
|
||||||
frame.z.y === 0 &&
|
|
||||||
frame.origin.x === 0 &&
|
|
||||||
frame.origin.y === 0 &&
|
|
||||||
frame.origin.z === 0);
|
|
||||||
|
|
||||||
/** East-north-up at `origin`. */
|
|
||||||
export function eastNorthUp(origin) {
|
|
||||||
if (
|
|
||||||
equalsEpsilon(origin.x, 0, EPSILON14) &&
|
|
||||||
equalsEpsilon(origin.y, 0, EPSILON14)
|
|
||||||
) {
|
|
||||||
// At a pole: east is +y, north points away from the pole.
|
|
||||||
const sign = Math.sign(origin.z) || 1;
|
|
||||||
return {
|
|
||||||
x: vec(0, 1, 0),
|
|
||||||
y: vec(-sign, 0, 0),
|
|
||||||
z: vec(0, 0, sign),
|
|
||||||
origin: clone(origin),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const up = geodeticSurfaceNormal(origin);
|
|
||||||
const east = normalize(vec(-origin.y, origin.x, 0));
|
|
||||||
const north = cross(up, east);
|
|
||||||
return { x: east, y: north, z: up, origin: clone(origin) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const frameVector = (frame, v) =>
|
|
||||||
vec(
|
|
||||||
frame.x.x * v.x + frame.y.x * v.y + frame.z.x * v.z,
|
|
||||||
frame.x.y * v.x + frame.y.y * v.y + frame.z.y * v.z,
|
|
||||||
frame.x.z * v.x + frame.y.z * v.y + frame.z.z * v.z,
|
|
||||||
);
|
|
||||||
export const framePoint = (frame, p) =>
|
|
||||||
add(frameVector(frame, p), frame.origin);
|
|
||||||
export const inverseFrameVector = (frame, v) =>
|
|
||||||
vec(dot(frame.x, v), dot(frame.y, v), dot(frame.z, v));
|
|
||||||
export const inverseFramePoint = (frame, p) =>
|
|
||||||
inverseFrameVector(frame, sub(p, frame.origin));
|
|
||||||
|
|
||||||
// --- Rotations ---------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Rotate `v` about unit `axis` by `angle` (right-handed). */
|
|
||||||
export function rotate(v, axis, angle) {
|
|
||||||
const c = Math.cos(angle);
|
|
||||||
const s = Math.sin(angle);
|
|
||||||
const d = dot(axis, v);
|
|
||||||
const k = cross(axis, v);
|
|
||||||
return vec(
|
|
||||||
v.x * c + k.x * s + axis.x * d * (1 - c),
|
|
||||||
v.y * c + k.y * s + axis.y * d * (1 - c),
|
|
||||||
v.z * c + k.z * s + axis.z * d * (1 - c),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The rotation the contract's heading, pitch and roll describe in a local
|
* The rotation the contract's heading, pitch and roll describe in a local
|
||||||
|
|||||||
Reference in new issue
Block a user