import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { OBLIQUE_PITCH, STRAIGHT_DOWN_PITCH, bindCameraOrientationControls, createCameraOrientationAnimator, pickViewTarget, readCameraTargetFrame, resetCameraNorth, toggleCameraTilt, } from './cameraOrientationControls.js'; import { NavigationController } from './navigationController.js'; function createViewer({ cameraPosition, target, pickPosition = null } = {}) { const calls = []; const changed = new Cesium.Event(); const surface = target || Cesium.Cartesian3.fromDegrees(0, 0, 0); const position = cameraPosition || Cesium.Cartesian3.fromDegrees(0, 0, 1_000); const camera = { positionWC: position, directionWC: new Cesium.Cartesian3(-1, 0, 0), upWC: new Cesium.Cartesian3(0, 0, 1), heading: Cesium.Math.toRadians(90), changed, pickEllipsoid: () => surface, getPickRay: () => ({}), lookAt(targetValue, offset) { calls.push({ type: 'lookAt', target: targetValue, offset }); }, lookAtTransform(transform) { calls.push({ type: 'lookAtTransform', transform }); }, setView(view) { calls.push({ type: 'setView', view }); }, }; const viewer = { camera, scene: { canvas: { clientWidth: 1_200, clientHeight: 800 }, pickPositionSupported: Boolean(pickPosition), pickPosition: pickPosition || (() => null), globe: { pick: () => surface }, requestRender: () => calls.push({ type: 'requestRender' }), }, }; return { viewer, calls, target: surface }; } class FakeButton extends EventTarget { constructor() { super(); this.attributes = new Map(); this.style = { setProperty: (name, value) => this.attributes.set(name, value), }; } setAttribute(name, value) { this.attributes.set(name, String(value)); } getAttribute(name) { return this.attributes.get(name); } click() { this.dispatchEvent(new Event('click')); } } /** * A real Cesium camera attached to a minimal offscreen scene. * * The fake above records what lookAt was asked for but never moves, so it * cannot show whether a frame written into the camera reads back as the same * frame. These cases need the production math on both sides. */ function createRealCamera() { const scene = { canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 }, drawingBufferWidth: 1200, drawingBufferHeight: 800, mapProjection: new Cesium.GeographicProjection(Cesium.Ellipsoid.WGS84), globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, // setView converts a world-space direction/up pair into the local frame // only in 3D, and the camera derives heading/pitch the same way, so the // scene mode is load-bearing here rather than decoration. mode: Cesium.SceneMode.SCENE3D, pixelRatio: 1, }; const camera = new Cesium.Camera(scene); scene.camera = camera; return { camera, scene }; } /** A viewer whose center pick is a fixed ground point and whose camera is real. */ function createRealViewer(target) { const { camera, scene } = createRealCamera(); scene.pickPositionSupported = false; scene.pickPosition = () => null; scene.globe.pick = () => target; scene.requestRender = () => {}; // The binding drives the needle from the scene's per-frame signal, not from // `camera.changed`, so the fixture has to carry one. scene.preRender = new Cesium.Event(); camera.getPickRay = () => ({}); return { viewer: { camera, scene }, target }; } /** Place a real camera in an orbit around `target` and release the frame. */ function orbit(viewer, target, headingDeg, pitchRad, range) { viewer.camera.lookAt( target, new Cesium.HeadingPitchRange( Cesium.Math.toRadians(headingDeg), pitchRad, range, ), ); viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); } const AUSTIN_GROUND = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 0); test('a heading written into a real camera reads back as the same heading', () => { // One oblique heading per quadrant, plus the cardinals, at pitches a user // reaches with the tilt control. for (const headingDeg of [0, 30, 90, 135, 180, 225, 270, 315, 350]) { for (const pitchDeg of [-25, -40, -65]) { const { viewer } = createRealViewer(AUSTIN_GROUND); viewer.camera.lookAt( AUSTIN_GROUND, new Cesium.HeadingPitchRange( Cesium.Math.toRadians(headingDeg), Cesium.Math.toRadians(pitchDeg), 4000, ), ); viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); const frame = readCameraTargetFrame(viewer); assert.ok(frame, `no frame at ${headingDeg}/${pitchDeg}`); const readDeg = Cesium.Math.toDegrees(frame.heading); assert.ok( Math.abs(((readDeg - headingDeg + 540) % 360) - 180) < 0.01, `heading ${headingDeg} deg at pitch ${pitchDeg} read back as ${readDeg.toFixed(2)} deg`, ); assert.ok( Math.abs(Cesium.Math.toDegrees(frame.pitch) - pitchDeg) < 0.01, `pitch ${pitchDeg} read back as ${Cesium.Math.toDegrees(frame.pitch).toFixed(2)}`, ); assert.ok(Math.abs(frame.range - 4000) < 1, `range ${frame.range}`); } } }); test('north-up really points a real camera north from every quadrant', () => { for (const headingDeg of [30, 135, 225, 315]) { const { viewer } = createRealViewer(AUSTIN_GROUND); viewer.camera.lookAt( AUSTIN_GROUND, new Cesium.HeadingPitchRange( Cesium.Math.toRadians(headingDeg), Cesium.Math.toRadians(-40), 4000, ), ); viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); assert.equal( resetCameraNorth(viewer), true, `reset failed at ${headingDeg}`, ); const after = readCameraTargetFrame(viewer); assert.ok(after, `no frame after reset at ${headingDeg}`); const readDeg = Cesium.Math.toDegrees(after.heading); assert.ok( Math.min(readDeg, 360 - readDeg) < 0.01, `north-up from ${headingDeg} deg left the camera at ${readDeg.toFixed(2)} deg`, ); assert.ok( Math.abs(Cesium.Math.toDegrees(after.pitch) + 40) < 0.01, 'north-up must not change the pitch', ); assert.ok( Math.abs(after.range - 4000) < 1, 'north-up must not change the range', ); } }); test('tilt moves a real camera between the two pitches and keeps target and range', () => { const { viewer } = createRealViewer(AUSTIN_GROUND); viewer.camera.lookAt( AUSTIN_GROUND, new Cesium.HeadingPitchRange( Cesium.Math.toRadians(215), STRAIGHT_DOWN_PITCH, 3000, ), ); viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); const first = toggleCameraTilt(viewer); assert.deepEqual(first, { tilted: true, pitch: OBLIQUE_PITCH }); const tilted = readCameraTargetFrame(viewer); assert.ok(Math.abs(tilted.pitch - OBLIQUE_PITCH) < 1e-6); assert.ok(Math.abs(tilted.range - 3000) < 1, `range ${tilted.range}`); assert.ok( Math.abs(Cesium.Math.toDegrees(tilted.heading) - 215) < 0.01, 'tilt must not rotate the map', ); // Releasing the orbit frame must leave the camera pointing at the same // ground, not merely standing in the right place. const toTarget = Cesium.Cartesian3.normalize( Cesium.Cartesian3.subtract( AUSTIN_GROUND, viewer.camera.positionWC, new Cesium.Cartesian3(), ), new Cesium.Cartesian3(), ); const offBy = Cesium.Math.toDegrees( Math.acos( Cesium.Math.clamp( Cesium.Cartesian3.dot(viewer.camera.directionWC, toTarget), -1, 1, ), ), ); assert.ok( offBy < 0.05, `camera points ${offBy.toFixed(2)} deg away from its target`, ); const second = toggleCameraTilt(viewer); assert.deepEqual(second, { tilted: false, pitch: STRAIGHT_DOWN_PITCH }); const down = readCameraTargetFrame(viewer); assert.ok(Math.abs(down.pitch - STRAIGHT_DOWN_PITCH) < 1e-6); assert.ok(Math.abs(down.range - 3000) < 1); }); test('tilt alternates at globe range, not only at map range', () => { // The canonical full-globe view. The camera's own pitch here is tens of // degrees away from the orbit pitch, so a control reading the camera would // command oblique twice in a row and the map would never come back level. for (const range of [3_000, 250_000, 18_000_000]) { const { viewer } = createRealViewer(AUSTIN_GROUND); orbit(viewer, AUSTIN_GROUND, 0, STRAIGHT_DOWN_PITCH, range); const first = toggleCameraTilt(viewer); assert.deepEqual( first, { tilted: true, pitch: OBLIQUE_PITCH }, `first click at ${range} m must tilt`, ); const second = toggleCameraTilt(viewer); assert.deepEqual( second, { tilted: false, pitch: STRAIGHT_DOWN_PITCH }, `second click at ${range} m must level (camera pitch reads ${Cesium.Math.toDegrees(viewer.camera.pitch).toFixed(1)} deg)`, ); const third = toggleCameraTilt(viewer); assert.deepEqual( third, { tilted: true, pitch: OBLIQUE_PITCH }, `third click at ${range} m must tilt again`, ); } }); test('the button shows the state the next click will produce, at every range', () => { for (const range of [3_000, 18_000_000]) { const { viewer } = createRealViewer(AUSTIN_GROUND); orbit(viewer, AUSTIN_GROUND, 0, STRAIGHT_DOWN_PITCH, range); const tiltButton = new FakeButton(); const controls = bindCameraOrientationControls({ viewer, elements: { tiltButton, northButton: new FakeButton() }, runNavigation: (_noun, navigate) => navigate(), }); assert.equal( tiltButton.getAttribute('aria-pressed'), 'false', `straight down at ${range} m must not read as tilted`, ); tiltButton.click(); assert.equal( tiltButton.getAttribute('aria-pressed'), 'true', `after one click at ${range} m the button must read tilted`, ); // And the exact refresh that runs when the camera settles must agree. controls.refreshTilt(); assert.equal( tiltButton.getAttribute('aria-pressed'), 'true', `the settled state at ${range} m must agree with the commanded one`, ); controls.destroy(); } }); test('the needle follows a rotation too small for the camera-changed threshold', () => { const { viewer } = createRealViewer(AUSTIN_GROUND); orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000); const northButton = new FakeButton(); const controls = bindCameraOrientationControls({ viewer, elements: { tiltButton: new FakeButton(), northButton }, runNavigation: (_noun, navigate) => navigate(), }); assert.equal(northButton.getAttribute('--camera-heading'), '0deg'); // Ten degrees is far below Cesium's default percentageChanged, so // `camera.changed` may never fire for it; the per-frame signal must. let changedFired = 0; viewer.camera.changed.addEventListener(() => { changedFired += 1; }); orbit(viewer, AUSTIN_GROUND, 10, Cesium.Math.toRadians(-80), 5_000); viewer.scene.preRender.raiseEvent(); assert.equal( northButton.getAttribute('--camera-heading'), '10deg', `needle must follow a 10 degree rotation (camera.changed fired ${changedFired} time(s))`, ); orbit(viewer, AUSTIN_GROUND, 13, Cesium.Math.toRadians(-80), 5_000); viewer.scene.preRender.raiseEvent(); assert.equal(northButton.getAttribute('--camera-heading'), '13deg'); controls.destroy(); }); test('north-up indicator follows real heading including the 360-degree seam', () => { const { viewer } = createRealViewer(AUSTIN_GROUND); const northButton = new FakeButton(); const controls = bindCameraOrientationControls({ viewer, elements: { northButton }, runNavigation: (_noun, navigate) => navigate(), }); for (const [heading, active] of [ [0, true], [15, false], [359.8, true], [359, false], [360, true], [90, false], ]) { orbit(viewer, AUSTIN_GROUND, heading, Cesium.Math.toRadians(-80), 5_000); viewer.scene.preRender.raiseEvent(); assert.equal( northButton.getAttribute('data-north-up'), String(active), `heading ${heading}`, ); } controls.destroy(); }); test('a settled camera writes nothing, and teardown releases every signal', () => { const { viewer } = createRealViewer(AUSTIN_GROUND); orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000); const tiltButton = new FakeButton(); const northButton = new FakeButton(); let writes = 0; const count = (element) => { const setAttribute = element.setAttribute.bind(element); const setProperty = element.style.setProperty.bind(element.style); element.setAttribute = (name, value) => { writes += 1; setAttribute(name, value); }; element.style.setProperty = (name, value) => { writes += 1; setProperty(name, value); }; }; count(tiltButton); count(northButton); const preRenderBefore = viewer.scene.preRender.numberOfListeners; const moveEndBefore = viewer.camera.moveEnd.numberOfListeners; const controls = bindCameraOrientationControls({ viewer, elements: { tiltButton, northButton }, runNavigation: (_noun, navigate) => navigate(), }); assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore + 1); assert.equal(viewer.camera.moveEnd.numberOfListeners, moveEndBefore + 1); writes = 0; for (let i = 0; i < 60; i += 1) viewer.scene.preRender.raiseEvent(); assert.equal(writes, 0, '60 frames of a settled camera write nothing'); orbit(viewer, AUSTIN_GROUND, 47, Cesium.Math.toRadians(-80), 5_000); viewer.scene.preRender.raiseEvent(); assert.ok(writes > 0, 'a rotated camera updates the needle'); writes = 0; for (let i = 0; i < 20; i += 1) viewer.scene.preRender.raiseEvent(); assert.equal(writes, 0, 'and then goes quiet again'); controls.destroy(); assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore); assert.equal(viewer.camera.moveEnd.numberOfListeners, moveEndBefore); writes = 0; orbit(viewer, AUSTIN_GROUND, 120, Cesium.Math.toRadians(-80), 5_000); viewer.scene.preRender.raiseEvent(); viewer.camera.moveEnd.raiseEvent(); tiltButton.click(); assert.equal(writes, 0, 'a destroyed binding writes nothing'); }); test('a sky-facing camera yields no frame and both actions decline', () => { const { camera, scene } = createRealCamera(); scene.pickPositionSupported = false; scene.pickPosition = () => null; scene.globe.pick = () => undefined; scene.requestRender = () => {}; camera.getPickRay = () => ({}); camera.pickEllipsoid = () => undefined; const viewer = { camera, scene }; assert.equal(readCameraTargetFrame(viewer), null); assert.equal(toggleCameraTilt(viewer), false); assert.equal(resetCameraNorth(viewer), false); }); test('center picking prefers terrain over the ellipsoid and falls through an invalid depth hit', () => { const invalid = new Cesium.Cartesian3(Number.NaN, 0, 0); const terrain = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 400); const sea = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0); const { viewer } = createViewer({ pickPosition: () => invalid }); viewer.scene.globe.pick = () => terrain; viewer.camera.pickEllipsoid = () => sea; assert.equal( pickViewTarget(viewer), terrain, 'the rendered ground wins over sea level', ); viewer.scene.globe.pick = () => null; assert.equal( pickViewTarget(viewer), sea, 'the ellipsoid still answers when nothing is under the cursor', ); }); test('target frame identifies a straight-down camera in local ENU space', () => { const { viewer } = createViewer(); const frame = readCameraTargetFrame(viewer); assert.ok(frame); assert.ok(Math.abs(frame.pitch - Cesium.Math.toRadians(-90)) < 0.001); assert.ok(frame.range > 999 && frame.range < 1_001); assert.equal(frame.heading, Cesium.Math.toRadians(90)); }); test('tilt preserves the picked target and range while choosing oblique pitch', () => { const { viewer, calls, target } = createViewer(); const before = readCameraTargetFrame(viewer); const result = toggleCameraTilt(viewer); const lookAt = calls.find((call) => call.type === 'lookAt'); assert.deepEqual(result, { tilted: true, pitch: OBLIQUE_PITCH }); assert.equal(lookAt.target, target); assert.equal(lookAt.offset.range, before.range); assert.equal(lookAt.offset.pitch, OBLIQUE_PITCH); assert.ok(calls.some((call) => call.type === 'lookAtTransform')); assert.ok(calls.some((call) => call.type === 'setView')); }); test('north-up preserves target pitch and range', () => { const { viewer, calls } = createViewer(); const before = readCameraTargetFrame(viewer); assert.equal(resetCameraNorth(viewer), true); const lookAt = calls.find((call) => call.type === 'lookAt'); assert.equal(lookAt.offset.heading, 0); assert.equal(lookAt.offset.pitch, before.pitch); assert.equal(lookAt.offset.range, before.range); }); test('bindings route both controls and release every listener on destroy', () => { const { viewer } = createViewer(); const tiltButton = new FakeButton(); const northButton = new FakeButton(); const navigations = []; const toasts = []; const controls = bindCameraOrientationControls({ viewer, elements: { tiltButton, northButton }, runNavigation(noun, navigate) { navigations.push(noun); return navigate(); }, showToast: (message) => toasts.push(message), }); tiltButton.click(); northButton.click(); assert.deepEqual(navigations, ['camera', 'camera']); assert.deepEqual(toasts, ['Tilted view', 'North up']); // The button shows the state the click just commanded, not a re-measurement. assert.equal(tiltButton.getAttribute('aria-pressed'), 'true'); assert.equal(northButton.getAttribute('--camera-heading'), '90deg'); viewer.camera.heading = -1e-12; controls.sync(); assert.equal(northButton.getAttribute('--camera-heading'), '0deg'); controls.destroy(); tiltButton.click(); assert.deepEqual(navigations, ['camera', 'camera']); assert.equal(STRAIGHT_DOWN_PITCH, Cesium.Math.toRadians(-89)); }); for (const [label, altitude, range] of [ ['aircraft', 10000, 1500], ['satellite', 400000, 1500000], ['high satellite', 35786000, 3000000], ]) { test(`${label}: animated tilt and north retain a moving EntityView`, (t) => { // Use Cesium's built-in TEME fallback without fetching Earth-orientation data. t.mock.method( Cesium.Transforms, 'computeFixedToIcrfMatrix', () => undefined, ); let target = Cesium.Cartesian3.fromDegrees(-97, 30, altitude); const { viewer } = createRealViewer(target); viewer.scene.preUpdate = new Cesium.Event(); viewer.clock = { currentTime: Cesium.JulianDate.now() }; const entity = new Cesium.Entity({ position: new Cesium.CallbackPositionProperty(() => target, false), trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, }); entity.gevDisplayPosition = () => target; viewer.trackedEntity = entity; const follow = new Cesium.EntityView(entity, viewer.scene); follow.update(viewer.clock.currentTime); viewer.camera.lookAt( target, new Cesium.HeadingPitchRange( Cesium.Math.toRadians(350), OBLIQUE_PITCH, range, ), ); let time = 0; const animator = createCameraOrientationAnimator(viewer, { now: () => time, duration: 1000, }); for (const pitch of [STRAIGHT_DOWN_PITCH, OBLIQUE_PITCH]) { const frame = readCameraTargetFrame(viewer); animator.animate(frame, { heading: 0, pitch }); for (const step of [0, 100, 500, 900, 1000]) { time = (pitch === STRAIGHT_DOWN_PITCH ? 0 : 1000) + step; target = Cesium.Cartesian3.fromDegrees( -97 + time / 100000, 30, altitude, ); follow.update(viewer.clock.currentTime); const transform = Cesium.Matrix4.clone(viewer.camera.transform); viewer.scene.preUpdate.raiseEvent(); assert.equal(viewer.trackedEntity, entity); assert.ok( Cesium.Matrix4.equalsEpsilon( viewer.camera.transform, transform, 1e-8, ), ); const actual = readCameraTargetFrame(viewer); const eased = Cesium.EasingFunction.CUBIC_IN_OUT(step / 1000); assert.ok( Math.abs(actual.pitch - Cesium.Math.lerp(frame.pitch, pitch, eased)) < 1e-6, ); assert.ok(Math.abs(actual.range - range) < 1e-5); } assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); } target = Cesium.Cartesian3.fromDegrees(-96, 31, altitude); follow.update(viewer.clock.currentTime); assert.equal(viewer.trackedEntity, entity); assert.ok(Math.abs(readCameraTargetFrame(viewer).range - range) < 1e-5); }); } test('orientation animation cancels on replacement and explicit cancellation', () => { const { viewer } = createRealViewer(Cesium.Cartesian3.fromDegrees(0, 0)); orbit(viewer, Cesium.Cartesian3.fromDegrees(0, 0), 90, OBLIQUE_PITCH, 1000); viewer.scene.preUpdate = new Cesium.Event(); const animator = createCameraOrientationAnimator(viewer); const frame = readCameraTargetFrame(viewer); animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH }); animator.animate(frame, { heading: 0, pitch: OBLIQUE_PITCH }); assert.equal(viewer.scene.preUpdate.numberOfListeners, 1); viewer.trackedEntity = {}; viewer.scene.preUpdate.raiseEvent(); assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); viewer.trackedEntity = undefined; animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH }); animator.cancel(); assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); }); test('orientation navigation preserves follow and selection; new navigation cancels animation', () => { const calls = []; const entity = {}; const viewer = { trackedEntity: entity, camera: { cancelFlight: () => calls.push('cancel-flight') }, }; let cockpit = false; const navigation = new NavigationController({ viewer, tracking: {}, isCockpitActive: () => cockpit, cancelOrientation: () => calls.push('cancel-orientation'), clearLocation: () => calls.push('clear-location'), cancelShareSelection: () => calls.push('clear-selection'), interruptCameraMotion: () => calls.push('interrupt-motion'), stopOrbit: () => calls.push('stop-orbit'), showToast: () => calls.push('toast'), }); assert.equal( navigation.runOrientation('camera', () => 'animated'), 'animated', ); assert.equal(viewer.trackedEntity, entity); assert.deepEqual(calls, [ 'cancel-orientation', 'interrupt-motion', 'stop-orbit', 'cancel-flight', ]); calls.length = 0; navigation._stampNavigation({ cancelPendingSelection: false }); assert.deepEqual(calls, ['cancel-orientation', 'clear-location']); calls.length = 0; cockpit = true; assert.equal( navigation.runOrientation('camera', () => assert.fail('cockpit should refuse'), ), false, ); assert.deepEqual(calls, ['toast']); }); test('rapid tilt presses reverse direction and north-up keeps the requested tilt', (t) => { let time = 0; t.mock.method(performance, 'now', () => time); const target = Cesium.Cartesian3.fromDegrees(0, 0); const { viewer } = createRealViewer(target); orbit(viewer, target, 90, OBLIQUE_PITCH, 1000); viewer.scene.preUpdate = new Cesium.Event(); const tiltButton = new FakeButton(); const northButton = new FakeButton(); let controls; controls = bindCameraOrientationControls({ viewer, elements: { tiltButton, northButton }, runNavigation: (_noun, navigate) => { controls.cancel(); return navigate(); }, }); tiltButton.click(); tiltButton.click(); assert.equal(tiltButton.getAttribute('aria-pressed'), 'true'); tiltButton.click(); northButton.click(); assert.equal(viewer.scene.preUpdate.numberOfListeners, 1); time = 650; viewer.scene.preUpdate.raiseEvent(); const frame = readCameraTargetFrame(viewer); assert.ok(Math.abs(frame.pitch - STRAIGHT_DOWN_PITCH) < 1e-6); assert.ok(Math.abs(frame.heading) < 1e-6); controls.destroy(); assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); });