import { Cartesian3, Math as CesiumMath, PerspectiveFrustum, type Scene, SceneMode } from "@cesium/engine"; import { afterEach, describe, expect, test, vi } from "vitest"; import { type Aim, defaultAzimuth, DEFAULT_FOVY, DEFAULT_PITCH, fovFromFovy, fovyFromFov, isPlausibleGroundHeight, MAX_EYE_HEIGHT, MAX_FOVY, MIN_EYE_HEIGHT, MIN_FOVY, skyBasis, SkyView, } from "./SkyView"; const aim = (azimuth: number, pitch: number, roll = 0): Aim => ({ azimuth, pitch, roll }); const angleBetween = (a: Cartesian3, b: Cartesian3): number => CesiumMath.toDegrees(Cartesian3.angleBetween(a, b)); describe("skyBasis", () => { // Every case the camera has to survive, including the one `camera.setView` // cannot express: straight up, where its heading formula switches branch. const aims = [aim(0, 0), aim(90, 45), aim(180, -30), aim(270, 87.3), aim(0, 87.5), aim(45, 90), aim(300, 60, 35), aim(0, 90, 180)]; test("is orthonormal and right-handed at every aim", () => { for (const a of aims) { const { direction, up, right } = skyBasis(a); const label = `az=${a.azimuth} pitch=${a.pitch} roll=${a.roll}`; expect(Cartesian3.magnitude(direction), label).toBeCloseTo(1, 12); expect(Cartesian3.magnitude(up), label).toBeCloseTo(1, 12); expect(Cartesian3.magnitude(right), label).toBeCloseTo(1, 12); expect(Cartesian3.dot(direction, up), label).toBeCloseTo(0, 12); expect(Cartesian3.dot(direction, right), label).toBeCloseTo(0, 12); expect(Cartesian3.dot(up, right), label).toBeCloseTo(0, 12); // right = direction x up, the handedness Cesium's camera assumes. const cross = Cartesian3.cross(direction, up, new Cartesian3()); expect(angleBetween(cross, right), label).toBeCloseTo(0, 6); } }); test("points where the azimuth and pitch say", () => { // East-north-up components, azimuth clockwise from north. expect(skyBasis(aim(0, 0)).direction).toMatchObject({ x: expect.closeTo(0, 12), y: expect.closeTo(1, 12), z: expect.closeTo(0, 12) }); expect(skyBasis(aim(90, 0)).direction).toMatchObject({ x: expect.closeTo(1, 12), y: expect.closeTo(0, 12), z: expect.closeTo(0, 12) }); expect(skyBasis(aim(0, 90)).direction).toMatchObject({ x: expect.closeTo(0, 12), y: expect.closeTo(0, 12), z: expect.closeTo(1, 12) }); expect(skyBasis(aim(0, -90)).direction).toMatchObject({ x: expect.closeTo(0, 12), y: expect.closeTo(0, 12), z: expect.closeTo(-1, 12) }); }); test("keeps the horizon level when there is no roll", () => { // `right` has no vertical component, so the horizon is horizontal on screen. for (const pitch of [-90, -45, 0, 45, 87.5, 90]) { const { right } = skyBasis(aim(210, pitch)); expect(right.z, `pitch=${pitch}`).toBeCloseTo(0, 12); } }); test("rolls about the view axis without moving it", () => { const level = skyBasis(aim(120, 40)); const rolled = skyBasis(aim(120, 40, 30)); expect(angleBetween(level.direction, rolled.direction)).toBeCloseTo(0, 9); expect(angleBetween(level.up, rolled.up)).toBeCloseTo(30, 9); expect(angleBetween(level.right, rolled.right)).toBeCloseTo(30, 9); }); // The failure this whole approach exists to avoid: `setView` derives the roll // from direction/up and switches formula within EPSILON3 of straight up, so it // reports 175° of roll error at 87.5° and 180° at 90° — the sky mirrors. A // basis built from the angles has no such branch, so a quarter-degree step // across the zenith moves it a quarter of a degree. test("is continuous through the zenith", () => { const step = 0.25; for (let pitch = 85; pitch < 90; pitch += step) { const before = skyBasis(aim(0, pitch)); const after = skyBasis(aim(0, Math.min(pitch + step, 90))); const label = `pitch=${pitch}`; expect(angleBetween(before.direction, after.direction), label).toBeLessThan(step * 1.001); expect(angleBetween(before.up, after.up), label).toBeLessThan(step * 1.001); expect(angleBetween(before.right, after.right), label).toBeLessThan(step * 1.001); } }); test("has no roll at the zenith itself", () => { // Facing north at the zenith, the top of the screen is due south — the // continuous limit of tipping the view up, not its mirror image. const { up } = skyBasis(aim(0, 90)); expect(up.x).toBeCloseTo(0, 12); expect(up.y).toBeCloseTo(-1, 12); expect(up.z).toBeCloseTo(0, 12); }); }); describe("fovFromFovy", () => { // Cesium derives fovy back out as `aspect <= 1 ? fov : 2*atan(tan(fov/2)/aspect)`, // so this has to be its exact inverse or the vertical framing is not what was asked for. const cesiumFovy = (fov: number, aspectRatio: number): number => (aspectRatio <= 1 ? fov : 2 * Math.atan(Math.tan(fov * 0.5) / aspectRatio)); test("round-trips through Cesium's own derivation", () => { for (const fovyDegrees of [45, 65, 75, 100]) { for (const aspectRatio of [0.46, 1, 16 / 9, 21 / 9]) { const fovy = CesiumMath.toRadians(fovyDegrees); expect(cesiumFovy(fovFromFovy(fovy, aspectRatio), aspectRatio)).toBeCloseTo(fovy, 12); } } }); test("is the identity on a portrait viewport, where Cesium's fov is already vertical", () => { const fovy = CesiumMath.toRadians(75); expect(fovFromFovy(fovy, 0.46)).toBe(fovy); expect(fovFromFovy(fovy, 1)).toBe(fovy); }); test("survives a viewport with no height yet", () => { const fovy = CesiumMath.toRadians(75); expect(fovFromFovy(fovy, Number.NaN)).toBe(fovy); }); }); describe("fovyFromFov", () => { // The way in: a flight from the globe starts at whatever the globe camera's // frustum held, and everything the sky view interpolates is vertical. test("undoes fovFromFovy at every aspect ratio", () => { for (const fovyDegrees of [36, 45, 60, 75, 100]) { for (const aspectRatio of [0.46, 1, 16 / 9, 21 / 9, Number.NaN]) { const fovy = CesiumMath.toRadians(fovyDegrees); expect(fovyFromFov(fovFromFovy(fovy, aspectRatio), aspectRatio)).toBeCloseTo(fovy, 12); } } }); test("reads Cesium's default 60° fov as a narrower vertical angle on a wide window", () => { // Which is why entering widens as well as descends: the globe is seen // through about 36° of vertical angle on a 16:9 window, the sky through 75°. expect(CesiumMath.toDegrees(fovyFromFov(CesiumMath.toRadians(60), 16 / 9))).toBeCloseTo(36, 1); }); }); describe("isPlausibleGroundHeight", () => { test("accepts real ground", () => { expect(isPlausibleGroundHeight(0)).toBe(true); expect(isPlausibleGroundHeight(519)).toBe(true); expect(isPlausibleGroundHeight(-430)).toBe(true); expect(isPlausibleGroundHeight(8849)).toBe(true); }); test("rejects a missing tile reporting itself as a number", () => { // Observed from `globe.getHeight` at 48.14N 11.58E under the default // EllipsoidTerrainProvider, where the true answer is 0. Believing it put the // camera 37 km down, which stopped the tiles under the observer rendering, // which kept the answer wrong. expect(isPlausibleGroundHeight(-36990.17462565757)).toBe(false); }); test("rejects absent and non-finite answers", () => { expect(isPlausibleGroundHeight(undefined)).toBe(false); expect(isPlausibleGroundHeight(Number.NaN)).toBe(false); expect(isPlausibleGroundHeight(Number.POSITIVE_INFINITY)).toBe(false); }); }); describe("defaultAzimuth", () => { test("faces the equator, where the passes are", () => { expect(defaultAzimuth({ lat: 48.14, lon: 11.58 })).toBe(180); expect(defaultAzimuth({ lat: 0, lon: 0 })).toBe(180); expect(defaultAzimuth({ lat: -33.9, lon: 151.2 })).toBe(0); }); }); describe("eyeHeight", () => { // Same bare scene as the fovy clamp below, and for the same reason: with no // observer the camera work short-circuits and what is left is the clamp. const view = (): SkyView => new SkyView({} as Scene); test("starts standing on the ground", () => { expect(view().eyeHeight).toBe(MIN_EYE_HEIGHT); }); test("cannot be walked under the surface it is standing on", () => { const sunk = view(); sunk.eyeHeight = -100; expect(sunk.eyeHeight).toBe(MIN_EYE_HEIGHT); }); test("stops where looking up from the ground stops describing the picture", () => { const risen = view(); risen.eyeHeight = 1e6; expect(risen.eyeHeight).toBe(MAX_EYE_HEIGHT); }); test("holds a height inside the range, which is what the keys move", () => { const lifted = view(); lifted.eyeHeight = 500; expect(lifted.eyeHeight).toBe(500); // The keys move it by adding to it, so a rise from a rise has to accumulate // rather than reset. lifted.eyeHeight += 250; expect(lifted.eyeHeight).toBe(750); }); }); describe("fovy", () => { // A bare scene is enough: with no observer the camera work short-circuits, and // what is under test is the clamp on the way in. const view = (): SkyView => new SkyView({} as Scene); test("starts at the default", () => { expect(view().fovy).toBe(DEFAULT_FOVY); expect(DEFAULT_FOVY).toBeGreaterThanOrEqual(MIN_FOVY); expect(DEFAULT_FOVY).toBeLessThanOrEqual(MAX_FOVY); }); test("clamps rather than letting a gesture run past the ends", () => { const zoomedIn = view(); zoomedIn.fovy = 0.001; expect(zoomedIn.fovy).toBe(MIN_FOVY); const zoomedOut = view(); zoomedOut.fovy = 400; expect(zoomedOut.fovy).toBe(MAX_FOVY); }); test("the default zoom keeps the horizon on screen at the default pitch", () => { // `pitch < fovy/2` on entry — the guarantee the defaults exist to provide. expect(DEFAULT_PITCH).toBeLessThan(DEFAULT_FOVY / 2); }); test("zooming in is allowed to take the horizon off screen", () => { // Deliberate: at maximum zoom the invariant above cannot hold at any useful // pitch, and clamping pitch to preserve it would silently tilt the view down. expect(DEFAULT_PITCH).toBeGreaterThan(MIN_FOVY / 2); }); }); describe("the scene state the view borrows", () => { // Enough scene for `enter` and `exit` to run. Real Cartesian3s and a real // PerspectiveFrustum, because the poses are cloned into them and the `fov` is // only saved when the frustum is one. const stubScene = (depthTestAgainstTerrain: boolean) => ({ mode: SceneMode.SCENE3D, requestRenderMode: true, globe: { depthTestAgainstTerrain, getHeight: () => 800 }, canvas: { clientWidth: 1280, clientHeight: 720 }, camera: { position: Cartesian3.fromDegrees(11.58, 48.14, 1e7), direction: new Cartesian3(0, 0, -1), up: new Cartesian3(0, 1, 0), right: new Cartesian3(1, 0, 0), frustum: new PerspectiveFrustum({ fov: CesiumMath.toRadians(60), aspectRatio: 16 / 9, near: 1, far: 1e9 }), lookAtTransform: () => {}, }, screenSpaceCameraController: { enableInputs: true, enableCollisionDetection: true }, preRender: { addEventListener: () => () => {} }, requestRender: () => {}, }); // Reduced motion, so both flights are cuts and the borrow-and-return happens in // the two calls rather than over 1.5 s of frames. const cut = (): void => void vi.stubGlobal("matchMedia", () => ({ matches: true })); afterEach(() => { vi.unstubAllGlobals(); }); test("lets the ground occlude while the view is up", async () => { cut(); const scene = stubScene(false); await new SkyView(scene as unknown as Scene).enter({ lat: 47.3879, lon: 12.3077 }); expect(scene.globe.depthTestAgainstTerrain).toBe(true); }); // Run for both starting states: "put back" and "clear" only differ on one. const roundTrip = async (found: boolean): Promise => { const scene = stubScene(found); const view = new SkyView(scene as unknown as Scene); await view.enter({ lat: 47.3879, lon: 12.3077 }); await view.exit(); expect(scene.globe.depthTestAgainstTerrain, `found ${found}`).toBe(found); expect(scene.requestRenderMode).toBe(true); expect(scene.screenSpaceCameraController.enableInputs).toBe(true); expect(scene.screenSpaceCameraController.enableCollisionDetection).toBe(true); expect(CesiumMath.toDegrees(scene.camera.frustum.fov ?? Number.NaN)).toBeCloseTo(60, 9); }; test("hands the globe back exactly as it was found", async () => { cut(); await roundTrip(false); await roundTrip(true); }); });