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; } }); });