mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
LocationControls offers the indicator with showOrbit(active) in its presented tray instead of building it on the body, and the scene chrome teleports it there. Orbit toggles, including the voice switch, light it through the controls.
1350 lines
44 KiB
JavaScript
1350 lines
44 KiB
JavaScript
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';
|
||
|
||
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);
|
||
});
|