orbit-studio / src /modules /skyFlight.test.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
13.5 kB
import { Cartesian3, Math as CesiumMath, Quaternion } from "@cesium/engine";
import { describe, expect, test } from "vitest";
import { easeFlight, type FlightPath, flightPose, flightPosition, LOCK_ON, newPose, type Pose, poseRotation, TIP_UP, TOUCHDOWN } from "./skyFlight";
const EARTH_RADIUS = 6378137;
const at = (x: number, y: number, z: number, radius: number): Cartesian3 =>
Cartesian3.multiplyByScalar(Cartesian3.normalize(new Cartesian3(x, y, z), new Cartesian3()), radius, new Cartesian3());
const pose = (position: Cartesian3, direction: Cartesian3, up: Cartesian3, fovy = 60): Pose => {
const d = Cartesian3.normalize(direction, new Cartesian3());
const u = Cartesian3.normalize(up, new Cartesian3());
return { position, direction: d, up: u, right: Cartesian3.cross(d, u, new Cartesian3()), fovy };
};
const angleDegrees = (a: Cartesian3, b: Cartesian3): number => CesiumMath.toDegrees(Cartesian3.angleBetween(a, b));
/** How far one attitude has to turn to become another, the short way round. */
function turnBetween(a: Pose, b: Pose): number {
const from = poseRotation(a, new Quaternion());
const to = poseRotation(b, new Quaternion());
const delta = Quaternion.multiply(to, Quaternion.conjugate(from, new Quaternion()), new Quaternion());
return CesiumMath.toDegrees(2 * Math.acos(Math.min(1, Math.abs(delta.w))));
}
/** A camera in orbit over the Gulf of Guinea, looking straight down with north up. */
const orbit = pose(at(1, 0, 0, EARTH_RADIUS + 20_000_000), new Cartesian3(-1, 0, 0), new Cartesian3(0, 0, 1), 36);
// The destination is on the equator a quarter turn east, where the local east,
// north and up axes are these. Written out because every attitude below is one
// of them or a combination, and the tests are unreadable in raw components.
const destination = at(0, 1, 0, EARTH_RADIUS + 2);
const EAST = new Cartesian3(-1, 0, 0);
const NORTH = new Cartesian3(0, 0, 1);
const UP = new Cartesian3(0, 1, 0);
/** Standing there, facing north, 45° above the horizon — the aim the flight ends on. */
const ground = pose(destination, new Cartesian3(0, 1, 1), new Cartesian3(0, 1, -1), 75);
/**
* The same spot and the same facing, tipped all the way down.
*
* `up` is north because that is the continuous limit of tipping the aim above
* down toward the horizon — the relationship `skyBasis` gives between a -90° aim
* and every aim above it, and what makes the last leg a pure change of pitch.
*/
const overGround = pose(destination, Cartesian3.negate(UP, new Cartesian3()), NORTH);
const path: FlightPath = { from: orbit, to: ground, over: overGround };
describe("flightPosition", () => {
const between = (from: Cartesian3, to: Cartesian3, sweep: number, drop = sweep): Cartesian3 => flightPosition(from, to, sweep, drop, new Cartesian3());
test("starts and ends exactly where it was told to", () => {
expect(Cartesian3.distance(between(orbit.position, destination, 0), orbit.position)).toBeLessThan(1e-6);
expect(Cartesian3.distance(between(orbit.position, destination, 1), destination)).toBeLessThan(1e-6);
});
test("follows the great circle rather than the chord", () => {
const total = angleDegrees(orbit.position, destination);
for (const t of [0.25, 0.5, 0.75]) {
expect(angleDegrees(orbit.position, between(orbit.position, destination, t))).toBeCloseTo(total * t, 6);
}
});
test("comes down where it is told to, independently of how far round it has come", () => {
// The two fractions are what let the flight stop over the destination and
// then land on it, so neither may leak into the other.
const overhead = between(orbit.position, destination, 1, 0.4);
expect(angleDegrees(overhead, destination)).toBeCloseTo(0, 9);
const radii = [Cartesian3.magnitude(orbit.position), Cartesian3.magnitude(destination)];
expect(Cartesian3.magnitude(overhead)).toBeCloseTo(radii[0]! + 0.4 * (radii[1]! - radii[0]!), 6);
});
test("never dips below either end, so no arc height is needed to clear the ground", () => {
// The distance from the geocentre is interpolated on its own, so it stays
// inside the interval the two endpoints bound — and both are above ground.
const low = Math.min(Cartesian3.magnitude(orbit.position), Cartesian3.magnitude(destination));
for (let t = 0; t <= 1; t += 0.02) {
expect(Cartesian3.magnitude(between(orbit.position, destination, Math.min(1, t * 1.4), t))).toBeGreaterThanOrEqual(low - 1e-6);
}
});
test("crosses the planet from the antipode without going through it", () => {
// The one input with no unique great circle: the cross product vanishes, and
// taking it as the rotation axis would give a zero-length axis and NaN.
const from = at(1, 0, 0, EARTH_RADIUS + 1_000_000);
const to = at(-1, 0, 0, EARTH_RADIUS + 2);
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
const step = between(from, to, t);
expect(Number.isFinite(step.x) && Number.isFinite(step.y) && Number.isFinite(step.z)).toBe(true);
expect(angleDegrees(from, step)).toBeCloseTo(180 * t, 6);
}
});
test("stays put when both ends are the same place", () => {
const here = at(0.3, -0.9, 0.2, EARTH_RADIUS + 2);
expect(Cartesian3.distance(between(here, here, 0.5), here)).toBeLessThan(1e-6);
});
});
describe("flightPose", () => {
const step = (t: number): Pose => flightPose(path, t, newPose());
/** How far off the centre of the screen the destination sits, in degrees. */
const offCentre = (here: Pose): number => angleDegrees(here.direction, Cartesian3.subtract(destination, here.position, new Cartesian3()));
test("starts and ends on the poses it was given", () => {
for (const [t, end] of [
[0, orbit],
[1, ground],
] as const) {
const here = step(t);
expect(Cartesian3.distance(here.position, end.position)).toBeLessThan(1e-6);
expect(angleDegrees(here.direction, end.direction)).toBeCloseTo(0, 6);
expect(angleDegrees(here.up, end.up)).toBeCloseTo(0, 6);
expect(angleDegrees(here.right, end.right)).toBeCloseTo(0, 6);
expect(here.fovy).toBeCloseTo(end.fovy, 9);
}
});
test("holds the destination at the centre of the screen for the whole descent", () => {
// The point of the three legs. Between the swing finishing and the rise
// starting, the place being flown to is the thing on screen — which is the
// only way the flight can say where it is going while it is still going.
for (let t = LOCK_ON; t <= 1 - TIP_UP; t += 0.02) {
expect(offCentre(step(t)), `t=${t.toFixed(2)}`).toBeLessThan(1e-4);
}
});
test("brings the destination in without overshooting it", () => {
// Monotone through the swing: the view converges on the destination rather
// than sweeping past it and coming back. It starts well off centre — only
// 13° here, because a quarter of the globe away subtends little from 20,000
// km up, and that is exactly the confusion the swing exists to clear up.
let previous = offCentre(step(0));
expect(previous).toBeGreaterThan(10);
for (let t = 0.01; t <= LOCK_ON; t += 0.01) {
const off = offCentre(step(t));
// To within a tenth of a degree: the destination is a moving target while
// the camera is travelling, so the swing trails it by a hair as it closes.
expect(off, `t=${t.toFixed(2)}`).toBeLessThanOrEqual(previous + 0.1);
previous = Math.min(previous, off);
}
expect(previous).toBeLessThan(0.1);
});
test("comes to rest directly over the destination as the rise begins", () => {
// Arriving vertically rather than along the swoop's own tangent. Without it
// the camera reaches the ground travelling sideways and the aim following it
// whips through the last few metres.
const overhead = step(1 - TIP_UP);
expect(angleDegrees(overhead.position, destination)).toBeCloseTo(0, 9);
expect(Cartesian3.magnitude(overhead.position)).toBeGreaterThan(Cartesian3.magnitude(destination));
});
test("is standing on the destination before the rise finishes", () => {
// Land, then look up — two movements rather than one blurred diagonal.
expect(Cartesian3.distance(step(TOUCHDOWN).position, destination)).toBeLessThan(1e-6);
expect(angleDegrees(step(TOUCHDOWN).direction, ground.direction)).toBeGreaterThan(30);
});
test("rises through the horizon rather than around it", () => {
// The last leg is a change of pitch and nothing else, so the view axis stays
// in the vertical plane it spent the descent facing: no drift in azimuth, and
// no roll. East is the normal of that plane for this destination.
for (let t = 1 - TIP_UP; t <= 1; t += 0.02) {
const here = step(t);
expect(Cartesian3.dot(here.direction, EAST), `t=${t.toFixed(2)}`).toBeCloseTo(0, 6);
expect(Cartesian3.dot(here.up, EAST), `t=${t.toFixed(2)}`).toBeCloseTo(0, 6);
}
});
test("hands the camera an orthonormal right-handed basis at every step", () => {
// The whole reason the blend goes through a quaternion. Three separately
// interpolated vectors are not orthonormal in between, and Cesium's camera
// takes what it is given: a sheared basis is a sheared picture.
for (let t = 0; t <= 1; t += 0.02) {
const { direction, up, right } = step(t);
const label = `t=${t.toFixed(2)}`;
expect(Cartesian3.magnitude(direction), label).toBeCloseTo(1, 9);
expect(Cartesian3.magnitude(up), label).toBeCloseTo(1, 9);
expect(Cartesian3.magnitude(right), label).toBeCloseTo(1, 9);
expect(Cartesian3.dot(direction, up), label).toBeCloseTo(0, 9);
expect(Cartesian3.dot(direction, right), label).toBeCloseTo(0, 9);
expect(Cartesian3.dot(up, right), label).toBeCloseTo(0, 9);
const cross = Cartesian3.cross(direction, up, new Cartesian3());
expect(angleDegrees(cross, right), label).toBeCloseTo(0, 6);
}
});
test("takes the short way round rather than spinning to the same attitude", () => {
// `Quaternion.slerp` negates one end when the two point away from each other.
// Without that the blend arrives at the right attitude by turning most of a
// revolution to get there — which a smoothness check cannot catch, because a
// long way round is perfectly smooth. Adding up how far the view actually
// turns can: a spin would cost another 180° at least.
let travelled = 0;
let previous = step(0);
for (let t = 0.005; t <= 1; t += 0.005) {
const next = step(t);
travelled += turnBetween(previous, next);
previous = next;
}
// 90° from the globe attitude to straight down at the destination, then 135°
// of rise, plus the slack the descent's own tracking adds on top.
expect(travelled).toBeLessThan(turnBetween(orbit, overGround) + turnBetween(overGround, ground) + 45);
});
test("moves smoothly, with no jump at either join between legs", () => {
const dt = 0.002;
let previous = step(0);
for (let t = dt; t <= 1; t += dt) {
const next = step(t);
const label = `t=${t.toFixed(3)}`;
// Three degrees per two thousandths is roughly triple the fastest the view
// legitimately turns — which is mid-descent, where the camera really is
// racing a quarter of the way round the planet with the destination pinned.
// A join that failed to meet would show up here as tens of degrees.
expect(angleDegrees(previous.direction, next.direction), label).toBeLessThan(3);
expect(angleDegrees(previous.up, next.up), label).toBeLessThan(3);
previous = next;
}
});
test("widens the field of view on the way in, and has finished by the landing", () => {
expect(step(0.4).fovy).toBeGreaterThan(orbit.fovy);
expect(step(0.4).fovy).toBeLessThan(ground.fovy);
// Steady through the rise: a zoom laid over the sweep would read as a second,
// unrelated movement.
expect(step(TOUCHDOWN).fovy).toBeCloseTo(ground.fovy, 9);
});
test("stays defined once the camera is standing on the point it is aiming at", () => {
// Past touchdown there is no line of sight left to aim along — a look-at
// built from a cross product would be dividing by zero here every frame.
for (let t = TOUCHDOWN; t <= 1; t += 0.01) {
const { direction, up, right } = step(t);
const finite = (v: Cartesian3): boolean => Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z);
expect(finite(direction) && finite(up) && finite(right), `t=${t.toFixed(2)}`).toBe(true);
}
});
});
describe("poseRotation", () => {
test("is a unit rotation, which is what the blend and the conversion back need", () => {
for (const p of [orbit, ground, overGround]) {
expect(Quaternion.magnitude(poseRotation(p, new Quaternion()))).toBeCloseTo(1, 9);
}
});
});
describe("easeFlight", () => {
test("pins both ends, so the flight lands exactly on its destination", () => {
expect(easeFlight(0)).toBe(0);
expect(easeFlight(1)).toBe(1);
});
test("clamps, because that is also how each leg is scheduled off the shared clock", () => {
expect(easeFlight(-0.5)).toBe(0);
expect(easeFlight(1.7)).toBe(1);
});
test("never goes backwards", () => {
let previous = 0;
for (let t = 0; t <= 1; t += 0.01) {
const eased = easeFlight(t);
expect(eased).toBeGreaterThanOrEqual(previous);
previous = eased;
}
});
});