mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Main's camera, voice, Cockpit and layer work now runs on the globe contract where this branch draws through it: - The voice tilt to an angle, its pivot about the place under the crosshair and the frame limit from orbit run through the camera rig; src/globeInView.js measures a camera pose with src/geo instead of a Cesium camera. The free-look height limit and the globe-in-view recovery stay Cesium-only for now. - Cockpit takes main's own-layer entry rule. Double-clicking the followed track goes through cockpitHost.routeDoubleClick, so it works on both engines. - Space Missions' failed-entry rollback and GhostMaps' age filter run in the engine-neutral layer code. On Cesium an expired report is dropped in place; on Navara its product is redrawn. - Wildlife Sightings joins the catalog Cesium-only: Navara leaves it out, and the shell builds its card only for the real layer, so a Navara page no longer fails to start on its stand-in. - One instruction sentence is shorter, which keeps the Realtime prompt within its 55,500-character budget with the Sentinel-2 basemap.
1073 lines
36 KiB
JavaScript
1073 lines
36 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { createApplication } from './application.js';
|
||
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
|
||
import * as Cesium from 'cesium';
|
||
import {
|
||
cesiumFramePose,
|
||
installFreeLookHeightLimit,
|
||
installGlobeInViewRecovery,
|
||
installTrackpadPinchZoom,
|
||
} from './viewer.js';
|
||
import * as globeInView from '../globeInView.js';
|
||
|
||
const { FREE_LOOK_MAX_HEIGHT_METERS } = globeInView;
|
||
// The frame measure judges a pose; these tests read the Cesium camera's.
|
||
const globeFrameShare = (camera, options) =>
|
||
globeInView.globeFrameShare(cesiumFramePose(camera), options);
|
||
const globeOutOfFrame = (camera, options) =>
|
||
globeInView.globeOutOfFrame(cesiumFramePose(camera), options);
|
||
const lookAngleWithinFrame = (camera, look, angle, options) =>
|
||
globeInView.lookAngleWithinFrame(
|
||
cesiumFramePose(camera),
|
||
look,
|
||
angle,
|
||
options,
|
||
);
|
||
const lookStepLosesGlobe = (camera, look, angle, options) =>
|
||
globeInView.lookStepLosesGlobe(cesiumFramePose(camera), look, angle, options);
|
||
|
||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||
function fixture(overrides = {}) {
|
||
const events = [];
|
||
const constructors = Object.fromEntries(
|
||
phases.map((phase) => [
|
||
`create${phase}`,
|
||
({ defer }) => {
|
||
events.push(`start:${phase}`);
|
||
defer(() => events.push(`stop:${phase}`));
|
||
return { name: phase };
|
||
},
|
||
]),
|
||
);
|
||
return { events, app: createApplication({ ...constructors, ...overrides }) };
|
||
}
|
||
function deferred() {
|
||
let resolve;
|
||
const promise = new Promise((done) => {
|
||
resolve = done;
|
||
});
|
||
return { promise, resolve };
|
||
}
|
||
|
||
test('importing and constructing the package is inactive without browser globals', () => {
|
||
const result = spawnSync(
|
||
process.execPath,
|
||
[
|
||
// Views are single-file components; Node needs the loader hosts use.
|
||
'--import=gods-eye-view/build/register-vue',
|
||
'--input-type=module',
|
||
'-e',
|
||
`
|
||
globalThis.fetch = () => { throw new Error('unexpected request'); };
|
||
globalThis.setTimeout = () => { throw new Error('unexpected timer'); };
|
||
const { createApplication } = await import('gods-eye-view/application');
|
||
const fail = () => { throw new Error('unexpected construction'); };
|
||
const app = createApplication({ createScene: fail, createControls: fail, createData: fail, createTools: fail });
|
||
if (app.getState().status !== 'created') process.exit(1);
|
||
`,
|
||
],
|
||
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
|
||
);
|
||
assert.equal(result.status, 0, result.stderr);
|
||
});
|
||
|
||
test('constructors are validated before allocating anything', () => {
|
||
assert.throws(() => createApplication({}), /Missing scene constructor/);
|
||
});
|
||
|
||
test('startup shares one promise and teardown keeps data alive for controls', async () => {
|
||
const { app, events } = fixture({
|
||
createTools({ scene, controls, data, defer }) {
|
||
assert.equal(scene.name, 'Scene');
|
||
assert.equal(controls.name, 'Controls');
|
||
assert.equal(data.name, 'Data');
|
||
defer(() => events.push('stop:Tools'));
|
||
return { name: 'Tools' };
|
||
},
|
||
});
|
||
const states = [];
|
||
app.subscribe((state) => states.push(`${state.status}:${state.phase}`));
|
||
const started = app.start();
|
||
assert.equal(started, app.start());
|
||
const components = await started;
|
||
assert.ok(Object.isFrozen(components));
|
||
assert.equal(components.tools.name, 'Tools');
|
||
assert.equal(app.getState().status, 'ready');
|
||
const stopped = app.destroy();
|
||
assert.equal(stopped, app.destroy());
|
||
await stopped;
|
||
assert.deepEqual(events.slice(-4), [
|
||
'stop:Tools',
|
||
'stop:Controls',
|
||
'stop:Data',
|
||
'stop:Scene',
|
||
]);
|
||
assert.deepEqual(app.getComponents(), {});
|
||
assert.equal(states.at(-1), 'destroyed:null');
|
||
await assert.rejects(app.start(), /destroyed/);
|
||
});
|
||
|
||
test('later components receive the scene engine beside its viewer', async () => {
|
||
const viewer = { name: 'viewer' };
|
||
const engine = { kind: 'cesium' };
|
||
const seen = [];
|
||
const receive =
|
||
(phase) =>
|
||
({ scene }) => {
|
||
seen.push([phase, scene.viewer, scene.engine]);
|
||
return { name: phase };
|
||
};
|
||
const { app } = fixture({
|
||
createScene: () => ({ viewer, engine }),
|
||
createControls: receive('Controls'),
|
||
createData: receive('Data'),
|
||
createTools: receive('Tools'),
|
||
});
|
||
const components = await app.start();
|
||
assert.equal(components.scene.engine, engine);
|
||
assert.deepEqual(seen, [
|
||
['Controls', viewer, engine],
|
||
['Data', viewer, engine],
|
||
['Tools', viewer, engine],
|
||
]);
|
||
await app.destroy();
|
||
});
|
||
|
||
for (const phase of phases) {
|
||
test(`failure during ${phase} releases partially acquired resources`, async () => {
|
||
const { app, events } = fixture({
|
||
[`create${phase}`]({ defer }) {
|
||
defer(() => events.push('partial:released'));
|
||
throw new Error('broken constructor');
|
||
},
|
||
});
|
||
await assert.rejects(app.start(), /broken constructor/);
|
||
assert.equal(
|
||
events.filter((value) => value === 'partial:released').length,
|
||
1,
|
||
);
|
||
assert.equal(app.getState().status, 'failed');
|
||
assert.deepEqual(app.getComponents(), {});
|
||
await app.destroy();
|
||
assert.equal(
|
||
events.filter((value) => value === 'partial:released').length,
|
||
1,
|
||
);
|
||
});
|
||
}
|
||
|
||
test('destroy before startup prevents every constructor', async () => {
|
||
const { app, events } = fixture();
|
||
const started = app.start();
|
||
const failed = assert.rejects(started, { name: 'AbortError' });
|
||
await app.destroy();
|
||
await failed;
|
||
assert.deepEqual(events, []);
|
||
});
|
||
|
||
test('destroy aborts an in-flight constructor and cleans late acquisitions before resolving', async () => {
|
||
const gate = deferred();
|
||
const entered = deferred();
|
||
let observedSignal;
|
||
const { app, events } = fixture({
|
||
async createScene({ signal, defer }) {
|
||
observedSignal = signal;
|
||
entered.resolve();
|
||
await gate.promise;
|
||
defer(() => events.push('late:released'));
|
||
return {};
|
||
},
|
||
});
|
||
const started = app.start();
|
||
const failed = assert.rejects(started, { name: 'AbortError' });
|
||
await entered.promise;
|
||
let settled = false;
|
||
const stopped = app.destroy().then(() => {
|
||
settled = true;
|
||
});
|
||
assert.equal(observedSignal.aborted, true);
|
||
await Promise.resolve();
|
||
assert.equal(settled, false);
|
||
gate.resolve();
|
||
await stopped;
|
||
await failed;
|
||
assert.deepEqual(events, ['late:released']);
|
||
});
|
||
|
||
test('cleanup errors are reported after all remaining resources are attempted', async () => {
|
||
const { app, events } = fixture({
|
||
createTools({ defer }) {
|
||
defer(() => events.push('tools:earlier'));
|
||
defer(() => {
|
||
throw new Error('dispose failed');
|
||
});
|
||
return {};
|
||
},
|
||
});
|
||
await app.start();
|
||
await assert.rejects(app.destroy(), (error) => {
|
||
assert.ok(error instanceof AggregateError);
|
||
assert.equal(error.errors[0].message, 'dispose failed');
|
||
return true;
|
||
});
|
||
assert.deepEqual(events.slice(-4), [
|
||
'tools:earlier',
|
||
'stop:Controls',
|
||
'stop:Data',
|
||
'stop:Scene',
|
||
]);
|
||
assert.equal(app.getState().status, 'failed');
|
||
});
|
||
|
||
test('a failed teardown can be retried and repeats only the failed release', async () => {
|
||
let failures = 1;
|
||
const { app, events } = fixture({
|
||
createData({ defer }) {
|
||
defer(() => events.push('data:released'));
|
||
defer(() => {
|
||
events.push('data:detach');
|
||
if (failures-- > 0) throw new Error('detach failed');
|
||
});
|
||
return {};
|
||
},
|
||
});
|
||
await app.start();
|
||
await assert.rejects(app.destroy(), AggregateError);
|
||
assert.equal(app.getState().status, 'failed');
|
||
const firstAttempt = events.splice(0);
|
||
assert.deepEqual(firstAttempt.slice(-4), [
|
||
'stop:Controls',
|
||
'data:detach',
|
||
'data:released',
|
||
'stop:Scene',
|
||
]);
|
||
await app.destroy();
|
||
assert.deepEqual(events, ['data:detach']);
|
||
assert.equal(app.getState().status, 'destroyed');
|
||
await assert.rejects(app.start(), /destroyed/);
|
||
});
|
||
|
||
test('subscribers can destroy during startup without allowing the next constructor', async () => {
|
||
const { app, events } = fixture();
|
||
app.subscribe((state) => {
|
||
if (state.phase === 'controls') void app.destroy();
|
||
});
|
||
await assert.rejects(app.start(), { name: 'AbortError' });
|
||
await app.destroy();
|
||
assert.deepEqual(events, ['start:Scene', 'stop:Scene']);
|
||
});
|
||
|
||
test('destroy during ready notification rejects startup and tears everything down', async () => {
|
||
const { app } = fixture();
|
||
app.subscribe((state) => {
|
||
if (state.status === 'ready') void app.destroy();
|
||
});
|
||
await assert.rejects(app.start(), { name: 'AbortError' });
|
||
await app.destroy();
|
||
assert.equal(app.getState().status, 'destroyed');
|
||
});
|
||
|
||
test('cleanup registration closes when a constructor settles', async () => {
|
||
let register;
|
||
const { app } = fixture({
|
||
createScene({ defer }) {
|
||
register = defer;
|
||
return {};
|
||
},
|
||
});
|
||
await app.start();
|
||
assert.throws(() => register(() => {}), /during component construction/);
|
||
await app.destroy();
|
||
});
|
||
|
||
test('different application instances do not share lifecycle state', async () => {
|
||
const first = fixture();
|
||
const second = fixture();
|
||
await Promise.all([first.app.start(), second.app.start()]);
|
||
await first.app.destroy();
|
||
assert.equal(second.app.getState().status, 'ready');
|
||
assert.equal(second.events.length, 4);
|
||
await second.app.destroy();
|
||
});
|
||
|
||
test('the separate viewer export imports without constructing a browser viewer', async () => {
|
||
const { createApplicationViewer } =
|
||
await import('gods-eye-view/application/viewer');
|
||
assert.equal(typeof createApplicationViewer, 'function');
|
||
assert.throws(() => createApplicationViewer({}), /containers are required/);
|
||
});
|
||
|
||
test('the viewer gets the starry sky as an option, which keeps the Sun and Moon', async () => {
|
||
const { applicationViewerOptions } = await import('./viewer.js');
|
||
const { StarrySky } = await import('../sky/starrySky.js');
|
||
const creditContainer = {};
|
||
const options = applicationViewerOptions(creditContainer);
|
||
// CesiumWidget skips scene.sun and scene.moon when skyBox is false.
|
||
assert.ok(options.skyBox instanceof StarrySky);
|
||
assert.equal(options.creditContainer, creditContainer);
|
||
options.skyBox.destroy();
|
||
});
|
||
|
||
function pinchFixture({ zoomEventTypes } = {}) {
|
||
const handlers = new Set();
|
||
const relayed = [];
|
||
const container = {
|
||
addEventListener(type, handler, options) {
|
||
assert.equal(type, 'wheel');
|
||
assert.deepEqual(options, { capture: true, passive: false });
|
||
handlers.add(handler);
|
||
},
|
||
removeEventListener(type, handler, capture) {
|
||
assert.equal(type, 'wheel');
|
||
assert.equal(capture, true);
|
||
handlers.delete(handler);
|
||
},
|
||
emit(event) {
|
||
for (const handler of handlers) handler(event);
|
||
},
|
||
};
|
||
const canvas = {
|
||
dispatchEvent(event) {
|
||
// A real canvas dispatch crosses the container's capture listener again.
|
||
container.emit(event);
|
||
relayed.push(event);
|
||
return true;
|
||
},
|
||
};
|
||
const controller = { zoomEventTypes };
|
||
const viewer = {
|
||
container,
|
||
canvas,
|
||
scene: { screenSpaceCameraController: controller },
|
||
};
|
||
const createEvent = (init = {}) => ({
|
||
deltaX: 0,
|
||
deltaY: 0,
|
||
deltaZ: 0,
|
||
deltaMode: 0,
|
||
screenX: 0,
|
||
screenY: 0,
|
||
clientX: 0,
|
||
clientY: 0,
|
||
ctrlKey: false,
|
||
defaultPrevented: false,
|
||
propagationStopped: false,
|
||
preventDefault() {
|
||
this.defaultPrevented = true;
|
||
},
|
||
stopPropagation() {
|
||
this.propagationStopped = true;
|
||
},
|
||
...init,
|
||
});
|
||
return { viewer, controller, container, handlers, relayed, createEvent };
|
||
}
|
||
|
||
test('trackpad pinch adds the Ctrl+wheel binding without replacing Cesium inputs', () => {
|
||
const original = [1, 2, 3];
|
||
const f = pinchFixture({ zoomEventTypes: original });
|
||
const dispose = installTrackpadPinchZoom(f.viewer, {
|
||
createWheelEvent: (_type, init) => f.createEvent(init),
|
||
});
|
||
assert.deepEqual(f.controller.zoomEventTypes.slice(0, 3), original);
|
||
assert.equal(f.controller.zoomEventTypes.length, 4);
|
||
|
||
const plain = f.createEvent({ deltaY: 2 });
|
||
f.container.emit(plain);
|
||
assert.equal(plain.defaultPrevented, false);
|
||
const lineMode = f.createEvent({ ctrlKey: true, deltaY: 2, deltaMode: 1 });
|
||
f.container.emit(lineMode);
|
||
assert.equal(lineMode.defaultPrevented, false);
|
||
assert.equal(f.relayed.length, 0);
|
||
|
||
dispose();
|
||
dispose();
|
||
assert.equal(f.controller.zoomEventTypes, original);
|
||
assert.equal(f.handlers.size, 0);
|
||
});
|
||
|
||
test('trackpad pinch relays pixel deltas once with bounded amplification', () => {
|
||
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
|
||
installTrackpadPinchZoom(f.viewer, {
|
||
createWheelEvent: (_type, init) => f.createEvent(init),
|
||
});
|
||
|
||
const small = f.createEvent({ ctrlKey: true, deltaY: 2 });
|
||
f.container.emit(small);
|
||
assert.equal(small.defaultPrevented, true);
|
||
assert.equal(small.propagationStopped, true);
|
||
assert.equal(f.relayed.length, 1);
|
||
assert.equal(f.relayed[0].deltaY, 16);
|
||
assert.equal(f.relayed[0].ctrlKey, true);
|
||
|
||
const large = f.createEvent({ ctrlKey: true, deltaY: -100 });
|
||
f.container.emit(large);
|
||
assert.equal(f.relayed.length, 2);
|
||
assert.equal(f.relayed[1].deltaY, -120);
|
||
});
|
||
|
||
test('trackpad pinch falls back to the original event when relay construction fails', () => {
|
||
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
|
||
installTrackpadPinchZoom(f.viewer, {
|
||
createWheelEvent() {
|
||
throw new Error('unsupported');
|
||
},
|
||
});
|
||
const event = f.createEvent({ ctrlKey: true, deltaY: 2 });
|
||
f.container.emit(event);
|
||
assert.equal(event.defaultPrevented, false);
|
||
assert.equal(event.propagationStopped, false);
|
||
assert.equal(f.relayed.length, 0);
|
||
});
|
||
|
||
function freeLookFixture({ enableLook = true, height = 0 } = {}) {
|
||
const listeners = new Set();
|
||
const controller = { enableLook, minimumTrackBallHeight: 7_500_000 };
|
||
const camera = { positionCartographic: { height } };
|
||
const viewer = {
|
||
camera,
|
||
scene: {
|
||
screenSpaceCameraController: controller,
|
||
preUpdate: {
|
||
addEventListener(listener) {
|
||
listeners.add(listener);
|
||
return () => listeners.delete(listener);
|
||
},
|
||
},
|
||
},
|
||
};
|
||
const frame = (nextHeight) => {
|
||
camera.positionCartographic = { height: nextHeight };
|
||
for (const listener of listeners) listener();
|
||
};
|
||
return { viewer, controller, listeners, frame };
|
||
}
|
||
|
||
test('free look follows camera height and is restored on dispose', () => {
|
||
const f = freeLookFixture({ height: 500 });
|
||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||
assert.equal(f.controller.enableLook, true);
|
||
|
||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1);
|
||
assert.equal(f.controller.enableLook, false);
|
||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS);
|
||
assert.equal(f.controller.enableLook, true);
|
||
f.frame(Number.NaN);
|
||
assert.equal(f.controller.enableLook, true);
|
||
|
||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||
dispose();
|
||
dispose();
|
||
assert.equal(f.controller.enableLook, true);
|
||
assert.equal(f.listeners.size, 0);
|
||
});
|
||
|
||
test('drags that start on the stars orbit above the free-look height', () => {
|
||
// Below Cesium's trackball height such a drag turns the camera in place.
|
||
const f = freeLookFixture({ height: 500 });
|
||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||
assert.equal(
|
||
f.controller.minimumTrackBallHeight,
|
||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||
);
|
||
dispose();
|
||
assert.equal(f.controller.minimumTrackBallHeight, 7_500_000);
|
||
|
||
const lower = freeLookFixture({ height: 500 });
|
||
lower.controller.minimumTrackBallHeight = 500_000;
|
||
const disposeLower = installFreeLookHeightLimit(lower.viewer);
|
||
assert.equal(
|
||
lower.controller.minimumTrackBallHeight,
|
||
500_000,
|
||
'never raised',
|
||
);
|
||
disposeLower();
|
||
assert.equal(lower.controller.minimumTrackBallHeight, 500_000);
|
||
|
||
const later = freeLookFixture({ height: 500 });
|
||
const disposeLater = installFreeLookHeightLimit(later.viewer);
|
||
later.controller.minimumTrackBallHeight = 2_000_000;
|
||
disposeLater();
|
||
assert.equal(
|
||
later.controller.minimumTrackBallHeight,
|
||
2_000_000,
|
||
'dispose keeps a value set after install',
|
||
);
|
||
});
|
||
|
||
test('free look keeps a setting other code makes after it is installed', () => {
|
||
const f = freeLookFixture({ height: 500 });
|
||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||
f.controller.enableLook = false;
|
||
f.frame(10);
|
||
assert.equal(f.controller.enableLook, false, 'turned off stays off');
|
||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||
f.frame(10);
|
||
assert.equal(f.controller.enableLook, false, 'and stays off after orbit');
|
||
f.controller.enableLook = true;
|
||
f.frame(10);
|
||
assert.equal(f.controller.enableLook, true, 'turned back on');
|
||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||
assert.equal(f.controller.enableLook, false, 'still limited from orbit');
|
||
f.frame(10);
|
||
f.controller.enableLook = false;
|
||
dispose();
|
||
assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting');
|
||
});
|
||
|
||
test('free look stays off when the viewer had it disabled', () => {
|
||
const f = freeLookFixture({ enableLook: false, height: 500 });
|
||
installFreeLookHeightLimit(f.viewer);
|
||
f.frame(10);
|
||
assert.equal(f.controller.enableLook, false);
|
||
});
|
||
|
||
// A real Cesium camera on the smallest scene its constructor reads, so the
|
||
// frustum test runs on Cesium's own view math rather than a stub.
|
||
function realCamera({ width = 1600, height = 900, mode } = {}) {
|
||
const scene = {
|
||
canvas: { clientWidth: width, clientHeight: height },
|
||
drawingBufferWidth: width,
|
||
drawingBufferHeight: height,
|
||
mapProjection: new Cesium.GeographicProjection(),
|
||
mode: mode ?? Cesium.SceneMode.SCENE3D,
|
||
pixelRatio: 1,
|
||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||
tweens: new Cesium.TweenCollection(),
|
||
renders: 0,
|
||
requestRender() {
|
||
this.renders += 1;
|
||
},
|
||
};
|
||
const camera = new Cesium.Camera(scene);
|
||
camera.frustum.aspectRatio = width / height;
|
||
return { camera, scene, viewer: { camera, scene } };
|
||
}
|
||
|
||
function aim(
|
||
camera,
|
||
{ lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 },
|
||
) {
|
||
camera.setView({
|
||
destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
|
||
orientation: {
|
||
heading: Cesium.Math.toRadians(heading),
|
||
pitch: Cesium.Math.toRadians(pitch),
|
||
roll: Cesium.Math.toRadians(roll),
|
||
},
|
||
});
|
||
}
|
||
|
||
const reportedStuckView = {
|
||
alt: 17_996_811,
|
||
heading: 180,
|
||
pitch: -42,
|
||
roll: 180,
|
||
};
|
||
// Oblique poses that still show part of the planet: Director recipe shots
|
||
// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit.
|
||
const partialGlobeViews = [
|
||
{ lon: -30, lat: 20, alt: 19_000_000, pitch: -65 },
|
||
{ lon: 2, lat: 46, alt: 8_000_000, pitch: -52 },
|
||
{ lon: 110, lat: 20, alt: 9_500_000, pitch: -66 },
|
||
{ lon: 0, lat: 0, alt: 2_000_000, pitch: -30 },
|
||
];
|
||
|
||
// Stars and a few pixels of limb, no surface: where a frame limit measured
|
||
// against a sphere of the planet's largest radius used to stop a voice tilt.
|
||
const limbOnlyView = { alt: 18_000_000, pitch: -56.75 };
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
test('the globe is out of frame only when no surface is on screen', () => {
|
||
const cases = [
|
||
[reportedStuckView, 1225, 923, true],
|
||
[limbOnlyView, 1600, 900, true],
|
||
[{ alt: 17_996_811, pitch: -90 }, 1225, 923, false],
|
||
...partialGlobeViews.map((view) => [view, 1600, 900, false]),
|
||
];
|
||
for (const [view, width, height, out] of cases) {
|
||
const { camera } = realCamera({ width, height });
|
||
aim(camera, view);
|
||
const label = JSON.stringify(view);
|
||
assert.equal(globeOutOfFrame(camera), out, label);
|
||
assert.equal(surfacePixelShare(camera, width, height) === 0, out, label);
|
||
assert.ok(
|
||
Math.abs(
|
||
globeFrameShare(camera) - surfacePixelShare(camera, width, height),
|
||
) < 0.03,
|
||
`${label}: the frame share matches the pick-ray oracle`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('a move that ends with the globe out of frame turns back to nadir', () => {
|
||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||
const dispose = installGlobeInViewRecovery(viewer);
|
||
aim(camera, reportedStuckView);
|
||
const position = Cesium.Cartesian3.clone(camera.positionWC);
|
||
const heading = camera.heading;
|
||
camera.moveEnd.raiseEvent();
|
||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||
assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6));
|
||
assert.ok(
|
||
Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3),
|
||
);
|
||
assert.equal(globeOutOfFrame(camera), false);
|
||
assert.equal(scene.renders, 1);
|
||
|
||
// Looking up near the ground, then zoomed out past the limit.
|
||
aim(camera, { alt: 3_834_000, pitch: 10 });
|
||
camera.moveEnd.raiseEvent();
|
||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||
|
||
// Only the limb on a 16:9 screen still counts as lost.
|
||
const wide = realCamera({ width: 1600, height: 900 });
|
||
const disposeWide = installGlobeInViewRecovery(wide.viewer);
|
||
aim(wide.camera, limbOnlyView);
|
||
wide.camera.moveEnd.raiseEvent();
|
||
assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6));
|
||
disposeWide();
|
||
|
||
dispose();
|
||
dispose();
|
||
aim(camera, reportedStuckView);
|
||
camera.moveEnd.raiseEvent();
|
||
assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert');
|
||
});
|
||
|
||
// True when a moveEnd leaves the camera's view direction as it was.
|
||
function moveEndKeepsView(camera) {
|
||
const direction = Cesium.Cartesian3.clone(camera.directionWC);
|
||
camera.moveEnd.raiseEvent();
|
||
return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9);
|
||
}
|
||
|
||
test('globe recovery leaves partial, low, locked and 2D views alone', () => {
|
||
for (const view of partialGlobeViews) {
|
||
const { camera, viewer } = realCamera();
|
||
installGlobeInViewRecovery(viewer);
|
||
aim(camera, view);
|
||
assert.ok(moveEndKeepsView(camera), JSON.stringify(view));
|
||
}
|
||
|
||
const low = realCamera();
|
||
installGlobeInViewRecovery(low.viewer);
|
||
aim(low.camera, { alt: 900_000, pitch: 85 });
|
||
assert.equal(globeOutOfFrame(low.camera), true);
|
||
assert.ok(moveEndKeepsView(low.camera), 'below the limit');
|
||
|
||
// Locked on a point in space, between it and the planet, facing away.
|
||
const locked = realCamera();
|
||
installGlobeInViewRecovery(locked.viewer);
|
||
locked.camera.lookAtTransform(
|
||
Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)),
|
||
new Cesium.Cartesian3(-1_000_000, 0, 0),
|
||
);
|
||
assert.equal(globeOutOfFrame(locked.camera), true);
|
||
assert.ok(moveEndKeepsView(locked.camera), 'locked camera');
|
||
|
||
// Tracking an entity that has no position yet keeps an identity transform.
|
||
const tracked = realCamera();
|
||
tracked.viewer.trackedEntity = { id: 'pending-fix' };
|
||
installGlobeInViewRecovery(tracked.viewer);
|
||
aim(tracked.camera, reportedStuckView);
|
||
assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera');
|
||
|
||
const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW });
|
||
installGlobeInViewRecovery(flat.viewer);
|
||
flat.scene.mode = Cesium.SceneMode.SCENE3D;
|
||
aim(flat.camera, reportedStuckView);
|
||
flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
|
||
assert.ok(moveEndKeepsView(flat.camera), 'Columbus view');
|
||
});
|
||
|
||
test('the look limit from orbit tolerates grid noise and never reaches zero', () => {
|
||
const radians = Cesium.Math.toRadians;
|
||
// About 2% of a 16:9 frame on the planet, below the 10% limit.
|
||
const sliver = { alt: 18_000_000, pitch: -60 };
|
||
const { camera } = realCamera();
|
||
aim(camera, sliver);
|
||
const start = surfacePixelShare(camera, 1600, 900);
|
||
assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`);
|
||
|
||
// Sideways across the sliver: the share wobbles by a cell, the planet stays.
|
||
assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false);
|
||
const startShare = globeFrameShare(camera);
|
||
for (let i = 0; i < 20; i += 1) {
|
||
const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), {
|
||
startShare,
|
||
});
|
||
assert.equal(step, radians(0.25), `sideways step ${i + 1}`);
|
||
camera.lookLeft(step);
|
||
}
|
||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||
|
||
// Toward the planet is always allowed.
|
||
aim(camera, sliver);
|
||
assert.equal(
|
||
lookAngleWithinFrame(camera, 'lookDown', radians(10)),
|
||
radians(10),
|
||
);
|
||
|
||
// Away from it stops short of losing it.
|
||
aim(camera, sliver);
|
||
const away = lookAngleWithinFrame(camera, 'lookUp', radians(30));
|
||
assert.ok(away < radians(30));
|
||
camera.lookUp(away);
|
||
assert.ok(globeFrameShare(camera) > 0);
|
||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||
});
|
||
|
||
test('the look limit leaves a view with no surface to the move-end recovery', () => {
|
||
const { camera } = realCamera({ width: 1225, height: 923 });
|
||
aim(camera, reportedStuckView);
|
||
assert.equal(
|
||
lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)),
|
||
Cesium.Math.toRadians(5),
|
||
);
|
||
});
|
||
|
||
// Surface only in the corners of a 16:9 frame, all of it outside the scope's
|
||
// keyhole: the full frame shows some planet, the scope shows none.
|
||
const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 };
|
||
|
||
test('with a scope keyhole, surface hidden in the masked corners counts as none', () => {
|
||
const { camera } = realCamera();
|
||
aim(camera, cornerOnlyView);
|
||
const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS };
|
||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||
assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0);
|
||
assert.equal(globeOutOfFrame(camera), false);
|
||
assert.equal(globeOutOfFrame(camera, keyhole), true);
|
||
aim(camera, { alt: 18_000_000, pitch: -90 });
|
||
assert.ok(
|
||
Math.abs(
|
||
globeFrameShare(camera, keyhole) -
|
||
surfacePixelShare(camera, 1600, 900, { keyhole: true }),
|
||
) < 0.03,
|
||
'the keyhole share matches the pick-ray oracle',
|
||
);
|
||
});
|
||
|
||
test('the recovery follows the scope: corner-only views are turned back only behind it', () => {
|
||
for (const [scopeOn, expectRecovery] of [
|
||
[true, true],
|
||
[false, false],
|
||
]) {
|
||
const { camera, viewer } = realCamera();
|
||
installGlobeInViewRecovery(viewer, {
|
||
keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0),
|
||
});
|
||
aim(camera, cornerOnlyView);
|
||
camera.moveEnd.raiseEvent();
|
||
assert.equal(
|
||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||
expectRecovery,
|
||
`scope ${scopeOn ? 'on' : 'off'}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('the recovery leaves a camera flight and an authored scene alone', () => {
|
||
let playing = true;
|
||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||
scene.camera = camera;
|
||
scene.screenSpaceCameraController = { enableInputs: true };
|
||
installGlobeInViewRecovery(viewer, { paused: () => playing });
|
||
aim(camera, reportedStuckView);
|
||
camera.moveEnd.raiseEvent();
|
||
assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone');
|
||
playing = false;
|
||
|
||
// A flight that pauses looking away is left alone until it ends.
|
||
camera.flyTo({
|
||
destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000),
|
||
duration: 5,
|
||
});
|
||
camera.moveEnd.raiseEvent();
|
||
assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone');
|
||
camera.cancelFlight();
|
||
camera.moveEnd.raiseEvent();
|
||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||
});
|
||
|
||
test('a check a scene or flight defers runs once it ends', () => {
|
||
for (const viaMoveEnd of [false, true]) {
|
||
let playing = true;
|
||
const timers = fakeTimers();
|
||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||
installGlobeInViewRecovery(viewer, {
|
||
paused: () => playing,
|
||
...timers,
|
||
});
|
||
aim(camera, reportedStuckView);
|
||
camera.moveStart.raiseEvent();
|
||
// A hold on a stars-only shot: quiet, with or without Cesium's moveEnd.
|
||
for (let i = 0; i < 4; i += 1) timers.tick();
|
||
if (viaMoveEnd) camera.moveEnd.raiseEvent();
|
||
// Longer than the settle check's five-minute bound.
|
||
for (let i = 0; i < 1500; i += 1) timers.tick();
|
||
assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays');
|
||
assert.equal(timers.pending(), 1, 'still watching');
|
||
playing = false;
|
||
timers.tick();
|
||
assert.ok(
|
||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||
`recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`,
|
||
);
|
||
}
|
||
});
|
||
|
||
// Holding a drag still for half a second on a view that already showed no
|
||
// surface looked like a settled camera, and the view was turned down under
|
||
// the user's hand.
|
||
test('the recovery waits while a pointer is held on the globe', () => {
|
||
const timers = fakeTimers();
|
||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||
const document = new EventTarget();
|
||
scene.canvas = Object.assign(new EventTarget(), {
|
||
clientWidth: 1225,
|
||
clientHeight: 923,
|
||
ownerDocument: document,
|
||
});
|
||
const dispose = installGlobeInViewRecovery(viewer, timers);
|
||
aim(camera, reportedStuckView);
|
||
scene.canvas.dispatchEvent(new Event('pointerdown'));
|
||
camera.moveStart.raiseEvent();
|
||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||
camera.moveEnd.raiseEvent();
|
||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||
assert.equal(globeOutOfFrame(camera), true, 'left alone while held');
|
||
document.dispatchEvent(new Event('pointerup'));
|
||
for (let i = 0; i < 3; i += 1) timers.tick();
|
||
assert.ok(
|
||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||
'recovered once the pointer is released',
|
||
);
|
||
dispose();
|
||
aim(camera, reportedStuckView);
|
||
scene.canvas.dispatchEvent(new Event('pointerdown'));
|
||
document.dispatchEvent(new Event('pointerup'));
|
||
assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners');
|
||
});
|
||
|
||
/** A held-pointer recovery over a canvas in a document in a window. */
|
||
function heldPointerRecovery() {
|
||
const timers = fakeTimers();
|
||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||
const window = new EventTarget();
|
||
const document = Object.assign(new EventTarget(), {
|
||
defaultView: window,
|
||
visibilityState: 'visible',
|
||
});
|
||
scene.canvas = Object.assign(new EventTarget(), {
|
||
clientWidth: 1225,
|
||
clientHeight: 923,
|
||
ownerDocument: document,
|
||
});
|
||
const dispose = installGlobeInViewRecovery(viewer, timers);
|
||
const pointer = (target, type, pointerId, init = {}) =>
|
||
target.dispatchEvent(
|
||
Object.assign(new Event(type), { pointerId, ...init }),
|
||
);
|
||
/** Press, move and hold still: the check defers while a pointer is held. */
|
||
const holdStill = () => {
|
||
aim(camera, reportedStuckView);
|
||
camera.moveStart.raiseEvent();
|
||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||
camera.moveEnd.raiseEvent();
|
||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||
};
|
||
const settle = () => {
|
||
for (let i = 0; i < 3; i += 1) timers.tick();
|
||
};
|
||
const recovered = () =>
|
||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6);
|
||
return {
|
||
camera,
|
||
document,
|
||
window,
|
||
dispose,
|
||
pointer,
|
||
holdStill,
|
||
settle,
|
||
recovered,
|
||
canvas: scene.canvas,
|
||
};
|
||
}
|
||
|
||
// Releasing one of two fingers cleared the held flag, and the recovery
|
||
// turned the view down under the finger still on the glass.
|
||
test('the recovery waits until every held pointer is released', () => {
|
||
const h = heldPointerRecovery();
|
||
h.pointer(h.canvas, 'pointerdown', 1);
|
||
h.pointer(h.canvas, 'pointerdown', 2);
|
||
h.holdStill();
|
||
h.pointer(h.document, 'pointerup', 1);
|
||
h.settle();
|
||
assert.equal(h.recovered(), false, 'left alone under the second finger');
|
||
assert.equal(globeOutOfFrame(h.camera), true);
|
||
h.pointer(h.document, 'pointercancel', 2);
|
||
h.settle();
|
||
assert.equal(h.recovered(), true, 'recovered once both are released');
|
||
h.dispose();
|
||
});
|
||
|
||
// A release the page never sees (the pointer let go outside the window)
|
||
// must not hold the recovery off for good.
|
||
for (const [label, lose] of [
|
||
['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))],
|
||
[
|
||
'the page is hidden',
|
||
(h) => {
|
||
h.document.visibilityState = 'hidden';
|
||
h.document.dispatchEvent(new Event('visibilitychange'));
|
||
},
|
||
],
|
||
[
|
||
'the canvas loses the pointer capture',
|
||
(h) => h.pointer(h.canvas, 'lostpointercapture', 1),
|
||
],
|
||
[
|
||
'the pointer moves with no button down',
|
||
(h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }),
|
||
],
|
||
]) {
|
||
test(`a held pointer whose release is lost stops deferring when ${label}`, () => {
|
||
const h = heldPointerRecovery();
|
||
h.pointer(h.canvas, 'pointerdown', 1);
|
||
h.holdStill();
|
||
h.pointer(h.document, 'pointermove', 1, { buttons: 1 });
|
||
h.settle();
|
||
assert.equal(h.recovered(), false, 'left alone while held');
|
||
lose(h);
|
||
h.settle();
|
||
assert.equal(h.recovered(), true, 'recovered once the hold is let go');
|
||
h.dispose();
|
||
});
|
||
}
|
||
|
||
function fakeTimers() {
|
||
let next = 1;
|
||
const pending = new Map();
|
||
return {
|
||
setTimeout: (callback) => {
|
||
const id = next++;
|
||
pending.set(id, callback);
|
||
return id;
|
||
},
|
||
clearTimeout: (id) => pending.delete(id),
|
||
pending: () => pending.size,
|
||
/** Run the earliest pending callback, as 250 ms passing would. */
|
||
tick() {
|
||
const [id, callback] = pending.entries().next().value ?? [];
|
||
if (id === undefined) return false;
|
||
pending.delete(id);
|
||
callback();
|
||
return true;
|
||
},
|
||
};
|
||
}
|
||
|
||
test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => {
|
||
// At this pose the camera can drift by a rounding error every frame, so
|
||
// Cesium never sees it stop and never raises moveEnd for it.
|
||
const timers = fakeTimers();
|
||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||
installGlobeInViewRecovery(viewer, timers);
|
||
aim(camera, reportedStuckView);
|
||
camera.moveStart.raiseEvent();
|
||
timers.tick();
|
||
assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough');
|
||
// A rounding-error drift between samples still counts as at rest.
|
||
camera.lookUp(1e-15);
|
||
timers.tick();
|
||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||
// Under that drift Cesium raises no new moveStart, so a later move that
|
||
// ends looking away is caught by the same check.
|
||
aim(camera, reportedStuckView);
|
||
timers.tick();
|
||
timers.tick();
|
||
assert.equal(globeOutOfFrame(camera), true, 'not yet at rest');
|
||
timers.tick();
|
||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||
camera.moveEnd.raiseEvent();
|
||
assert.equal(timers.pending(), 0, 'moveEnd ends the check');
|
||
});
|
||
|
||
test('the settle check follows a long move and gives up five minutes after the last one', () => {
|
||
// A drag of half a minute that stops at a stuck pose is still recovered.
|
||
const long = fakeTimers();
|
||
const dragged = realCamera({ width: 1225, height: 923 });
|
||
installGlobeInViewRecovery(dragged.viewer, long);
|
||
aim(dragged.camera, reportedStuckView);
|
||
dragged.camera.moveStart.raiseEvent();
|
||
for (let i = 0; i < 120; i += 1) {
|
||
dragged.camera.moveLeft(0.01);
|
||
long.tick();
|
||
}
|
||
assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving');
|
||
while (long.tick());
|
||
assert.ok(
|
||
Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6),
|
||
'recovered once it stopped',
|
||
);
|
||
|
||
// A camera that keeps moving is followed, and left alone, for as long as it
|
||
// moves; once it rests the check gives up five minutes later.
|
||
const timers = fakeTimers();
|
||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||
installGlobeInViewRecovery(viewer, timers);
|
||
aim(camera, reportedStuckView);
|
||
camera.moveStart.raiseEvent();
|
||
for (let i = 0; i < 2000; i += 1) {
|
||
camera.moveLeft(0.01);
|
||
timers.tick();
|
||
}
|
||
assert.equal(timers.pending(), 1, 'still following after eight minutes');
|
||
assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone');
|
||
let idleSamples = 0;
|
||
while (timers.tick()) idleSamples += 1;
|
||
assert.ok(
|
||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||
'recovered once it stopped',
|
||
);
|
||
assert.ok(
|
||
idleSamples >= 1200 && idleSamples <= 1205,
|
||
`gave up ${idleSamples} samples after it stopped`,
|
||
);
|
||
|
||
const disposed = fakeTimers();
|
||
const other = realCamera({ width: 1225, height: 923 });
|
||
const dispose = installGlobeInViewRecovery(other.viewer, disposed);
|
||
aim(other.camera, reportedStuckView);
|
||
other.camera.moveStart.raiseEvent();
|
||
dispose();
|
||
assert.equal(disposed.pending(), 0, 'dispose cancels the pending check');
|
||
});
|