Files
gods-eye-view/src/cameraGlobeRotation.test.mjs
T
Sameh Khamis a20076fc5e fix(camera): judge a voice tilt's frame limit by the way its pitch moves
The pre-check picked the in-place look from args.direction alone, while the tilt itself turns down for any direction but up. A tilt sent as left or right at the frame limit was checked as a turn left or right, refused as losing the globe, though it turns down toward the planet. A rotate sent as up or down was checked as a look up or down, though it turns right.

A tilt's look now comes from its goal pitch against the current pitch (tiltLook), and the tick tilts in place through tiltInPlaceWithinFrame(cam, m, goalPitch). Neither depends on the direction word, so a tilt to an absolute angle, or a tilt with no direction, can use them as they are.
2026-11-05 15:55:52 -08:00

1887 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { GLOBE_VIEW } from './locations.js';
import { projectEarthDiscToViewport } from './celestialRing.js';
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
activeCameraMotionId,
getActiveCameraMotion,
initCameraVerbs,
interruptCameraMotion,
interruptGlobeRotation,
interruptCameraMotionIfActive,
moveCamera,
} from './cameraVerbs.js';
import { getRenderGovernorDiagnostics } from './renderGovernor.js';
import { MIN_GLOBE_FRAME_SHARE, globeFrameShare } from './globeInView.js';
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
import { setScopeMaskEnabled } from './scopeMask.js';
const radians = Cesium.Math.toRadians;
const clone = (value) => Cesium.Cartesian3.clone(value);
const longitude = (camera) =>
Cesium.Cartographic.fromCartesian(camera.positionWC).longitude;
const wrappedAngle = Cesium.Math.negativePiToPi;
const hasRenderHold = () =>
getRenderGovernorDiagnostics().holds.includes('camera-verb');
function near(actual, expected, tolerance = 1e-10, message = '') {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${message}: expected ${expected}, received ${actual}`,
);
}
function pose(camera) {
return {
position: clone(camera.positionWC),
direction: clone(camera.directionWC),
up: clone(camera.upWC),
};
}
function samePose(camera, expected) {
const actual = pose(camera);
for (const key of ['position', 'direction', 'up']) {
near(
Cesium.Cartesian3.distance(actual[key], expected[key]),
0,
key === 'position' ? 1e-6 : 1e-10,
key,
);
}
}
function orthonormalBasis(camera) {
const { directionWC: direction, upWC: up, rightWC: right } = camera;
for (const vector of [direction, up, right]) {
near(Cesium.Cartesian3.magnitude(vector), 1, 1e-12, 'unit camera basis');
}
near(Cesium.Cartesian3.dot(direction, up), 0, 1e-12);
near(Cesium.Cartesian3.dot(direction, right), 0, 1e-12);
near(Cesium.Cartesian3.dot(up, right), 0, 1e-12);
near(
Cesium.Cartesian3.distance(
Cesium.Cartesian3.cross(direction, up, new Cesium.Cartesian3()),
right,
),
0,
1e-12,
'right-handed camera basis',
);
}
/** Use Cesium itself as an independent oracle for the swept camera path. */
function arcHeights(camera, axis, angle, ellipsoid = Cesium.Ellipsoid.WGS84) {
const heights = [];
for (let i = 0; i <= 64; i += 1) {
const probe = Cesium.Camera.clone(camera);
probe.rotate(axis, (angle * i) / 64);
heights.push(ellipsoid.cartesianToCartographic(probe.positionWC).height);
}
return heights;
}
/** Camera-space screen coordinates, before FOV/aspect scaling. */
function projection(camera, target = Cesium.Cartesian3.ZERO) {
const offset = Cesium.Cartesian3.subtract(
target,
camera.positionWC,
new Cesium.Cartesian3(),
);
const depth = Cesium.Cartesian3.dot(offset, camera.directionWC);
return {
x: Cesium.Cartesian3.dot(offset, camera.rightWC) / depth,
y: Cesium.Cartesian3.dot(offset, camera.upWC) / depth,
depth,
};
}
/** Real Cesium geometry with no renderer, GPU, provider or wall-clock waits. */
function harness(
t,
{
latitude = 15,
longitudeDeg = 0,
height = GLOBE_VIEW.heightM,
heading = 0,
pitch = -90,
roll = 0,
ellipsoid = Cesium.Ellipsoid.WGS84,
viewportWidth = 1280,
viewportHeight = 800,
} = {},
) {
const realPerformance = globalThis.performance;
let nowMs = 0;
globalThis.performance = { now: () => nowMs };
const canvas = Object.assign(new EventTarget(), {
clientWidth: viewportWidth,
clientHeight: viewportHeight,
width: viewportWidth,
height: viewportHeight,
});
const scene = {
canvas,
drawingBufferWidth: viewportWidth,
drawingBufferHeight: viewportHeight,
frameState: { mode: Cesium.SceneMode.SCENE3D },
mapProjection: new Cesium.GeographicProjection(ellipsoid),
globe: { ellipsoid },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
tweens: [],
requestRender() {},
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(
longitudeDeg,
latitude,
height,
ellipsoid,
),
orientation: {
heading: radians(heading),
pitch: radians(pitch),
roll: radians(roll),
},
});
const viewer = {
camera,
scene,
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() },
};
const target = Cesium.Cartesian3.fromDegrees(
longitudeDeg,
latitude,
0,
ellipsoid,
);
initCameraVerbs(viewer, () => target);
t.after(() => {
interruptCameraMotion('test-cleanup');
globalThis.performance = realPerformance;
});
const tick = (seconds = 0.1) => {
nowMs += seconds * 1000;
viewer.clock.onTick.raiseEvent(viewer.clock);
};
const finishOnce = () => {
for (let i = 0; i < 20 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'once motion must finish');
};
return { viewer, camera, canvas, target, tick, finishOnce };
}
for (const [name, sceneMode] of [
['2D', Cesium.SceneMode.SCENE2D],
['Columbus', Cesium.SceneMode.COLUMBUS_VIEW],
['morphing', Cesium.SceneMode.MORPHING],
]) {
test(`globe rotation refuses ${name} before changing the prior owner`, (t) => {
const { viewer, camera } = harness(t);
assert.equal(
moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' })
.ok,
true,
);
const priorId = activeCameraMotionId();
const before = pose(camera);
const transform = Cesium.Matrix4.clone(camera.transform);
viewer.scene.mode = sceneMode;
let handoffs = 0;
let cancellations = 0;
camera.cancelFlight = () => {
cancellations += 1;
};
for (const mode of ['once', 'continuous']) {
const result = moveCamera(
{ motion: 'rotate_globe', direction: 'left', mode },
() => {
handoffs += 1;
return { ok: true };
},
);
assert.equal(result.ok, false);
assert.match(result.error, /3D/);
}
assert.equal(handoffs, 0);
assert.equal(cancellations, 0);
assert.equal(activeCameraMotionId(), priorId);
assert.equal(hasRenderHold(), true);
assert.ok(Cesium.Matrix4.equals(camera.transform, transform));
samePose(camera, before);
});
test(`active globe rotation yields to ${name} without changing the camera`, (t) => {
const { viewer, camera, tick } = harness(t);
assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true,
);
viewer.scene.mode = sceneMode;
const before = pose(camera);
let rotations = 0;
camera.rotate = () => {
rotations += 1;
};
tick();
assert.equal(rotations, 0);
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
samePose(camera, before);
});
}
test('globe rotation yields to tracking acquired and cleared before the next tick', (t) => {
const { viewer, camera, tick } = harness(t);
assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true,
);
const before = pose(camera);
const entity = { id: 'cockpit-aircraft', position: {} };
viewer.trackedEntity = entity;
viewer.trackedEntityChanged.raiseEvent(entity);
assert.equal(
getActiveCameraMotion(),
null,
'tracking acquisition must synchronously yield',
);
viewer.trackedEntity = undefined;
viewer.trackedEntityChanged.raiseEvent(undefined);
tick();
samePose(camera, before);
assert.equal(hasRenderHold(), false);
});
test('tracking event keeps legacy yaw and an untracked globe rotation active', (t) => {
const { viewer } = harness(t);
assert.equal(
moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }).ok,
true,
);
const yawId = activeCameraMotionId();
viewer.trackedEntityChanged.raiseEvent({ id: 'aircraft' });
assert.equal(activeCameraMotionId(), yawId);
assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true,
);
const globeId = activeCameraMotionId();
viewer.trackedEntityChanged.raiseEvent(undefined);
assert.equal(activeCameraMotionId(), globeId);
});
test('replaced viewer releases the old tracking listener without duplicate subscriptions', (t) => {
const first = harness(t);
assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1);
initCameraVerbs(first.viewer);
assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1);
const next = harness(t);
assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 0);
assert.equal(next.viewer.trackedEntityChanged.numberOfListeners, 1);
assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true,
);
const id = activeCameraMotionId();
first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' });
assert.equal(activeCameraMotionId(), id);
next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' });
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
});
for (const latitude of [0, 30, -60, 89.5]) {
test(`globe rotation preserves Earth framing and radius from latitude ${latitude} degrees`, (t) => {
const { camera, tick, finishOnce } = harness(t, { latitude });
const before = projection(camera);
const radius = Cesium.Cartesian3.magnitude(camera.positionWC);
const direction = clone(camera.directionWC);
const started = moveCamera({ motion: 'rotate_globe', mode: 'once' });
assert.equal(started.ok, true);
assert.equal(started.direction, 'right');
assert.equal(started.mode, 'once');
for (let i = 0; i < 5; i += 1) {
tick();
const current = projection(camera);
near(current.x, before.x);
near(current.y, before.y);
near(current.depth, before.depth, 1e-6);
near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6);
orthonormalBasis(camera);
}
finishOnce();
near(
Cesium.Cartesian3.angleBetween(camera.directionWC, direction),
radians(15),
);
assert.equal(hasRenderHold(), false);
});
}
for (const [direction, sign] of [
['left', -1],
['right', 1],
]) {
test(`globe ${direction} means visible landmark motion ${direction}`, (t) => {
const { camera, target, finishOnce } = harness(t, { latitude: 0 });
const originalLongitude = longitude(camera);
near(projection(camera, target).x, 0);
assert.equal(
moveCamera({ motion: 'rotate_globe', direction, mode: 'once' }).ok,
true,
);
finishOnce();
assert.ok(sign * projection(camera, target).x > 0.05);
near(
wrappedAngle(longitude(camera) - originalLongitude),
-sign * radians(15),
);
});
}
for (const view of [
{ label: 'north-up', heading: 0 },
{ label: 'east-up', heading: 90 },
{ label: 'south-up', heading: 180 },
{ label: 'tilted east-up', heading: 90, pitch: -86 },
{ label: 'tilted and rolled', heading: 180, pitch: -86, roll: 35 },
{ label: 'near the pole', latitude: 89.5, heading: 180, roll: -25 },
]) {
for (const [direction, sign] of [
['left', -1],
['right', 1],
]) {
test(`globe ${direction} stays screen-horizontal in a ${view.label} view`, (t) => {
const { camera, tick, finishOnce } = harness(t, view);
const ray = new Cesium.Ray(
clone(camera.positionWC),
clone(camera.directionWC),
);
const intersection = Cesium.IntersectionTests.rayEllipsoid(
ray,
Cesium.Ellipsoid.WGS84,
);
assert.ok(intersection, 'the original screen center must see the globe');
const landmark = Cesium.Ray.getPoint(
ray,
intersection.start,
new Cesium.Cartesian3(),
);
const before = projection(camera);
const radius = Cesium.Cartesian3.magnitude(camera.positionWC);
const originalLandmark = projection(camera, landmark);
near(originalLandmark.x, 0);
near(originalLandmark.y, 0);
// Exercise both bounded and continuous application of the same direction.
const mode = direction === 'left' ? 'once' : 'continuous';
assert.equal(
moveCamera({ motion: 'rotate_globe', direction, mode }).ok,
true,
);
if (mode === 'once') finishOnce();
else {
tick();
tick(0.25);
}
const after = projection(camera);
const movedLandmark = projection(camera, landmark);
assert.ok(
sign * movedLandmark.x > 0.001,
'landmark must move in the requested screen direction',
);
near(movedLandmark.y, originalLandmark.y);
near(after.x, before.x);
near(after.y, before.y);
near(after.depth, before.depth, 1e-6);
near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6);
orthonormalBasis(camera);
});
}
}
test('equal left and right globe rotations restore the complete camera pose', (t) => {
const { camera, finishOnce } = harness(t, {
latitude: 42,
longitudeDeg: 170,
height: GLOBE_VIEW.heightM,
});
const before = pose(camera);
moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' });
finishOnce();
moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' });
finishOnce();
samePose(camera, before);
});
test('globe rotation converts an inherited target frame without zooming or recentering', (t) => {
const { camera, target, tick } = harness(t);
camera.lookAt(
target,
new Cesium.HeadingPitchRange(radians(35), radians(-35), 50_000_000),
);
assert.equal(
Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY),
false,
);
const before = pose(camera);
const screen = projection(camera);
const range = Cesium.Cartesian3.magnitude(camera.positionWC);
moveCamera({
motion: 'rotate_globe',
direction: 'right',
mode: 'continuous',
});
assert.equal(
Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY),
true,
);
samePose(camera, before);
tick();
tick();
near(Cesium.Cartesian3.magnitude(camera.positionWC), range, 1e-6);
near(projection(camera).x, screen.x);
near(projection(camera).y, screen.y);
});
for (const [speed, degreesPerSecond] of [
['slow', 4],
['normal', 10],
['fast', 20],
]) {
test(`continuous globe rotation honors ${speed} speed and explicit stop`, (t) => {
const { camera, tick } = harness(t);
const result = moveCamera({
motion: 'rotate_globe',
mode: 'continuous',
speed,
});
assert.equal(result.ok, true);
assert.equal(hasRenderHold(), true);
tick(); // First tick initializes the motion's wall-clock anchor.
const before = clone(camera.directionWC);
tick(0.1);
near(
Cesium.Cartesian3.angleBetween(camera.directionWC, before),
radians(degreesPerSecond * 0.1),
);
assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe');
const stopped = moveCamera({ motion: 'stop' });
assert.equal(stopped.stopped, true);
assert.equal(hasRenderHold(), false);
const after = pose(camera);
tick();
samePose(camera, after);
assert.equal(moveCamera({ motion: 'stop' }).stopped, false);
});
}
for (const event of ['pointerdown', 'touchstart', 'wheel']) {
test(`${event} immediately cancels globe rotation and releases the render hold`, (t) => {
const { camera, canvas, tick } = harness(t);
moveCamera({ motion: 'rotate_globe', mode: 'continuous' });
tick();
tick();
const before = pose(camera);
canvas.dispatchEvent(new Event(event));
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
tick();
samePose(camera, before);
});
}
test('new navigation can cancel globe rotation before setting a new destination', (t) => {
const { camera, tick } = harness(t);
moveCamera({ motion: 'rotate_globe', mode: 'continuous' });
tick();
assert.equal(interruptCameraMotion('explicit-navigation').wasActive, true);
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(110, -25, 1_000_000),
});
const destination = pose(camera);
tick();
samePose(camera, destination);
assert.equal(hasRenderHold(), false);
});
test('replacement motion rejects a stale globe owner cancellation', (t) => {
const { camera, tick } = harness(t);
moveCamera({ motion: 'rotate_globe', mode: 'continuous' });
const globeId = activeCameraMotionId();
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' });
const yawId = activeCameraMotionId();
assert.notEqual(yawId, globeId);
assert.equal(interruptCameraMotionIfActive(globeId).wasActive, false);
assert.equal(getActiveCameraMotion()?.kind, 'rotate');
const position = clone(camera.positionWC);
tick();
tick();
near(Cesium.Cartesian3.distance(camera.positionWC, position), 0, 1e-6);
assert.equal(interruptCameraMotionIfActive(yawId).wasActive, true);
assert.equal(hasRenderHold(), false);
});
test('replacing the viewer retires globe motion and its old clock listener', (t) => {
const { viewer, camera, tick } = harness(t);
assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true,
);
tick();
const before = pose(camera);
const nextViewer = {
...viewer,
clock: { onTick: new Cesium.Event() },
};
initCameraVerbs(nextViewer);
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
tick();
nextViewer.clock.onTick.raiseEvent(nextViewer.clock);
samePose(camera, before);
});
for (const direction of ['up', 'down', 'diagonal']) {
test(`invalid globe direction ${direction} is refused before camera authority handoff`, (t) => {
const { camera } = harness(t);
const before = pose(camera);
let handoffs = 0;
const result = moveCamera({ motion: 'rotate_globe', direction }, () => {
handoffs += 1;
});
assert.equal(result.ok, false);
assert.match(result.error, /left or right/);
assert.equal(handoffs, 0);
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
samePose(camera, before);
});
}
for (const mode of ['once', 'continuous']) {
for (const acquisition of ['tracking', 'reference frame']) {
test(`globe ${mode} yields to new ${acquisition} without moving the new owner`, (t) => {
const { viewer, camera, target, tick } = harness(t);
assert.equal(moveCamera({ motion: 'rotate_globe', mode }).ok, true);
tick();
tick();
assert.equal(hasRenderHold(), true);
const tracked = { id: 'new-tracked-aircraft' };
if (acquisition === 'tracking') viewer.trackedEntity = tracked;
else
camera.lookAt(
target,
new Cesium.HeadingPitchRange(radians(35), radians(-40), 1_000),
);
const before = pose(camera);
const transform = Cesium.Matrix4.clone(camera.transform);
tick();
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
assert.equal(
viewer.trackedEntity,
acquisition === 'tracking' ? tracked : undefined,
);
assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true);
samePose(camera, before);
tick();
samePose(camera, before);
});
}
}
test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => {
const { viewer, camera, target } = harness(t);
viewer.trackedEntity = { id: 'tracked-aircraft' };
camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000));
const before = pose(camera);
const transform = Cesium.Matrix4.clone(camera.transform);
const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.ok, false);
assert.match(result.error, /following tracked-aircraft/);
assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true);
samePose(camera, before);
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
});
test('the existing Globe View preset remains unchanged', () => {
assert.deepEqual(GLOBE_VIEW, {
heightM: 18_000_000,
pitchDeg: -90,
durationS: 2.8,
});
});
for (const latitude of [0, 90, -90])
for (const height of [12_500_000, 18_000_000, 30_000_000])
test(`whole-globe admission depends on framing at latitude ${latitude}, height ${height}m`, (t) => {
const { camera, finishOnce } = harness(t, { latitude, height });
const radius = Cesium.Cartesian3.magnitude(camera.positionWC);
assert.equal(
moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' })
.ok,
true,
);
finishOnce();
near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6);
});
test('polar preset admission keeps centre-distance although geodetic altitude varies', (t) => {
const { camera, tick } = harness(t, { latitude: 90 });
const radius = Cesium.Cartesian3.magnitude(camera.positionWC);
assert.ok(Math.min(...arcHeights(camera, camera.upWC, Math.PI * 2)) > 0);
assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous', speed: 'fast' })
.ok,
true,
);
for (let i = 0; i < 45; i++) tick(0.1);
assert.ok(
Cesium.Ellipsoid.WGS84.cartesianToCartographic(camera.positionWC).height <
GLOBE_VIEW.heightM - 1000,
);
assert.equal(getActiveCameraMotion().kind, 'rotate_globe');
near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6);
moveCamera({ motion: 'stop' });
assert.equal(hasRenderHold(), false);
});
test('close-up refusal preserves an inherited target frame', (t) => {
const { camera, target } = harness(t);
camera.lookAt(target, new Cesium.HeadingPitchRange(0, -0.5, 5000));
const before = pose(camera),
transform = Cesium.Matrix4.clone(camera.transform);
let calls = 0;
const result = moveCamera({ motion: 'rotate_globe' }, () => {
calls++;
});
assert.equal(
result.error,
'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’',
);
assert.equal(calls, 0);
assert.ok(Cesium.Matrix4.equals(camera.transform, transform));
samePose(camera, before);
});
test('navigation callback never releases tracking for globe rotation', (t) => {
const { viewer, camera } = harness(t);
const tracked = (viewer.trackedEntity = { id: 'tracked-aircraft' });
const before = pose(camera);
let calls = 0;
assert.equal(
moveCamera({ motion: 'rotate_globe' }, () => {
calls++;
viewer.trackedEntity = undefined;
}).ok,
false,
);
assert.equal(calls, 0);
assert.equal(viewer.trackedEntity, tracked);
samePose(camera, before);
});
test('direct globe request refuses Cockpit before navigation mutation', (t) => {
const { viewer, camera } = harness(t);
initCameraVerbs(viewer, null, { isCockpitActive: () => true });
const before = pose(camera);
let calls = 0;
assert.equal(
moveCamera({ motion: 'rotate_globe' }, () => {
calls++;
}).ok,
false,
);
assert.equal(calls, 0);
samePose(camera, before);
assert.equal(hasRenderHold(), false);
});
test('navigation handoff preserves the original world pose even when it levels a rolled camera', (t) => {
const { camera, tick } = harness(t, {
latitude: 50,
heading: 75,
pitch: -86,
roll: 35,
});
const before = pose(camera);
const result = moveCamera(
{ motion: 'rotate_globe', mode: 'continuous' },
(start) => {
camera.setView({
orientation: { heading: camera.heading, pitch: camera.pitch, roll: 0 },
});
return start();
},
);
assert.equal(result.ok, true);
samePose(camera, before);
const screen = projection(camera);
tick();
tick();
near(projection(camera).x, screen.x);
near(projection(camera).y, screen.y);
orthonormalBasis(camera);
});
test('direct globe rotation cancels an existing camera flight before acquiring motion', (t) => {
const { camera } = harness(t);
let cancellations = 0;
const original = camera.cancelFlight;
camera.cancelFlight = function () {
cancellations += 1;
return original.call(this);
};
const before = pose(camera);
assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true);
assert.equal(cancellations, 1);
samePose(camera, before);
});
test('legacy rotate remains local yaw at an unchanged camera position', (t) => {
const { camera, finishOnce } = harness(t, { latitude: 0 });
const before = pose(camera);
moveCamera({ motion: 'rotate', direction: 'right' });
finishOnce();
near(Cesium.Cartesian3.distance(camera.positionWC, before.position), 0, 1e-6);
near(
Cesium.Cartesian3.angleBetween(camera.directionWC, before.direction),
radians(15),
);
assert.ok(
Math.abs(projection(camera).x) > 0.2,
'local yaw intentionally moves Earth out of the center',
);
});
test('legacy orbit still revolves around the selected ground target', (t) => {
const { camera, target, tick } = harness(t);
camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-45), 4_000));
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
const range = Cesium.Cartesian3.distance(camera.positionWC, target);
moveCamera({ motion: 'orbit', direction: 'right', mode: 'continuous' });
tick();
tick();
assert.equal(getActiveCameraMotion()?.kind, 'orbit');
near(Cesium.Cartesian3.distance(camera.positionWC, target), range, 1e-6);
near(projection(camera, target).x, 0);
near(projection(camera, target).y, 0);
interruptCameraMotion('test-stop');
assert.equal(
Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY),
true,
);
});
// Actual UI/tool authority routes, not a direct call to the interrupt helper.
test('new UI navigation intent stops globe motion before its destination and stale ticks', async (t) => {
const { NavigationController } = await import('./ui/navigationController.js');
const { viewer, camera, tick } = harness(t);
const navigation = new NavigationController({
viewer,
tracking: {
flightsLayer: {},
militaryFlightsLayer: {},
satellitesLayer: {},
},
interruptCameraMotion(reason) {
assert.equal(
getActiveCameraMotion(),
null,
'the intent stamp must retire rotation before release',
);
return interruptCameraMotion(reason);
},
interruptGlobeRotation,
isCockpitActive: () => false,
cancelOrientation() {},
clearLocation() {},
cancelShareSelection: () => false,
getDataManager: () => null,
stopOrbit() {},
showToast() {},
});
assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true,
);
navigation._runExplicitNavigation('camera', () => {
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
});
const after = pose(camera);
tick();
samePose(camera, after);
});
test('orbit-toggle controller stops globe motion before toggling', async (t) => {
const { LocationNavigation } = await import('./ui/locationNavigation.js');
const { target } = harness(t);
let toggled = false;
moveCamera({ motion: 'rotate_globe', mode: 'continuous' });
LocationNavigation.prototype._toggleOrbit.call({
services: { interruptGlobeRotation },
_currentTarget: target,
_currentPoi: {},
orbitController: {
toggle() {
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
toggled = true;
return true;
},
},
_locationControls: { showOrbit() {} },
});
assert.equal(toggled, true);
});
test('the voice orbit switch lights and clears the ORBIT indicator', async () => {
const { LocationNavigation } = await import('./ui/locationNavigation.js');
const shown = [];
let active = false;
const nav = Object.assign(Object.create(LocationNavigation.prototype), {
services: {},
_currentTarget: {},
_currentPoi: {},
orbitController: {
get active() {
return active;
},
toggle() {
active = !active;
return active;
},
stop() {
active = false;
},
},
_locationControls: { showOrbit: (value) => shown.push(value) },
});
assert.deepEqual(nav.setOrbit(true), { ok: true, orbiting: true });
assert.deepEqual(nav.setOrbit(true), { ok: true, orbiting: true });
assert.deepEqual(nav.setOrbit(false), { ok: true, orbiting: false });
assert.deepEqual(shown, [true, false]);
});
for (const owner of ['tracking', 'cockpit', 'close-up'])
test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => {
const { createGevActionRunner } = await import('./voice/gevActions.js');
const { runExplicitNavigation } = await import('./navigationPolicy.js');
const { viewer, camera } = harness(t, {
height: owner === 'close-up' ? 5000 : GLOBE_VIEW.heightM,
});
const before = pose(camera);
if (owner === 'tracking') viewer.trackedEntity = { id: 'tracked-aircraft' };
const tracked = viewer.trackedEntity;
let releases = 0,
stamps = 0;
const styleManager = {
getCockpitState: () => ({ active: owner === 'cockpit' }),
runImmediateNavigation(noun, navigate) {
return runExplicitNavigation({
cockpitActive: owner === 'cockpit',
noun,
stamp() {
stamps++;
},
release() {
releases++;
viewer.trackedEntity = undefined;
},
navigate,
});
},
};
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: {
layers: new Map(),
isEnabled: () => false,
getAll: () => [],
},
});
const result = await runner('move_camera', {
motion: 'rotate_globe',
direction: 'left',
});
assert.equal(result.ok, false);
assert.equal(releases, 0);
assert.equal(stamps, 0);
assert.equal(viewer.trackedEntity, tracked);
samePose(camera, before);
if (owner === 'close-up') {
assert.equal(result.reason, 'globe-view-required');
assert.equal(
result.error,
'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’',
);
}
});
for (const [label, ellipsoid] of [
['nonfinite radii', { minimumRadius: 1, maximumRadius: Infinity }],
['nonpositive radii', { minimumRadius: 0, maximumRadius: 1 }],
['non-WGS84 radii', new Cesium.Ellipsoid(30_000_000, 30_000_000, 1_000_000)],
])
test(`globe rotation refuses ${label} before handoff`, (t) => {
const { viewer, camera } = harness(t, { height: 100_000_000 });
const before = pose(camera);
viewer.scene.globe.ellipsoid = ellipsoid;
let handedOff = false;
const result = moveCamera({ motion: 'rotate_globe' }, () => {
handedOff = true;
});
assert.equal(result.ok, false);
assert.equal(
result.reason,
label === 'non-WGS84 radii' ? 'unsupported-view' : 'unsafe-ellipsoid',
);
assert.equal(handedOff, false);
assert.equal(hasRenderHold(), false);
samePose(camera, before);
});
test('an external radius change retires globe rotation without another write', (t) => {
const { camera, tick } = harness(t);
moveCamera({ motion: 'rotate_globe', mode: 'continuous' });
camera.moveForward(1000);
const changed = pose(camera);
tick();
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
samePose(camera, changed);
});
test('a non-WGS84 ellipsoid fails closed rather than borrowing the WGS84 silhouette', (t) => {
const ellipsoid = new Cesium.Ellipsoid(10_000_000, 10_000_000, 8_000_000);
const { camera } = harness(t, { ellipsoid, latitude: 60 });
const before = pose(camera);
const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.ok, false);
assert.equal(result.reason, 'unsupported-view');
samePose(camera, before);
});
test('untouched polar framing permits repeated direction and speed changes below preset altitude', (t) => {
const { camera, finishOnce, tick } = harness(t, { latitude: 90 });
const radius = Cesium.Cartesian3.magnitude(camera.positionWC);
assert.equal(
moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok,
true,
);
finishOnce();
assert.ok(
Cesium.Cartographic.fromCartesian(camera.positionWC).height <
GLOBE_VIEW.heightM - 1,
);
assert.equal(
moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok,
true,
);
finishOnce();
assert.equal(
moveCamera({
motion: 'rotate_globe',
direction: 'left',
speed: 'fast',
mode: 'continuous',
}).ok,
true,
);
tick();
tick();
assert.equal(getActiveCameraMotion().speed, 'fast');
near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6);
assert.equal(moveCamera({ motion: 'stop' }).stopped, true);
});
function projected(h) {
return projectEarthDiscToViewport(
h.viewer,
h.canvas.clientWidth,
h.canvas.clientHeight,
);
}
test('a smaller globe wholly inside the keyhole needs no overlay entry margin or reset', (t) => {
const h = harness(t, { latitude: 0, height: 12_500_000 });
const disc = projected(h);
const clearance =
disc.keyholeRadius -
Math.hypot(
disc.earthCenterX - disc.keyholeCenterX,
disc.earthCenterY - disc.keyholeCenterY,
) -
disc.earthRadius;
assert.ok(
clearance > 0 && clearance < 24,
`near-edge inside fit: ${clearance}`,
);
assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true);
});
test('keyhole overlap refuses without a camera handoff or pose change', (t) => {
const h = harness(t, { latitude: 0, height: 11_000_000 });
const before = pose(h.camera);
const disc = projected(h);
assert.ok(disc.earthRadius > disc.keyholeRadius);
let handoffs = 0;
const result = moveCamera({ motion: 'rotate_globe' }, () => {
handoffs++;
});
assert.equal(result.reason, 'globe-view-required');
assert.equal(
result.error,
'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’',
);
assert.equal(handoffs, 0);
samePose(h.camera, before);
});
for (const [angle, fits] of [
[5, true],
[25, false],
])
test(`actual off-centre globe at ${angle} degrees has fit ${fits}`, (t) => {
const h = harness(t, { height: 30_000_000 });
h.camera.lookRight(radians(angle));
const before = pose(h.camera);
assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, fits);
samePose(h.camera, before);
});
test('wide viewport uses vertical fovy rather than horizontal fov', (t) => {
const h = harness(t, {
viewportWidth: 2000,
viewportHeight: 500,
latitude: 0,
});
assert.ok(h.camera.frustum.fovy < h.camera.frustum.fov);
assert.ok(projected(h).earthRadius > projected(h).keyholeRadius);
assert.equal(
moveCamera({ motion: 'rotate_globe' }).reason,
'globe-reset-unavailable',
);
});
for (const invalid of [
'orthographic',
'behind',
'zero-css-width',
'offset-frustum',
])
test(`unsupported ${invalid} fails without a reset offer`, (t) => {
const h = harness(t);
if (invalid === 'orthographic')
h.camera.frustum = new Cesium.OrthographicFrustum({
width: 50_000_000,
aspectRatio: 1.6,
});
if (invalid === 'behind') h.camera.lookRight(Math.PI);
if (invalid === 'zero-css-width') h.canvas.clientWidth = 0;
if (invalid === 'offset-frustum') h.camera.frustum.xOffset = 0.1;
const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.ok, false);
assert.equal(result.reason, 'unsupported-view');
});
test('a new request rechecks close-up framing rather than using previous eligibility', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true,
);
h.canvas.dispatchEvent(new Event('wheel'));
h.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000) });
const before = pose(h.camera);
assert.equal(
moveCamera({ motion: 'rotate_globe' }).reason,
'globe-view-required',
);
samePose(h.camera, before);
assert.equal(getActiveCameraMotion(), null);
});
// Exact WGS84 equatorial tangent points expose the shared disc approximation.
// Keep the concrete counterexample public; an overlay approximation is not admission proof.
test('off-axis ellipsoid limb overlap must not be admitted as a fully framed globe', (t) => {
const h = harness(t, { latitude: 0, height: 18_000_000 });
h.camera.lookRight(radians(6));
const disc = projected(h);
assert.ok(
disc.earthRadius +
Math.hypot(
disc.earthCenterX - disc.keyholeCenterX,
disc.earthCenterY - disc.keyholeCenterY,
) <
disc.keyholeRadius,
'the shared approximation reports a fit',
);
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const x = (radius * radius) / h.camera.positionWC.x;
const y = Math.sqrt(radius * radius - x * x);
const limbRadii = [-y, y].map((value) => {
const pixel = Cesium.SceneTransforms.worldToWindowCoordinates(
h.viewer.scene,
new Cesium.Cartesian3(x, value, 0),
);
return Math.hypot(
pixel.x - disc.keyholeCenterX,
pixel.y - disc.keyholeCenterY,
);
});
assert.ok(
Math.max(...limbRadii) > disc.keyholeRadius,
'an actual ellipsoid tangent point overlaps the keyhole',
);
assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, false);
});
for (const roll of [0, 35])
test(`off-axis boundary overlap is refused with roll ${roll}`, (t) => {
const h = harness(t, { latitude: 0, height: 18_000_000, roll });
h.camera.lookRight(radians(6));
const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.reason, 'globe-view-required');
});
for (const latitude of [0, 90])
for (const pitch of [0, 15])
test(`close-up horizon/upward view offers reset at latitude ${latitude}, pitch ${pitch}`, (t) => {
const h = harness(t, { latitude, pitch, height: 5000 });
const before = pose(h.camera);
const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.reason, 'globe-view-required');
assert.equal(
result.error,
'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’',
);
samePose(h.camera, before);
});
for (const [viewportWidth, viewportHeight, fits] of [
[390, 844, true],
[844, 390, true],
[800, 1200, true],
[1280, 800, true],
[3440, 1440, false],
])
test(`unchanged reset preset is rechecked at ${viewportWidth}x${viewportHeight}`, (t) => {
const h = harness(t, {
viewportWidth,
viewportHeight,
height: GLOBE_VIEW.heightM,
latitude: 0,
});
const disc = projected(h);
assert.equal(
disc.earthRadius < disc.keyholeRadius,
fits,
'reset preset keyhole fit',
);
const before = pose(h.camera);
const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.ok, fits);
if (!fits) assert.equal(result.reason, 'globe-reset-unavailable');
samePose(h.camera, before);
});
test('keyhole overlap after resize stops rotation before another pose write', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true,
);
h.canvas.clientWidth = 2000;
h.canvas.clientHeight = 500;
h.camera.frustum.aspectRatio = 4;
assert.ok(projected(h).earthRadius > projected(h).keyholeRadius);
const before = pose(h.camera);
h.tick();
assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false);
samePose(h.camera, before);
});
for (const clipped of ['near', 'far', 'entirely-beyond-far'])
test(`depth-${clipped} globe is unsupported without a reset offer`, (t) => {
const h = harness(t, { latitude: 0 });
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC);
if (clipped === 'near') h.camera.frustum.near = distance - radius + 100;
if (clipped === 'far') h.camera.frustum.far = distance + radius - 100;
if (clipped === 'entirely-beyond-far')
h.camera.frustum.far = distance - 2 * radius;
const before = pose(h.camera);
const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.ok, false);
assert.equal(result.reason, 'unsupported-view');
samePose(h.camera, before);
assert.equal(hasRenderHold(), false);
});
test('fully depth-contained globe remains eligible at actual near/far limits', (t) => {
const h = harness(t, { latitude: 0 });
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC);
h.camera.frustum.near = distance - radius - 1;
h.camera.frustum.far = distance + radius + 1;
assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true);
});
for (const [near, far] of [
[0, 1e9],
[-1, 1e9],
[NaN, 1e9],
[1, Infinity],
[10, 10],
[10, 9],
])
test(`invalid depth limits ${near}/${far} are unsupported`, (t) => {
const h = harness(t);
h.camera.frustum.near = near;
h.camera.frustum.far = far;
const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.ok, false);
assert.equal(result.reason, 'unsupported-view');
});
test('bare globe rotation defaults right and continues beyond a nudge', (t) => {
const { camera, tick } = harness(t, { latitude: 0 });
const before = projection(camera);
const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.ok, true);
assert.equal(result.direction, 'right');
assert.equal(result.mode, 'continuous');
assert.equal(result.speed, 'normal');
for (let i = 0; i < 30; i++) tick(0.1);
assert.equal(getActiveCameraMotion().mode, 'continuous');
near(projection(camera).x, before.x);
near(projection(camera).y, before.y);
assert.equal(moveCamera({ motion: 'stop' }).stopped, true);
assert.equal(hasRenderHold(), false);
});
for (const [args, direction, mode, speed] of [
[{ mode: 'once' }, 'right', 'once', 'normal'],
[{ direction: 'left', speed: 'slow' }, 'left', 'continuous', 'slow'],
[{ direction: 'left', mode: 'once', speed: 'fast' }, 'left', 'once', 'fast'],
[
{ direction: 'right', mode: 'continuous', speed: 'fast' },
'right',
'continuous',
'fast',
],
])
test(`explicit globe controls prevail: ${JSON.stringify(args)}`, (t) => {
const { finishOnce, tick } = harness(t);
const result = moveCamera({ motion: 'rotate_globe', ...args });
assert.deepEqual(
[result.ok, result.direction, result.mode, result.speed],
[true, direction, mode, speed],
);
if (mode === 'once') finishOnce();
else {
tick();
tick();
assert.equal(getActiveCameraMotion().mode, 'continuous');
}
});
for (const motion of ['rotate', 'pan', 'tilt', 'orbit'])
test(`legacy ${motion} retains its once default`, (t) => {
harness(t);
const result = moveCamera({
motion,
direction: motion === 'tilt' ? 'up' : 'left',
});
assert.equal(result.ok, true);
assert.equal(result.mode, 'once');
});
for (const mode of [null, '', 'invalid', 'CONTINUOUS'])
test(`invalid globe mode ${JSON.stringify(mode)} retains legacy once fallback`, (t) => {
const { finishOnce } = harness(t);
assert.equal(moveCamera({ motion: 'rotate_globe', mode }).mode, 'once');
finishOnce();
});
test('canvas-only clipping after resize preserves a keyhole-fitting rotation', (t) => {
const h = harness(t, { latitude: 0 });
assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true);
h.canvas.clientWidth = 390;
h.canvas.clientHeight = 844;
h.camera.frustum.aspectRatio = 390 / 844;
const disc = projected(h);
assert.ok(disc.earthRadius < disc.keyholeRadius);
assert.ok(disc.earthRadius > h.canvas.clientWidth / 2);
const before = pose(h.camera);
h.tick();
h.tick();
assert.notDeepEqual(pose(h.camera).position, before.position);
assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe');
assert.equal(hasRenderHold(), true);
});
test('an invalid camera request leaves the active globe rotation untouched', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true);
const before = pose(h.camera);
const active = getActiveCameraMotion();
assert.equal(moveCamera({ motion: 'pan' }).ok, false);
assert.deepEqual(getActiveCameraMotion(), active);
samePose(h.camera, before);
assert.equal(hasRenderHold(), true);
});
test('close-up ultrawide refusal does not offer an unfit reset or move the camera', (t) => {
const h = harness(t, {
viewportWidth: 3440,
viewportHeight: 1440,
height: 5000,
});
const before = pose(h.camera);
let handoffs = 0;
const result = moveCamera({ motion: 'rotate_globe' }, () => {
handoffs++;
});
assert.equal(result.reason, 'globe-reset-unavailable');
assert.equal(handoffs, 0);
samePose(h.camera, before);
assert.equal(hasRenderHold(), false);
});
/**
* An oracle independent of the code under test: the share of an 80×45 grid of
* Cesium pick rays across the canvas that hit the ellipsoid surface. With
* `keyhole`, only the pixels inside the scope's visible circle count.
*/
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
let hits = 0;
let counted = 0;
for (let row = 0; row < 45; row += 1) {
for (let column = 0; column < 80; column += 1) {
const x = ((column + 0.5) * width) / 80;
const y = ((row + 0.5) * height) / 45;
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
continue;
counted += 1;
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
hits += 1;
}
}
return hits / counted;
}
/** Run a test with the scope mask on or off, restoring the default after. */
function withScope(t, enabled) {
setScopeMaskEnabled(enabled);
t.after(() => setScopeMaskEnabled(true));
}
// The frame limit is measured on a coarser grid than the oracle.
const SHARE_SLACK = 0.02;
// Far-out poses, height in metres and pitch in degrees, where a few tilts up
// are accepted before the limit refuses the next one.
const FAR_OUT_POSES = [
[35_884_000, -86.26],
[35_884_000, -89],
[30_000_000, -86],
[25_000_000, -80],
[40_000_000, -88],
];
test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => {
withScope(t, false); // the whole frame is visible
// Slightly off nadir: from straight down a tilt is clamped before it moves.
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
const results = [];
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
results.push(result);
if (result.ok) finishOnce();
assert.ok(
surfacePixelShare(camera, 1600, 900) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`after tilt ${i + 1}`,
);
}
// From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down
// to about -67°: the first tilt stops there, and later ones are refused.
assert.deepEqual(
results.map((result) => result.ok),
[true, false, false, false],
);
assert.match(results[1].error, /globe out of view/);
assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66));
});
// The frame limit judges a tilt by the way its pitch moves, not by the
// direction word: a tilt sent as left or right turns down.
for (const direction of ['left', 'right']) {
test(`a tilt ${direction} from the frame limit is judged as the look down it is`, (t) => {
withScope(t, false);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
finishOnce();
const atLimit = camera.pitch;
const result = moveCamera({ motion: 'tilt', direction });
assert.equal(result.ok, true, result.error);
finishOnce();
assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down');
});
}
test('voice turn in place from orbit stops with the planet still on screen', (t) => {
withScope(t, false); // the whole frame is visible
const { camera, tick } = harness(t, { pitch: -75 });
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
true,
);
const heading = camera.heading;
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
surfacePixelShare(camera, 1280, 800) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`tick ${i + 1}`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.notEqual(camera.heading, heading, 'the turn moved before stopping');
});
test('a long voice turn below the limit keeps the share it started with', (t) => {
withScope(t, false); // the whole frame is visible
// About 2% of a 16:9 frame on the planet: the limit is that share, less two
// grid cells of sampling noise, for the whole motion rather than per step.
const { camera, tick } = harness(t, {
pitch: -60,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
true,
);
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9,
`tick ${i + 1}`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('repeated voice turns from one spot cannot each take a little more', (t) => {
withScope(t, false); // the whole frame is visible
// About 2% of a 16:9 frame on the planet. Every turn from this position is
// measured against the same baseline, so asking again stops being allowed.
const { camera, finishOnce, tick } = harness(t, {
pitch: -60,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
const floor = start - 2 / (32 * 18) - 1e-9;
const results = [];
for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) {
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({ motion: 'rotate', direction: 'left', mode });
results.push(result.ok);
if (!result.ok) continue;
if (mode === 'once') finishOnce();
else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`);
}
}
assert.ok(results.includes(false), 'a turn at the limit is refused');
assert.equal(results.at(-1), false);
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('a voice turn after something else turned the camera starts over', (t) => {
withScope(t, false); // the whole frame is visible
// The same spot, first framed straight down, then turned to about 2% of the
// frame by something other than voice: a turn left is measured from there.
const { camera, finishOnce } = harness(t, {
pitch: -90,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
true,
);
finishOnce();
camera.setView({
orientation: { heading: 0, pitch: radians(-60), roll: 0 },
});
const start = globeFrameShare(camera);
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
true,
);
finishOnce();
assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9);
});
test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => {
withScope(t, false); // the whole frame is visible
// A wheel notch or an orbit drag between requests starts each turn afresh;
// the 1% floor still holds, and below it a turn may only keep what it has.
for (const [nudge, pitch] of [
[(camera) => camera.moveBackward(2), -60],
[(camera) => camera.rotateRight(radians(0.2)), -60],
[(camera) => camera.moveBackward(2), -58],
]) {
const { camera, tick } = harness(t, {
pitch,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
const floor = Math.min(0.01, start) - 1e-9;
for (let i = 0; i < 12; i += 1) {
nudge(camera);
if (
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
.ok
)
for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
assert.ok(
globeFrameShare(camera) >= floor,
`start ${start}, turn ${i + 1}`,
);
}
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
}
});
test('after the frame changes size, a voice turn toward the planet is allowed', (t) => {
withScope(t, false); // the whole frame is visible
// A tilt stops at its limit; then the window widens at the same pose, so the
// frame shows less than the carried limit. Toward the planet must still go.
const { camera, canvas, viewer, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
true,
);
finishOnce();
canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200;
camera.frustum.aspectRatio = 3200 / 900;
const narrowed = globeFrameShare(camera);
assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`);
const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' });
assert.equal(down.ok, true, down.error);
finishOnce();
assert.ok(globeFrameShare(camera) > narrowed);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok,
true,
);
finishOnce();
const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
if (up.ok) finishOnce();
assert.ok(surfacePixelShare(camera, 3200, 900) > 0);
});
test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => {
withScope(t, true);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
const results = [];
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
results.push(result.ok);
if (result.ok) finishOnce();
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`after tilt ${i + 1}`,
);
}
assert.equal(results[0], true);
assert.equal(results.at(-1), false, 'a tilt past the limit is refused');
});
test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => {
// An oblique orbit view: turning in place swings the planet toward a corner,
// where the full frame still shows it but the scope mask hides it.
for (const [height, pitch] of [
[5_000_000, -70],
[3_000_000, -75],
[18_000_000, -80],
]) {
withScope(t, true);
const { camera, tick } = harness(t, {
height,
pitch,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
.ok,
true,
);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`${height / 1000} km at ${pitch}°`,
);
}
});
test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => {
// A small planet disc: its sampled share wobbles as it crosses the grid.
// Whatever the limit decides, "done" must mean the camera turned, and some
// of these tilts must be accepted for that to be checked at all.
let accepted = 0;
for (const [height, pitch] of FAR_OUT_POSES) {
withScope(t, true);
const { camera, tick } = harness(t, {
latitude: 45.37,
longitudeDeg: -33.33,
height,
heading: 89.9,
pitch,
viewportWidth: 1280,
viewportHeight: 1024,
});
for (let i = 0; i < 4; i += 1) {
const before = camera.pitch;
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
if (!result.ok) {
assert.match(result.error, /zoom in first/);
continue;
}
accepted += 1;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
assert.ok(
camera.pitch - before > radians(0.5) - 1e-9,
`${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`,
);
}
assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0);
}
assert.ok(accepted > 0, 'no far-out tilt was accepted');
});
test('a voice turn reported as done turns at least the half degree its check proved', (t) => {
// A grazing sliver: the first frame's small step lands in a dip of the
// sampled share, which used to stop the turn before it moved at all.
for (const scope of [true, false]) {
withScope(t, scope);
const { camera, tick } = harness(t, {
latitude: 20,
longitudeDeg: 10,
height: 18_000_000,
pitch: -58,
heading: 37,
viewportWidth: 1600,
viewportHeight: 900,
});
const before = Cesium.Cartesian3.clone(camera.directionWC);
const result = moveCamera({
motion: 'rotate',
direction: 'left',
mode: 'once',
});
if (!result.ok) {
assert.match(result.error, /globe out of view/);
continue;
}
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
assert.ok(
turned >= radians(0.5) - 1e-9,
`scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`,
);
assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0);
}
});
/** Track an entity below `camera`, locking it into that entity's frame. */
function trackFrom(viewer, camera) {
viewer.trackedEntity = { id: 'high-satellite' };
const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000);
camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below));
}
test('a voice turn that releases a tracked camera is checked as released', (t) => {
// Voice releases the follow camera inside the navigation that runs the
// motion, so the request is judged by the pose the release will leave.
let accepted = 0;
for (const [scope, direction, motion] of [
[true, 'up', 'tilt'],
[false, 'up', 'tilt'],
[false, 'left', 'rotate'],
]) {
withScope(t, scope);
const { camera, viewer, tick } = harness(t, {
latitude: 20,
longitudeDeg: 10,
height: 18_000_000,
pitch: -58,
heading: 37,
viewportWidth: 1600,
viewportHeight: 900,
});
trackFrom(viewer, camera);
const releaseThenRun = (start) => {
viewer.trackedEntity = undefined;
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
return start();
};
const before = Cesium.Cartesian3.clone(camera.directionWC);
const result = moveCamera(
{ motion, direction, mode: 'once' },
releaseThenRun,
);
const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`;
if (!result.ok) {
assert.match(result.error, /globe out of view/, label);
continue;
}
accepted += 1;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`);
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0,
`${label}: the planet stays on screen`,
);
}
assert.ok(accepted > 0, 'no released turn was accepted');
});
test('a refused voice turn leaves a tracked camera tracking', (t) => {
for (const scope of [true, false]) {
withScope(t, scope);
const { camera, viewer, tick } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
// Tilt up to the limit untracked, then track from there.
for (let i = 0; i < 40; i += 1) {
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
break;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
}
trackFrom(viewer, camera);
const tracked = viewer.trackedEntity;
const transform = Cesium.Matrix4.clone(camera.transform);
let released = false;
const result = moveCamera(
{ motion: 'tilt', direction: 'up', mode: 'once' },
(start) => {
released = true;
viewer.trackedEntity = undefined;
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
return start();
},
);
const label = `scope ${scope ? 'on' : 'off'}`;
assert.equal(result.ok, false, label);
assert.match(result.error, /globe out of view/, label);
assert.equal(released, false, label);
assert.equal(viewer.trackedEntity, tracked, label);
assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label);
}
});
test('a turn proven at one position is checked again after a release moves the camera', (t) => {
withScope(t, false);
const { camera, tick } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
// Tilt up to the limit, then check from 4,000 km closer along the same view
// direction, where the next tilt fits; the release moves the camera back.
for (let i = 0; i < 40; i += 1) {
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
break;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
}
camera.moveForward(4_000_000);
const result = moveCamera(
{ motion: 'tilt', direction: 'up', mode: 'once' },
(start) => {
camera.moveBackward(4_000_000);
return start();
},
);
assert.equal(result.ok, false);
assert.match(result.error, /globe out of view/);
});
test('the voice frame limit leaves Columbus view alone', (t) => {
// At its limit in 3D a further tilt up is refused; the same tilt in Columbus
// view is not limited by the frame.
withScope(t, false);
const { viewer, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
true,
);
finishOnce();
const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.match(refused.error || '', /globe out of view/);
viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.doesNotMatch(result.error || '', /globe out of view/);
});
test('voice tilt below the free-look height is not limited by the frame', (t) => {
const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 });
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.equal(result.ok, true);
finishOnce();
near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt');
});