refactor(camera): use the globe engine

The startup fly-in, orbit, world-target focus, the arrival ground guard,
arrival ownership, camera sensitivity claims, landmark/viewport/globe
flights and place search (with its view bias and near-view recovery) take
the globe engine. Callers that still hold a viewer (voice, layers until
phase 1c) reach the same engine through engineFromViewer, and arrival
ownership is keyed by the engine whichever one a caller names.

The ground guard yields on camera.commanded instead of patching the
viewer camera itself; on Cesium that event hooks the same command methods
and tracking changes, so direct Cesium callers still end it. Landmark
framing reads the frustum fov from the pose's fovy and aspectRatio.

Tests that built viewer doubles now run on the fake engine (or on the
Cesium engine over the same double), with the same expectations.
This commit is contained in:
German Khokhlov committed 2026-10-15 19:47:43 +04:00
1 parent a3e929870d
commit 16e63f4c50
19 files changed
+600 -214

No files matched your search

+12 -1
View File
@@ -550,6 +550,7 @@
"ui-shell": { "ui-shell": {
"exports": ["./ui/shell"], "exports": ["./ui/shell"],
"modules": [ "modules": [
"src/globe/cesium/engineFromViewer.js",
"src/globe/contract.js", "src/globe/contract.js",
"src/hudLayouts.js", "src/hudLayouts.js",
"src/hudLayoutPolicy.js", "src/hudLayoutPolicy.js",
@@ -758,6 +759,8 @@
"scenes": { "scenes": {
"exports": ["./scenes"], "exports": ["./scenes"],
"modules": [ "modules": [
"src/globe/cesium/engineFromViewer.js",
"src/globe/contract.js",
"src/scenes/nepalEvidencePack.js", "src/scenes/nepalEvidencePack.js",
"src/data/bhoteKoshiIncidentPlaces.js", "src/data/bhoteKoshiIncidentPlaces.js",
"src/data/bhoteKoshiShotPaths.js", "src/data/bhoteKoshiShotPaths.js",
@@ -3087,6 +3090,7 @@
"application-operations": { "application-operations": {
"exports": ["./application/operations"], "exports": ["./application/operations"],
"modules": [ "modules": [
"src/globe/cesium/engineFromViewer.js",
"src/globe/cesium/credits.js", "src/globe/cesium/credits.js",
"src/services/groundSnap.js", "src/services/groundSnap.js",
"src/globe/contract.js", "src/globe/contract.js",
@@ -3164,12 +3168,19 @@
}, },
"layer-lifecycle": { "layer-lifecycle": {
"exports": ["./data/lifecycle"], "exports": ["./data/lifecycle"],
"modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], "modules": [
"src/globe/cesium/engineFromViewer.js",
"src/globe/contract.js",
"src/data/lifecycle.js",
"src/data/cameraArrival.js"
],
"external": [] "external": []
}, },
"ui-navigation": { "ui-navigation": {
"exports": ["./ui/navigation"], "exports": ["./ui/navigation"],
"modules": [ "modules": [
"src/globe/cesium/engineFromViewer.js",
"src/globe/contract.js",
"src/navigationPolicy.js", "src/navigationPolicy.js",
"src/ui/navigationController.js", "src/ui/navigationController.js",
"src/app/stateChannel.js", "src/app/stateChannel.js",
+49 -2
View File
@@ -28,6 +28,7 @@ import {
} from '../data/dataCredits.js'; } from '../data/dataCredits.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js'; import { isPickedWorldPosition } from '../data/scenePick.js';
import { isGlobeEngine } from '../globe/contract.js';
/** Ms the analyst region lookup waits on geocode + admin boundary. */ /** Ms the analyst region lookup waits on geocode + admin boundary. */
export const REGION_FALLBACK_BUDGET_MS = 3_000; export const REGION_FALLBACK_BUDGET_MS = 3_000;
@@ -1834,7 +1835,10 @@ export function createAnnotationResolver({
*/ */
function viewportProximity(viewer) { function viewportProximity(viewer) {
try { try {
const carto = viewer?.camera?.positionCartographic; // A globe engine (search) or a Cesium viewer (annotations until 1c).
const carto = isGlobeEngine(viewer)
? viewer.camera.pose().cartographic
: viewer?.camera?.positionCartographic;
if (!carto) return null; if (!carto) return null;
const lat = geo.math.toDegrees(carto.latitude); const lat = geo.math.toDegrees(carto.latitude);
const lon = geo.math.toDegrees(carto.longitude); const lon = geo.math.toDegrees(carto.longitude);
@@ -1849,7 +1853,9 @@ export function createAnnotationResolver({
/** Exported for searchAndFlyTo (src/locations.js), which shares this bias. */ /** Exported for searchAndFlyTo (src/locations.js), which shares this bias. */
function viewportBias(viewer) { function viewportBias(viewer) {
try { try {
const rect = viewer?.camera?.computeViewRectangle?.(); const rect = isGlobeEngine(viewer)
? viewer.camera.viewRectangle()
: viewer?.camera?.computeViewRectangle?.();
if (!rect) return null; if (!rect) return null;
const swLat = geo.math.toDegrees(rect.south).toFixed(4); const swLat = geo.math.toDegrees(rect.south).toFixed(4);
const swLng = geo.math.toDegrees(rect.west).toFixed(4); const swLng = geo.math.toDegrees(rect.west).toFixed(4);
@@ -1899,6 +1905,46 @@ export function createAnnotationResolver({
return hit && hit.distanceM <= PLACES_MAX_DISTANCE_M ? hit : null; return hit && hit.distanceM <= PLACES_MAX_DISTANCE_M ? hit : null;
} }
/** World lon/lat/height from a picked Cartesian, or null for a missed pick. */
function pickedPlace(cart) {
if (!isPickedWorldPosition(cart)) return null;
const carto = geo.cartographic.fromCartesian(cart);
if (!carto) return null;
return {
lat: geo.math.toDegrees(carto.latitude),
lon: geo.math.toDegrees(carto.longitude),
height: Number.isFinite(carto.height) ? carto.height : 0,
};
}
/** pickWorldFromScreen through a globe engine: the same cascade. */
function enginePickWorldFromScreen(engine, nx, ny) {
const { width: w, height: h } = engine.host.size();
if (!w || !h) return null;
const screen = {
x: Math.max(0, Math.min(1, nx)) * w,
y: Math.max(0, Math.min(1, ny)) * h,
};
const stages = [
() =>
engine.picker.pickPositionSupported
? engine.picker.pickPosition(screen)
: undefined,
() => engine.camera.pickEllipsoid(screen),
() => engine.picker.pickGlobe(screen),
];
for (const stage of stages) {
let cart = null;
try {
cart = stage();
} catch {
cart = null;
}
if (isPickedWorldPosition(cart)) return pickedPlace(cart);
}
return null;
}
/** /**
* Convert a NORMALIZED screen point (x,y in [0,1] of the current viewport) into * Convert a NORMALIZED screen point (x,y in [0,1] of the current viewport) into
* a world lon/lat using the depth-aware pick cascade — the inverse of the * a world lon/lat using the depth-aware pick cascade — the inverse of the
@@ -1907,6 +1953,7 @@ export function createAnnotationResolver({
* name the place, and we anchor the mark to the actual world point under it. * name the place, and we anchor the mark to the actual world point under it.
*/ */
function pickWorldFromScreen(viewer, nx, ny) { function pickWorldFromScreen(viewer, nx, ny) {
if (isGlobeEngine(viewer)) return enginePickWorldFromScreen(viewer, nx, ny);
const scene = viewer?.scene; const scene = viewer?.scene;
if (!scene) return null; if (!scene) return null;
const canvas = scene.canvas; const canvas = scene.canvas;
+78
View File
@@ -0,0 +1,78 @@
// The view-centred search helpers read the camera through a globe engine as
// well as through a viewer (src/locations.js searchAndFlyTo hands them the
// engine).
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../geo/index.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
import { createAnnotationResolver } from './resolver.js';
const resolver = createAnnotationResolver({
featureSource: Object.fromEntries(
FEATURE_SOURCE_METHODS.map((method) => [method, async () => null]),
),
});
function lookingDownAt(lon, lat, height = 20000) {
const engine = createFakeEngine();
engine.fake.setPose({
position: geo.cartesian3.fromDegrees(lon, lat, height),
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
});
return engine;
}
test('viewportBias reads the engine view rectangle', () => {
const engine = lookingDownAt(-97.7431, 30.2672);
engine.fake.script('viewRectangle', {
west: geo.math.toRadians(-98),
south: geo.math.toRadians(30),
east: geo.math.toRadians(-97.5),
north: geo.math.toRadians(30.5),
});
assert.equal(resolver.viewportBias(engine), '30.0000,-98.0000|30.5000,-97.5000');
engine.fake.script('viewRectangle', undefined);
assert.equal(resolver.viewportBias(engine), null);
});
test('pickWorldFromScreen tries the depth pick, then the ellipsoid, then the globe', () => {
const engine = lookingDownAt(-97.7431, 30.2672);
const depth = geo.cartesian3.fromDegrees(-97.7, 30.3, 210);
engine.fake.script('pickPosition', depth);
const picked = resolver.pickWorldFromScreen(engine, 0.5, 0.5);
assert.ok(Math.abs(picked.lat - 30.3) < 1e-9 && Math.abs(picked.height - 210) < 1e-6);
const [screen] = engine.fake.callsOf('picker.pickPosition')[0].args;
assert.deepEqual([screen.x, screen.y], [512, 384]);
engine.fake.script('pickPosition', undefined);
const ellipsoid = resolver.pickWorldFromScreen(engine, 0.5, 0.5);
assert.ok(Math.abs(ellipsoid.lon - -97.7431) < 0.01);
assert.ok(Math.abs(ellipsoid.height) < 1e-3);
engine.fake.script('pickEllipsoid', undefined);
engine.fake.script('pickGlobe', geo.cartesian3.fromDegrees(-97.71, 30.25, 300));
const globe = resolver.pickWorldFromScreen(engine, 0.5, 0.5);
assert.ok(Math.abs(globe.height - 300) < 1e-6);
engine.fake.script('pickGlobe', undefined);
assert.equal(resolver.pickWorldFromScreen(engine, 0.5, 0.5), null);
});
test('near-view recovery centres on the engine camera when nothing is picked', async () => {
const engine = lookingDownAt(2.35, 48.85, 10000);
for (const name of ['pickPosition', 'pickEllipsoid', 'pickGlobe'])
engine.fake.script(name, undefined);
const centres = [];
const placeSearch = {
textSearch: async (query, centre) => {
centres.push(centre);
return [];
},
};
await resolver.placesNearViewRecovery(engine, 'cafe', null, undefined, placeSearch);
assert.equal(centres.length, 1);
assert.ok(Math.abs(centres[0].latitude - 48.85) < 1e-9);
assert.ok(Math.abs(centres[0].longitude - 2.35) < 1e-9);
});
+1 -1
View File
@@ -44,7 +44,7 @@ export function createApplicationControls({
// If no share link state, do default fly-to Austin // If no share link state, do default fly-to Austin
if (!styleManager.hasShareState) { if (!styleManager.hasShareState) {
loaderStatus.textContent = 'Flying to Austin, TX...'; loaderStatus.textContent = 'Flying to Austin, TX...';
defer(flyToAustin(viewer)); defer(flyToAustin(engine));
} else { } else {
loaderStatus.textContent = 'Restoring shared view...'; loaderStatus.textContent = 'Restoring shared view...';
} }
+32 -16
View File
@@ -1,25 +1,41 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { flyToAustin } from '../camera.js'; import { flyToAustin } from '../camera.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
test('teardown before the initial camera delay prevents a late flight', (t) => { test('teardown before the initial camera delay prevents a late flight', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
let flights = 0; const engine = createFakeEngine();
let cancelled = 0; const stop = flyToAustin(engine);
const stop = flyToAustin({
isDestroyed: () => false,
camera: {
setView() {},
flyTo() {
flights++;
},
cancelFlight() {
cancelled++;
},
},
});
stop(); stop();
t.mock.timers.tick(1000); t.mock.timers.tick(1000);
assert.equal(flights, 0); assert.equal(engine.fake.callsOf('camera.flyTo').length, 0);
assert.equal(cancelled, 1); assert.equal(engine.fake.callsOf('camera.cancelFlight').length, 1);
});
test('the startup fly-in starts high over Austin, then flies in on the engine', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const engine = createFakeEngine();
flyToAustin(engine);
const [view] = engine.fake.callsOf('camera.setView')[0].args;
assert.deepEqual(view.orientation, {
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
});
t.mock.timers.tick(500);
const [flight] = engine.fake.callsOf('camera.flyTo')[0].args;
assert.equal(flight.duration, 4);
assert.equal(flight.easing, 'cubicInOut');
});
test('a destroyed engine gets no late flight', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const engine = createFakeEngine();
const stop = flyToAustin(engine);
engine.destroy();
t.mock.timers.tick(1000);
stop();
assert.equal(engine.fake.callsOf('camera.flyTo').length, 0);
assert.equal(engine.fake.callsOf('camera.cancelFlight').length, 0);
}); });
+18 -11
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import * as geo from './geo/index.js'; import * as geo from './geo/index.js';
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
/** /**
* Camera presets for notable locations. * Camera presets for notable locations.
@@ -34,26 +34,33 @@ export const CAMERA_PRESETS = {
/** /**
* Fly the camera to a preset location with a smooth animation. * Fly the camera to a preset location with a smooth animation.
* @param {import('./globe/contract.js').GlobeEngine | object} engine The globe
* engine, or a viewer registered for one.
*/ */
export function flyToPreset(viewer, presetName, duration = 3.0) { export function flyToPreset(engine, presetName, duration = 3.0) {
const preset = CAMERA_PRESETS[presetName]; const preset = CAMERA_PRESETS[presetName];
if (!preset) return; const camera = engineFromViewer(engine)?.camera;
if (!preset || !camera) return;
viewer.camera.flyTo({ camera.flyTo({
destination: preset.destination, destination: preset.destination,
orientation: preset.orientation, orientation: preset.orientation,
duration, duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easing: 'cubicInOut',
}); });
} }
/** /**
* Set camera to Austin on load with a cinematic fly-in. * Set camera to Austin on load with a cinematic fly-in.
* @param {import('./globe/contract.js').GlobeEngine | object} target The globe
* engine, or a viewer registered for one.
* @returns {Function} Cancels the pending or active startup flight. * @returns {Function} Cancels the pending or active startup flight.
*/ */
export function flyToAustin(viewer) { export function flyToAustin(target) {
const engine = engineFromViewer(target);
if (!engine) return () => {};
// Start from a high altitude, then fly down // Start from a high altitude, then fly down
viewer.camera.setView({ engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7431, 30.2672, 25000), destination: geo.cartesian3.fromDegrees(-97.7431, 30.2672, 25000),
orientation: { orientation: {
heading: geo.math.toRadians(0), heading: geo.math.toRadians(0),
@@ -64,8 +71,8 @@ export function flyToAustin(viewer) {
// Cinematic fly-in after a brief pause // Cinematic fly-in after a brief pause
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (viewer.isDestroyed()) return; if (engine.isDestroyed()) return;
viewer.camera.flyTo({ engine.camera.flyTo({
destination: geo.cartesian3.fromDegrees(-97.7431, 30.2672, 600), destination: geo.cartesian3.fromDegrees(-97.7431, 30.2672, 600),
orientation: { orientation: {
heading: geo.math.toRadians(15), heading: geo.math.toRadians(15),
@@ -73,11 +80,11 @@ export function flyToAustin(viewer) {
roll: 0.0, roll: 0.0,
}, },
duration: 4.0, duration: 4.0,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easing: 'cubicInOut',
}); });
}, 500); }, 500);
return () => { return () => {
clearTimeout(timer); clearTimeout(timer);
if (!viewer.isDestroyed()) viewer.camera.cancelFlight(); if (!engine.isDestroyed()) engine.camera.cancelFlight();
}; };
} }
+28 -46
View File
@@ -14,9 +14,9 @@
* lifts the eye if it sits below a usable clearance. Heading and pitch are * lifts the eye if it sits below a usable clearance. Heading and pitch are
* held, so the correction reads as the shot settling. * held, so the correction reads as the shot settling.
*/ */
import * as Cesium from 'cesium';
import * as geo from './geo/index.js'; import * as geo from './geo/index.js';
import { ownCameraArrival } from './data/cameraArrival.js'; import { ownCameraArrival } from './data/cameraArrival.js';
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
/** Minimum eye height above the rendered surface for a usable view. */ /** Minimum eye height above the rendered surface for a usable view. */
export const MIN_EYE_CLEARANCE_M = 120; export const MIN_EYE_CLEARANCE_M = 120;
@@ -31,17 +31,16 @@ const probeScratch = geo.cartographic.create();
/** /**
* Highest rendered surface across a few cells, with how many cells answered. * Highest rendered surface across a few cells, with how many cells answered.
* A cell whose tiles are not streamed yet contributes nothing. * A cell whose tiles are not streamed yet contributes nothing.
* @param {Cesium.Scene} scene Live scene. * @param {import('./globe/contract.js').GlobeSurface} surface Live surface.
* @param {Array<{lat: number, lon: number}>} cells Cells to probe. * @param {Array<{lat: number, lon: number}>} cells Cells to probe.
* @returns {{heightM: number, sampled: number}} Reading. * @returns {{heightM: number, sampled: number}} Reading.
*/ */
function sampleSurfaceM(scene, cells) { function sampleSurfaceM(surface, cells) {
let heightM = Number.NaN; let heightM = Number.NaN;
let sampled = 0; let sampled = 0;
if (typeof scene?.sampleHeight !== 'function') return { heightM, sampled };
for (const cell of cells) { for (const cell of cells) {
try { try {
const height = scene.sampleHeight( const height = surface.sampleHeight(
geo.cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch), geo.cartographic.fromDegrees(cell.lon, cell.lat, 0, probeScratch),
); );
if (!Number.isFinite(height)) continue; if (!Number.isFinite(height)) continue;
@@ -77,15 +76,18 @@ export function groundClearanceDeficitM(
* Watch an arrival and lift the camera once the real surface can be measured. * Watch an arrival and lift the camera once the real surface can be measured.
* *
* Stops quietly on a clear view, exhausted attempts, the user taking the * Stops quietly on a clear view, exhausted attempts, the user taking the
* controls (pointer or wheel on the canvas), or `isStale()` reporting that a * controls (pointer or wheel on the canvas, a key), any camera command
* newer arrival owns the camera. * (`camera.commanded`: a flight, setView, lookAt, cancelFlight or, on
* @param {Cesium.Viewer} viewer Live viewer. * Cesium, a new tracked entity), or `isStale()` reporting that a newer
* arrival owns the camera.
* @param {import('./globe/contract.js').GlobeEngine | object} target The
* globe engine, or a viewer registered for one.
* @param {{lat: number, lon: number}} at Destination being framed. * @param {{lat: number, lon: number}} at Destination being framed.
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number, * @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
* isStale?: () => boolean, onLift?: (liftM: number) => void}} [options] * isStale?: () => boolean, onLift?: (liftM: number) => void}} [options]
* @returns {() => void} Cancel function. * @returns {() => void} Cancel function.
*/ */
export function guardCameraAboveGround(viewer, at, options = {}) { export function guardCameraAboveGround(target, at, options = {}) {
const { const {
clearanceM = MIN_EYE_CLEARANCE_M, clearanceM = MIN_EYE_CLEARANCE_M,
attempts = GUARD_ATTEMPTS, attempts = GUARD_ATTEMPTS,
@@ -93,12 +95,13 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
isStale = () => false, isStale = () => false,
onLift = null, onLift = null,
} = options; } = options;
if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon)) const engine = engineFromViewer(target);
if (!engine || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
return () => {}; return () => {};
let timer = null; let timer = null;
let done = false; let done = false;
const canvas = viewer.scene.canvas; const canvas = engine.host.canvas;
const documentRef = canvas?.ownerDocument; const documentRef = canvas?.ownerDocument;
const removers = []; const removers = [];
let release = () => {}; let release = () => {};
@@ -112,33 +115,11 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
for (const remove of removers) remove(); for (const remove of removers) remove();
release(); release();
}; };
release = ownCameraArrival(viewer, stop); release = ownCameraArrival(engine, stop);
// Direct camera API callers (including duration-zero flights) must yield // Any new camera owner (including duration-zero flights and, on Cesium,
// before moving the eye. Restore the exact method ownership on completion. // direct viewer-camera calls and tracking) yields the guard before it moves
for (const name of [ // the eye.
'flyTo', removers.push(engine.camera.commanded.addEventListener(stop));
'flyToBoundingSphere',
'setView',
'lookAt',
'cancelFlight',
]) {
const camera = viewer.camera;
const original = camera[name];
if (typeof original !== 'function') continue;
const descriptor = Object.getOwnPropertyDescriptor(camera, name);
const wrapped = function (...args) {
stop();
return original.apply(this, args);
};
camera[name] = wrapped;
removers.push(() => {
if (camera[name] !== wrapped) return;
if (descriptor) Object.defineProperty(camera, name, descriptor);
else delete camera[name];
});
}
const removeTracking = viewer.trackedEntityChanged?.addEventListener(stop);
if (removeTracking) removers.push(removeTracking);
documentRef?.addEventListener?.('keydown', stop, true); documentRef?.addEventListener?.('keydown', stop, true);
// A manual gesture ends the guard, as it interrupts camera motion elsewhere. // A manual gesture ends the guard, as it interrupts camera motion elsewhere.
for (const type of ['pointerdown', 'wheel']) for (const type of ['pointerdown', 'wheel'])
@@ -147,16 +128,17 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
let remaining = attempts; let remaining = attempts;
const attempt = () => { const attempt = () => {
if (done) return; if (done) return;
if (isStale() || viewer.isDestroyed?.()) return stop(); if (isStale() || engine.isDestroyed()) return stop();
remaining -= 1; remaining -= 1;
const camera = viewer.camera; const { camera } = engine;
const carto = camera.positionCartographic; const pose = camera.pose();
const carto = pose.cartographic;
// The eye must clear both the framed target and the ground beneath it. // The eye must clear both the framed target and the ground beneath it.
const below = { const below = {
lat: geo.math.toDegrees(carto.latitude), lat: geo.math.toDegrees(carto.latitude),
lon: geo.math.toDegrees(carto.longitude), lon: geo.math.toDegrees(carto.longitude),
}; };
const { heightM, sampled } = sampleSurfaceM(viewer.scene, [ const { heightM, sampled } = sampleSurfaceM(engine.surface, [
{ lat: at.lat, lon: at.lon }, { lat: at.lat, lon: at.lon },
below, below,
]); ]);
@@ -175,12 +157,12 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
carto.height + deficit, carto.height + deficit,
), ),
orientation: { orientation: {
heading: camera.heading, heading: pose.heading,
pitch: camera.pitch, pitch: pose.pitch,
roll: camera.roll, roll: pose.roll,
}, },
duration: 0.9, duration: 0.9,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easing: 'cubicInOut',
}); });
try { try {
onLift?.(deficit); onLift?.(deficit);
+51 -7
View File
@@ -11,6 +11,9 @@ import {
groundClearanceDeficitM, groundClearanceDeficitM,
guardCameraAboveGround, guardCameraAboveGround,
} from './cameraGroundGuard.js'; } from './cameraGroundGuard.js';
import { createCesiumEngine } from './globe/cesium/createCesiumEngine.js';
import { registerViewerEngine } from './globe/cesium/engineFromViewer.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
test('a buried camera is lifted clear of the surface', () => { test('a buried camera is lifted clear of the surface', () => {
// Framed near sea level, arriving over ground at ~1,609 m (Denver). // Framed near sea level, arriving over ground at ~1,609 m (Denver).
@@ -47,24 +50,32 @@ test('the clearance frames a subject without turning into an overflight', () =>
assert.ok(MIN_EYE_CLEARANCE_M <= 250); assert.ok(MIN_EYE_CLEARANCE_M <= 250);
}); });
function fakeViewer({ cameraHeightM, surfaceM }) { // A viewer double under the real Cesium engine, registered as the app does,
// so the guard runs the shell's engine path against Cesium-shaped calls.
function fakeViewer({ cameraHeightM, surfaceM, canvas = null }) {
const flights = []; const flights = [];
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM); const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
return { const viewer = {
flights, flights,
scene: { scene: {
canvas: null, canvas,
sampleHeightSupported: true,
sampleHeight: (carto) => sampleHeight: (carto) =>
typeof surfaceM === 'function' ? surfaceM(carto) : surfaceM, typeof surfaceM === 'function' ? surfaceM(carto) : surfaceM,
}, },
camera: { camera: {
positionCartographic: position, positionCartographic: position,
positionWC: Cesium.Cartographic.toCartesian(position),
frustum: { fovy: 1, aspectRatio: 1.5 },
heading: 0.5, heading: 0.5,
pitch: -0.55, pitch: -0.55,
roll: 0, roll: 0,
flyTo: (options) => flights.push(options), flyTo: (options) => flights.push(options),
}, },
}; };
viewer.engine = createCesiumEngine(viewer);
registerViewerEngine(viewer, viewer.engine);
return viewer;
} }
test('the guard lifts a buried arrival once the surface answers', async () => { test('the guard lifts a buried arrival once the surface answers', async () => {
@@ -116,9 +127,9 @@ import { LayerLifecycle } from './data/lifecycle.js';
function handoffFixture(t) { function handoffFixture(t) {
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 }); const canvas = new EventTarget();
viewer.scene.canvas = new EventTarget(); canvas.ownerDocument = new EventTarget();
viewer.scene.canvas.ownerDocument = new EventTarget(); const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171, canvas });
viewer.trackedEntityChanged = new Cesium.Event(); viewer.trackedEntityChanged = new Cesium.Event();
viewer.camera.cancelFlight = () => {}; viewer.camera.cancelFlight = () => {};
viewer.camera.lookAtTransform = () => {}; viewer.camera.lookAtTransform = () => {};
@@ -127,6 +138,7 @@ function handoffFixture(t) {
const originalFly = viewer.camera.flyTo; const originalFly = viewer.camera.flyTo;
const navigation = new NavigationController({ const navigation = new NavigationController({
viewer, viewer,
engine: viewer.engine,
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer', tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer',
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer', 'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer',
'rocketLaunchesLayer'].map((key) => [key, {}])), 'rocketLaunchesLayer'].map((key) => [key, {}])),
@@ -144,7 +156,7 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
const { viewer, navigation, originalFly } = handoffFixture(t); const { viewer, navigation, originalFly } = handoffFixture(t);
if (handoff === 'Director') { if (handoff === 'Director') {
SceneDirector.prototype._claimCameraOwnership.call({ SceneDirector.prototype._claimCameraOwnership.call({
viewer, styleManager: { runImmediateNavigation: (noun, fn) => viewer, engine: viewer.engine, styleManager: { runImmediateNavigation: (noun, fn) =>
navigation._runExplicitNavigation(noun, fn) }, navigation._runExplicitNavigation(noun, fn) },
}); });
} else if (handoff === 'tracking') { } else if (handoff === 'tracking') {
@@ -169,3 +181,35 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
navigation.destroy(); navigation.destroy();
}); });
} }
test('the guard runs on a globe engine alone and yields to its next camera command', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const engine = createFakeEngine();
engine.fake.setPose({
position: Cesium.Cartesian3.fromDegrees(-97.7603, 30.3141, 172),
heading: 0.5,
pitch: -0.55,
roll: 0,
});
engine.fake.script('sampleHeight', 171);
const lifts = [];
guardCameraAboveGround(engine, { lat: 30.3125, lon: -97.765 }, {
onLift: (m) => lifts.push(m),
});
t.mock.timers.tick(600);
const [flight] = engine.fake.callsOf('camera.flyTo')[0].args;
const lifted = Cesium.Cartographic.fromCartesian(flight.destination);
assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5);
assert.ok(Math.abs(flight.orientation.heading - 0.5) < 1e-9);
assert.equal(flight.easing, 'cubicInOut');
assert.equal(lifts.length, 1);
assert.equal(engine.camera.commanded.numberOfListeners, 0);
// A camera command before the first probe ends the guard.
engine.fake.setPose({ position: Cesium.Cartesian3.fromDegrees(-97.7603, 30.3141, 172) });
guardCameraAboveGround(engine, { lat: 30.3125, lon: -97.765 });
engine.camera.cancelFlight();
t.mock.timers.tick(10000);
assert.equal(engine.fake.callsOf('camera.flyTo').length, 1);
assert.equal(engine.camera.commanded.numberOfListeners, 0);
});
+17 -7
View File
@@ -1,16 +1,26 @@
/** Arrival corrections belong to one viewer and yield to its next camera owner. */ import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
/**
* Arrival corrections belong to one globe engine and yield to its next camera
* owner. Callers may name the engine or (layers until phase 1c) the viewer it
* is registered for; both reach the same correction.
*/
const arrivals = new WeakMap(); const arrivals = new WeakMap();
const ownerOf = (engineOrViewer) =>
engineFromViewer(engineOrViewer) ?? engineOrViewer;
/** Cancel an outstanding arrival correction before navigation or teardown. */ /** Cancel an outstanding arrival correction before navigation or teardown. */
export function cancelCameraArrival(viewer) { export function cancelCameraArrival(engineOrViewer) {
if (viewer) arrivals.get(viewer)?.(); if (engineOrViewer) arrivals.get(ownerOf(engineOrViewer))?.();
} }
/** Register a correction and return an idempotent ownership release. */ /** Register a correction and return an idempotent ownership release. */
export function ownCameraArrival(viewer, cancel) { export function ownCameraArrival(engineOrViewer, cancel) {
cancelCameraArrival(viewer); const owner = ownerOf(engineOrViewer);
arrivals.set(viewer, cancel); cancelCameraArrival(owner);
arrivals.set(owner, cancel);
return () => { return () => {
if (arrivals.get(viewer) === cancel) arrivals.delete(viewer); if (arrivals.get(owner) === cancel) arrivals.delete(owner);
}; };
} }
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
import { cancelCameraArrival, ownCameraArrival } from './cameraArrival.js';
test('an arrival owned on the engine yields to a cancel through its viewer', () => {
const engine = createFakeEngine();
const viewer = {};
const unregister = registerViewerEngine(viewer, engine);
const cancelled = [];
// A correction releases its ownership when it stops, as the ground guard does.
const own = (key, name) => {
let release = () => {};
release = ownCameraArrival(key, () => {
cancelled.push(name);
release();
});
};
own(engine, 'engine-owned');
cancelCameraArrival(viewer);
own(viewer, 'viewer-owned');
cancelCameraArrival(engine);
assert.deepEqual(cancelled, ['engine-owned', 'viewer-owned']);
unregister();
});
test('a newer arrival replaces the older one and release is idempotent', () => {
const engine = createFakeEngine();
const seen = [];
const releaseOld = ownCameraArrival(engine, () => seen.push('old'));
ownCameraArrival(engine, () => seen.push('new'));
assert.deepEqual(seen, ['old']);
releaseOld();
cancelCameraArrival(engine);
assert.deepEqual(seen, ['old', 'new']);
cancelCameraArrival(null);
assert.deepEqual(seen, ['old', 'new']);
});
+18 -6
View File
@@ -30,6 +30,12 @@ const _cameras = new WeakMap();
/** The value Cesium starts a camera at, used when the camera reports nothing. */ /** The value Cesium starts a camera at, used when the camera reports nothing. */
const DEFAULT_PERCENTAGE_CHANGED = 0.5; const DEFAULT_PERCENTAGE_CHANGED = 0.5;
/**
* A globe engine (or a viewer) claims through its camera; anything else is
* the camera itself (a camera has no `camera` of its own).
*/
const cameraOf = (target) => target?.camera ?? target;
function ledgerFor(camera) { function ledgerFor(camera) {
let ledger = _cameras.get(camera); let ledger = _cameras.get(camera);
if (!ledger) { if (!ledger) {
@@ -53,12 +59,16 @@ function applyLedger(camera, ledger) {
/** /**
* Ask for a camera-change sensitivity at least as fine as `value`. * Ask for a camera-change sensitivity at least as fine as `value`.
* @param {object|null|undefined} camera A Cesium camera (or anything with `percentageChanged`). * @param {object|null|undefined} target A globe engine, its camera, or a
* Cesium camera (anything with `percentageChanged`). Layers still pass the
* viewer's Cesium camera until phase 1c; that is a separate claimant from
* the engine's camera, so one camera must not be claimed both ways.
* @param {string} owner Stable claim name, usually the layer id. * @param {string} owner Stable claim name, usually the layer id.
* @param {number} value Requested `percentageChanged`. * @param {number} value Requested `percentageChanged`.
* @returns {number|null} The value now in force, or null when there is no camera. * @returns {number|null} The value now in force, or null when there is no camera.
*/ */
export function claimCameraSensitivity(camera, owner, value) { export function claimCameraSensitivity(target, owner, value) {
const camera = cameraOf(target);
if (!camera || !owner || !Number.isFinite(value)) return null; if (!camera || !owner || !Number.isFinite(value)) return null;
const ledger = ledgerFor(camera); const ledger = ledgerFor(camera);
ledger.claims.set(owner, value); ledger.claims.set(owner, value);
@@ -68,11 +78,12 @@ export function claimCameraSensitivity(camera, owner, value) {
/** /**
* Give up one claim. The camera keeps the finest value still claimed by * Give up one claim. The camera keeps the finest value still claimed by
* somebody else, and returns to its original only when none are left. * somebody else, and returns to its original only when none are left.
* @param {object|null|undefined} camera * @param {object|null|undefined} target A globe engine or a camera.
* @param {string} owner * @param {string} owner
* @returns {number|null} The value now in force, or null when there is no camera. * @returns {number|null} The value now in force, or null when there is no camera.
*/ */
export function releaseCameraSensitivity(camera, owner) { export function releaseCameraSensitivity(target, owner) {
const camera = cameraOf(target);
if (!camera || !owner) return null; if (!camera || !owner) return null;
const ledger = _cameras.get(camera); const ledger = _cameras.get(camera);
if (!ledger) return null; if (!ledger) return null;
@@ -84,10 +95,11 @@ export function releaseCameraSensitivity(camera, owner) {
/** /**
* Who currently holds a claim. For tests and diagnostics only. * Who currently holds a claim. For tests and diagnostics only.
* @param {object|null|undefined} camera * @param {object|null|undefined} target A globe engine or a camera.
* @returns {Array<string>} * @returns {Array<string>}
*/ */
export function cameraSensitivityClaims(camera) { export function cameraSensitivityClaims(target) {
const camera = cameraOf(target);
const ledger = camera ? _cameras.get(camera) : null; const ledger = camera ? _cameras.get(camera) : null;
return ledger ? [...ledger.claims.keys()] : []; return ledger ? [...ledger.claims.keys()] : [];
} }
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import {
cameraSensitivityClaims,
claimCameraSensitivity,
releaseCameraSensitivity,
} from './cameraSensitivity.js';
test('claims on a globe engine set its camera sensitivity and share one ledger', () => {
const engine = createFakeEngine();
assert.equal(claimCameraSensitivity(engine, 'traffic', 0.05), 0.05);
assert.equal(engine.camera.percentageChanged, 0.05);
// The engine and its camera are the same claimant.
assert.equal(claimCameraSensitivity(engine.camera, 'installations', 0.01), 0.01);
assert.deepEqual(cameraSensitivityClaims(engine), ['traffic', 'installations']);
assert.equal(releaseCameraSensitivity(engine, 'installations'), 0.05);
assert.equal(releaseCameraSensitivity(engine.camera, 'traffic'), 0.5);
assert.equal(engine.camera.percentageChanged, 0.5);
assert.deepEqual(cameraSensitivityClaims(engine), []);
});
+85 -49
View File
@@ -1,8 +1,8 @@
import { isUnavailableCapability } from './sources/capability.js'; import { isUnavailableCapability } from './sources/capability.js';
import { createOverpassFeatureSource } from './sources/overpassFeatures.js'; import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
import { applicationServices } from './services/application.js'; import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium';
import * as geo from './geo/index.js'; import * as geo from './geo/index.js';
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
import { import {
viewportBias, viewportBias,
placesNearViewRecovery, placesNearViewRecovery,
@@ -538,16 +538,21 @@ export const GLOBE_VIEW = Object.freeze({
/** /**
* Fly straight out to the full-earth globe view, keeping the current sub-camera * Fly straight out to the full-earth globe view, keeping the current sub-camera
* point centered so the user's continent stays in front of them. * point centered so the user's continent stays in front of them.
* @param {Cesium.Viewer} viewer * @param {import('./globe/contract.js').GlobeEngine | object} target The globe
* engine, or a viewer registered for one.
* @param {{duration?: number, onComplete?: Function, onCancel?: Function}} options * @param {{duration?: number, onComplete?: Function, onCancel?: Function}} options
* @returns {{latitude: number, longitude: number, heightM: number}} * @returns {{latitude: number, longitude: number, heightM: number} | null}
* Null without an engine.
*/ */
export function flyToGlobeView(viewer, options = {}) { export function flyToGlobeView(target, options = {}) {
const carto = viewer.camera.positionCartographic; const engine = engineFromViewer(target);
if (!engine) return null;
const { camera } = engine;
const carto = camera.pose().cartographic;
const longitude = geo.math.toDegrees(carto.longitude); const longitude = geo.math.toDegrees(carto.longitude);
const latitude = geo.math.toDegrees(carto.latitude); const latitude = geo.math.toDegrees(carto.latitude);
viewer.camera.cancelFlight(); camera.cancelFlight();
viewer.camera.flyTo({ camera.flyTo({
destination: geo.cartesian3.fromDegrees( destination: geo.cartesian3.fromDegrees(
longitude, longitude,
latitude, latitude,
@@ -559,9 +564,10 @@ export function flyToGlobeView(viewer, options = {}) {
roll: 0, roll: 0,
}, },
duration: finitePositive(options.duration) || GLOBE_VIEW.durationS, duration: finitePositive(options.duration) || GLOBE_VIEW.durationS,
endTransform: Cesium.Matrix4.IDENTITY, endTransform: geo.matrix4.IDENTITY,
// Cesium's Camera.flyTo reads `complete`/`cancel`. `onComplete`/`onCancel` // The engine's flyTo reads `complete`/`cancel`, as Cesium's Camera.flyTo
// are this module's OWN option names and are silently ignored by Cesium — // does. `onComplete`/`onCancel` are this module's OWN option names and were
// silently ignored by Cesium —
// spelling them through to flyTo meant the reset never resolved on the // spelling them through to flyTo meant the reset never resolved on the
// flight's own events and every caller fell back to its watchdog timeout. // flight's own events and every caller fell back to its watchdog timeout.
complete: options.onComplete, complete: options.onComplete,
@@ -584,7 +590,8 @@ export const LOCATIONS = Object.entries(CITY_POIS).map(([id, city]) => ({
* Fly the camera to a landmark using lookAt-based targeting. * Fly the camera to a landmark using lookAt-based targeting.
* Guarantees the target is centered in viewport via flyToBoundingSphere + lookAt. * Guarantees the target is centered in viewport via flyToBoundingSphere + lookAt.
* *
* @param {Cesium.Viewer} viewer * @param {import('./globe/contract.js').GlobeEngine | object} target The
* globe engine, or a viewer registered for one.
* @param {number} lat - Latitude in degrees * @param {number} lat - Latitude in degrees
* @param {number} lon - Longitude in degrees * @param {number} lon - Longitude in degrees
* @param {object} options * @param {object} options
@@ -596,10 +603,14 @@ export const LOCATIONS = Object.entries(CITY_POIS).map(([id, city]) => ({
* @param {number} options.duration - Flight duration in seconds (default 3.0) * @param {number} options.duration - Flight duration in seconds (default 3.0)
* @param {{cachedGroundFloor?: Function, warmGroundFloor?: Function}} [options.ground] - * @param {{cachedGroundFloor?: Function, warmGroundFloor?: Function}} [options.ground] -
* Optional ground-floor service; its cached elevation outranks `groundElevation` * Optional ground-floor service; its cached elevation outranks `groundElevation`
* @returns {{ targetPosition: Cesium.Cartesian3 }} The computed target for orbit use * @returns {{ targetPosition: {x: number, y: number, z: number} } | null} The
* computed target for orbit use; null without an engine.
*/ */
export function flyToLandmark(viewer, lat, lon, options = {}) { export function flyToLandmark(target, lat, lon, options = {}) {
cancelCameraArrival(viewer); const engine = engineFromViewer(target);
if (!engine) return null;
const { camera } = engine;
cancelCameraArrival(engine);
const { const {
range = 500, range = 500,
pitch = -30, pitch = -30,
@@ -616,7 +627,7 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
// Sample terrain height (sync — uses loaded tiles; 0 if globe/terrain not ready) // Sample terrain height (sync — uses loaded tiles; 0 if globe/terrain not ready)
const targetCartographic = geo.cartographic.fromDegrees(lon, lat); const targetCartographic = geo.cartographic.fromDegrees(lon, lat);
const sampledHeight = viewer.scene.globe?.getHeight(targetCartographic); const sampledHeight = engine.surface.terrainHeight(targetCartographic);
// Use sampled height if available, otherwise the cached ground-floor // Use sampled height if available, otherwise the cached ground-floor
// elevation (same ellipsoidal datum), then the pre-baked city elevation. // elevation (same ellipsoidal datum), then the pre-baked city elevation.
@@ -646,16 +657,16 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
const boundingRadius = bounds ? buildingBoundingRadius(bounds) : 0; const boundingRadius = bounds ? buildingBoundingRadius(bounds) : 0;
const framingRange = bounds const framingRange = bounds
? Math.max( ? Math.max(
rangeForBoundingSphere(viewer, boundingRadius), rangeForBoundingSphere(engine, boundingRadius),
boundingRadius * 1.35, boundingRadius * 1.35,
) )
: range; : range;
const hpr = new Cesium.HeadingPitchRange( const hpr = {
geo.math.toRadians(heading), heading: geo.math.toRadians(heading),
geo.math.toRadians(pitch), pitch: geo.math.toRadians(pitch),
framingRange, range: framingRange,
); };
if (typeof onStart === 'function') { if (typeof onStart === 'function') {
try { try {
@@ -666,20 +677,20 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
} }
// Fly to target, then lock with lookAt for guaranteed centering // Fly to target, then lock with lookAt for guaranteed centering
viewer.camera.flyToBoundingSphere( camera.flyToSphere(
new Cesium.BoundingSphere(targetPosition, boundingRadius), { center: targetPosition, radius: boundingRadius },
{ {
offset: hpr, ...hpr,
duration, duration,
complete: () => { complete: () => {
viewer.camera.lookAt(targetPosition, hpr); camera.lookAt(targetPosition, hpr);
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); camera.release();
// Framing used a predicted ground height. Once tiles stream in, measure // Framing used a predicted ground height. Once tiles stream in, measure
// the rendered surface and lift the eye if it is buried or pressed // the rendered surface and lift the eye if it is buried or pressed
// against it; a newer arrival or a manual gesture ends the check. // against it; a newer arrival or a manual gesture ends the check.
try { try {
guardCameraAboveGround( guardCameraAboveGround(
viewer, engine,
{ lat, lon }, { lat, lon },
{ isStale: () => generation !== arrivalGeneration }, { isStale: () => generation !== arrivalGeneration },
); );
@@ -718,7 +729,7 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
* Fly to a preset location by ID (uses the first POI as default). * Fly to a preset location by ID (uses the first POI as default).
* Returns target position for orbit controller. * Returns target position for orbit controller.
*/ */
export function flyToPresetLocation(viewer, locationId, options = {}) { export function flyToPresetLocation(target, locationId, options = {}) {
const city = CITY_POIS[locationId]; const city = CITY_POIS[locationId];
if (!city) return null; if (!city) return null;
if ( if (
@@ -726,7 +737,7 @@ export function flyToPresetLocation(viewer, locationId, options = {}) {
!finitePositive(options.range) && !finitePositive(options.range) &&
city.viewBounds city.viewBounds
) { ) {
return flyToViewportBounds(viewer, city.viewBounds, { return flyToViewportBounds(engineFromViewer(target), city.viewBounds, {
duration: options.duration, duration: options.duration,
onStart: options.onStart, onStart: options.onStart,
onComplete: options.onComplete, onComplete: options.onComplete,
@@ -735,7 +746,7 @@ export function flyToPresetLocation(viewer, locationId, options = {}) {
}); });
} }
const poi = city.pois[0]; const poi = city.pois[0];
return flyToLandmark(viewer, poi.lat, poi.lon, { return flyToLandmark(target, poi.lat, poi.lon, {
range: poi.alt, range: poi.alt,
pitch: poi.pitch, pitch: poi.pitch,
heading: poi.heading || 0, heading: poi.heading || 0,
@@ -750,11 +761,11 @@ export function flyToPresetLocation(viewer, locationId, options = {}) {
* Fly to a specific POI within a city. * Fly to a specific POI within a city.
* Returns target position for orbit controller. * Returns target position for orbit controller.
*/ */
export function flyToPOI(viewer, cityId, poiIndex, options = {}) { export function flyToPOI(target, cityId, poiIndex, options = {}) {
const city = CITY_POIS[cityId]; const city = CITY_POIS[cityId];
if (!city || !city.pois[poiIndex]) return null; if (!city || !city.pois[poiIndex]) return null;
const poi = city.pois[poiIndex]; const poi = city.pois[poiIndex];
return flyToLandmark(viewer, poi.lat, poi.lon, { return flyToLandmark(target, poi.lat, poi.lon, {
range: poi.alt, range: poi.alt,
pitch: poi.pitch, pitch: poi.pitch,
heading: poi.heading || 0, heading: poi.heading || 0,
@@ -811,15 +822,18 @@ export const CANCELLED_SEARCH = Object.freeze({ cancelled: true });
* appropriate to the request. Countries and cities use their viewport by * appropriate to the request. Countries and cities use their viewport by
* default; precise landmarks/buildings use close landmark framing. * default; precise landmarks/buildings use close landmark framing.
*/ */
export async function searchAndFlyTo(viewer, query, options = {}) { export async function searchAndFlyTo(target, query, options = {}) {
const { placeSearch = unavailablePlaceSearch, signal } = options; const { placeSearch = unavailablePlaceSearch, signal } = options;
// The globe engine, or (voice, until phase 1c) a viewer registered for one.
const engine = engineFromViewer(target);
if (!engine) return null;
signal?.throwIfAborted(); signal?.throwIfAborted();
const beforeFly = const beforeFly =
typeof options.beforeFly === 'function' ? options.beforeFly : null; typeof options.beforeFly === 'function' ? options.beforeFly : null;
const mayFly = () => const mayFly = () =>
!signal?.aborted && (beforeFly === null || beforeFly() !== false); !signal?.aborted && (beforeFly === null || beforeFly() !== false);
const outcome = await placeSearch.geocode(query, { const outcome = await placeSearch.geocode(query, {
bias: viewportBias(viewer), bias: viewportBias(engine),
signal, signal,
}); });
signal?.throwIfAborted(); signal?.throwIfAborted();
@@ -838,7 +852,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
const recovered = result?.exact const recovered = result?.exact
? null ? null
: await (options.recoverNearView || placesNearViewRecovery)( : await (options.recoverNearView || placesNearViewRecovery)(
viewer, engine,
query, query,
result && !outcome.fallbackUsed ? { lat, lon: lng } : null, result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
signal, signal,
@@ -874,7 +888,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
navigationMode === 'area-overview' ? regionFramingPlan(viewport) : null; navigationMode === 'area-overview' ? regionFramingPlan(viewport) : null;
if (swath?.mode === 'swath') { if (swath?.mode === 'swath') {
if (!mayFly()) return CANCELLED_SEARCH; if (!mayFly()) return CANCELLED_SEARCH;
flyToLandmark(viewer, swath.centerLat, swath.centerLng, { flyToLandmark(engine, swath.centerLat, swath.centerLng, {
range: swath.rangeM, range: swath.rangeM,
pitch: swath.pitchDeg, pitch: swath.pitchDeg,
heading: swath.headingDeg, heading: swath.headingDeg,
@@ -910,7 +924,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
const framedViewport = gateFraming const framedViewport = gateFraming
? placeFramingViewport(viewport, lat, lng, types) ? placeFramingViewport(viewport, lat, lng, types)
: viewport; : viewport;
const flight = flyToViewportBounds(viewer, framedViewport, { const flight = flyToViewportBounds(engine, framedViewport, {
duration, duration,
navigationMode, navigationMode,
beforeFly: mayFly, beforeFly: mayFly,
@@ -956,7 +970,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
]); ]);
const searchGroundElevation = ground?.cachedGroundFloor?.(flyLat, flyLon); const searchGroundElevation = ground?.cachedGroundFloor?.(flyLat, flyLon);
if (!mayFly()) return CANCELLED_SEARCH; if (!mayFly()) return CANCELLED_SEARCH;
const flight = flyToLandmark(viewer, flyLat, flyLon, { const flight = flyToLandmark(engine, flyLat, flyLon, {
range, range,
pitch: buildingPitch(buildingBounds), pitch: buildingPitch(buildingBounds),
heading: 30, heading: 30,
@@ -1274,7 +1288,7 @@ function shouldFrameGeocodeViewport(mode) {
); );
} }
function flyToViewportBounds(viewer, viewport, options = {}) { function flyToViewportBounds(engine, viewport, options = {}) {
const { const {
duration = 3.0, duration = 3.0,
beforeFly = null, beforeFly = null,
@@ -1298,8 +1312,9 @@ function flyToViewportBounds(viewer, viewport, options = {}) {
// that crosses the antimeridian: Alaska (sw 172.3E, ne -130.0) subtracts to // that crosses the antimeridian: Alaska (sw 172.3E, ne -130.0) subtracts to
// -302, and a 0.4-degree metro box straddling the dateline subtracts to -359.6, // -302, and a 0.4-degree metro box straddling the dateline subtracts to -359.6,
// whose 12% padding alone is 43 degrees — that box framed 86.7 degrees of ocean // whose 12% padding alone is 43 degrees — that box framed 86.7 degrees of ocean
// instead of a city. Cesium's Rectangle does NOT normalize past +/-180, so the // instead of a city. A rectangle destination (Cesium's Rectangle) does NOT
// padded edges are wrapped here into a proper east<west crossing rectangle. // normalize past +/-180, so the padded edges are wrapped here into a proper
// east<west crossing rectangle.
const metrics = viewportMetrics(viewport); const metrics = viewportMetrics(viewport);
const latitudePadding = Math.max(0.05, Math.abs(metrics.latSpanDeg) * 0.12); const latitudePadding = Math.max(0.05, Math.abs(metrics.latSpanDeg) * 0.12);
const longitudePadding = Math.max(0.05, metrics.lonSpanDeg * 0.12); const longitudePadding = Math.max(0.05, metrics.lonSpanDeg * 0.12);
@@ -1308,8 +1323,8 @@ function flyToViewportBounds(viewer, viewport, options = {}) {
const north = Math.min(89.9, northeast.lat + latitudePadding); const north = Math.min(89.9, northeast.lat + latitudePadding);
const rectangle = const rectangle =
paddedLonSpan >= 360 paddedLonSpan >= 360
? Cesium.Rectangle.fromDegrees(-180, south, 180, north) ? rectangleFromDegrees(-180, south, 180, north)
: Cesium.Rectangle.fromDegrees( : rectangleFromDegrees(
wrapLongitude(southwest.lng - longitudePadding), wrapLongitude(southwest.lng - longitudePadding),
south, south,
wrapLongitude(southwest.lng + metrics.lonSpanDeg + longitudePadding), wrapLongitude(southwest.lng + metrics.lonSpanDeg + longitudePadding),
@@ -1324,10 +1339,10 @@ function flyToViewportBounds(viewer, viewport, options = {}) {
/* no-op */ /* no-op */
} }
} }
viewer.camera.flyTo({ engine.camera.flyTo({
destination: rectangle, destination: rectangle,
duration, duration,
endTransform: Cesium.Matrix4.IDENTITY, endTransform: geo.matrix4.IDENTITY,
complete: () => { complete: () => {
if (typeof onComplete === 'function') { if (typeof onComplete === 'function') {
try { try {
@@ -1378,10 +1393,31 @@ function buildingBoundingRadius(bounds) {
return Math.hypot(halfHeight, halfWidth, halfDepth) * 1.18; return Math.hypot(halfHeight, halfWidth, halfDepth) * 1.18;
} }
function rangeForBoundingSphere(viewer, radius) { /** A {west, south, east, north} radians rectangle, as Cesium's Rectangle.fromDegrees. */
const frustum = viewer.camera.frustum; function rectangleFromDegrees(west, south, east, north) {
const verticalFov = Number(frustum?.fov) || geo.math.toRadians(60); return {
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1); west: geo.math.toRadians(west),
south: geo.math.toRadians(south),
east: geo.math.toRadians(east),
north: geo.math.toRadians(north),
};
}
/**
* The perspective frustum's `fov` (Cesium's PerspectiveFrustum: the angle
* across the wider side) from the pose's vertical fov and aspect ratio.
*/
function frustumFov({ fovy, aspectRatio }) {
if (!Number.isFinite(fovy) || !Number.isFinite(aspectRatio)) return NaN;
return aspectRatio <= 1
? fovy
: 2 * Math.atan(Math.tan(fovy / 2) * aspectRatio);
}
function rangeForBoundingSphere(engine, radius) {
const pose = engine.camera.pose();
const verticalFov = frustumFov(pose) || geo.math.toRadians(60);
const aspectRatio = Math.max(0.5, Number(pose.aspectRatio) || 1);
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio); const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
const limitingFov = Math.min(verticalFov, horizontalFov); const limitingFov = Math.min(verticalFov, horizontalFov);
// Target 57% sphere occupancy so perspective still leaves at least 40% // Target 57% sphere occupancy so perspective still leaves at least 40%
+31 -22
View File
@@ -21,25 +21,28 @@ import {
GLOBE_VIEW, GLOBE_VIEW,
searchAndFlyTo, searchAndFlyTo,
} from './locations.js'; } from './locations.js';
import * as geo from './geo/index.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
/**
* A globe engine over Austin at 1200 m. `flights` lists the flights asked of
* it in order: flyTo options, and flyToSphere options with their `sphere`.
*/
function stubViewer() { function stubViewer() {
const flights = []; const engine = createFakeEngine();
return { engine.fake.setPose({
flights, position: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 1200),
scene: { globe: null, canvas: { clientWidth: 0, clientHeight: 0 } }, });
camera: { // No globe terrain answers, as with the globe hidden under photoreal tiles.
positionCartographic: { engine.fake.script('terrainHeight', undefined);
longitude: Cesium.Math.toRadians(-97.7431), return Object.defineProperty(engine, 'flights', {
latitude: Cesium.Math.toRadians(30.2672), get: () =>
height: 1200, engine.fake.calls
}, .filter(({ method }) => method === 'camera.flyTo' || method === 'camera.flyToSphere')
cancelFlight() {}, .map(({ method, args }) =>
flyTo(options) { flights.push(options); }, method === 'camera.flyTo' ? args[0] : { sphere: args[0], ...args[1] },
flyToBoundingSphere(sphere, options) { flights.push({ sphere, ...options }); }, ),
lookAt() {}, });
lookAtTransform() {},
},
};
} }
const AUSTIN_RESULT = { const AUSTIN_RESULT = {
@@ -73,8 +76,14 @@ async function runSearch(viewer, options, { result = AUSTIN_RESULT, query = 'aus
/** Read a recorded viewport flight back as a degrees rectangle. */ /** Read a recorded viewport flight back as a degrees rectangle. */
function flownRectangleDegrees(viewer, index = 0) { function flownRectangleDegrees(viewer, index = 0) {
const rectangle = viewer.flights[index]?.destination; const destination = viewer.flights[index]?.destination;
assert.ok(rectangle instanceof Cesium.Rectangle, 'expected a rectangle (viewport) flight'); assert.ok(destination && 'west' in destination, 'expected a rectangle (viewport) flight');
const rectangle = new Cesium.Rectangle(
destination.west,
destination.south,
destination.east,
destination.north,
);
return { return {
south: Cesium.Math.toDegrees(rectangle.south), south: Cesium.Math.toDegrees(rectangle.south),
north: Cesium.Math.toDegrees(rectangle.north), north: Cesium.Math.toDegrees(rectangle.north),
@@ -541,11 +550,11 @@ test('geocoded Location branches forward the resolved-navigation ownership hook'
test('globe and city-overview flights name the world frame explicitly', () => { test('globe and city-overview flights name the world frame explicitly', () => {
const globeViewer = stubViewer(); const globeViewer = stubViewer();
flyToGlobeView(globeViewer); flyToGlobeView(globeViewer);
assert.equal(globeViewer.flights[0].endTransform, Cesium.Matrix4.IDENTITY); assert.deepEqual([...globeViewer.flights[0].endTransform], [...geo.matrix4.IDENTITY]);
const cityViewer = stubViewer(); const cityViewer = stubViewer();
flyToPresetLocation(cityViewer, 'austin', { viewMode: 'overview' }); flyToPresetLocation(cityViewer, 'austin', { viewMode: 'overview' });
assert.equal(cityViewer.flights[0].endTransform, Cesium.Matrix4.IDENTITY); assert.deepEqual([...cityViewer.flights[0].endTransform], [...geo.matrix4.IDENTITY]);
}); });
test('city and landmark flights expose completion and cancellation hooks', () => { test('city and landmark flights expose completion and cancellation hooks', () => {
@@ -657,6 +666,6 @@ test('a precise search without an outline frames against the resolved ground, no
const flight = viewer.flights[0]; const flight = viewer.flights[0];
const target = Cesium.Cartographic.fromCartesian(flight.sphere.center); const target = Cesium.Cartographic.fromCartesian(flight.sphere.center);
assert.ok(Math.abs(target.height - (171 + 30)) < 0.5, `target ${target.height}`); assert.ok(Math.abs(target.height - (171 + 30)) < 0.5, `target ${target.height}`);
const eye = target.height + flight.offset.range * Math.sin(-flight.offset.pitch); const eye = target.height + flight.range * Math.sin(-flight.pitch);
assert.ok(eye > 171 + 100, `eye ${eye} must clear the 171 m surface`); assert.ok(eye > 171 + 100, `eye ${eye} must clear the 171 m surface`);
}); });
+20 -16
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import * as geo from './geo/index.js'; import * as geo from './geo/index.js';
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
import { import {
holdContinuousRender, holdContinuousRender,
releaseContinuousRender, releaseContinuousRender,
@@ -7,12 +7,16 @@ import {
/** /**
* OrbitController — smooth orbit around a target point. * OrbitController — smooth orbit around a target point.
* Uses scene.preRender for frame-rate-independent 60fps updates. * Uses the engine's preRender frames for frame-rate-independent updates.
* Toggle with O key; auto-stops on POI/city change. * Toggle with O key; auto-stops on POI/city change.
*/ */
export class OrbitController { export class OrbitController {
constructor(viewer) { /**
this.viewer = viewer; * @param {import('./globe/contract.js').GlobeEngine | object} engine The
* globe engine, or a viewer registered for one.
*/
constructor(engine) {
this.engine = engineFromViewer(engine);
this.active = false; this.active = false;
this.target = null; this.target = null;
this.radius = 500; this.radius = 500;
@@ -24,14 +28,15 @@ export class OrbitController {
/** /**
* Start orbiting around a target position. * Start orbiting around a target position.
* @param {Cesium.Cartesian3} targetCartesian - The point to orbit around * @param {{x: number, y: number, z: number}} targetCartesian - The point to orbit around
* @param {object} options * @param {object} options
* @param {number} options.radius - Distance from target in meters * @param {number} options.radius - Distance from target in meters
* @param {number} options.pitch - Tilt angle in degrees (negative = looking down) * @param {number} options.pitch - Tilt angle in degrees (negative = looking down)
* @param {number} options.speed - Degrees per second (default 6) * @param {number} options.speed - Degrees per second (default 6)
*/ */
start(targetCartesian, options = {}) { start(targetCartesian, options = {}) {
if (!targetCartesian) return; if (!targetCartesian || !this.engine) return;
const { camera, frames } = this.engine;
this.target = targetCartesian; this.target = targetCartesian;
this.radius = options.radius || this.radius; this.radius = options.radius || this.radius;
@@ -43,10 +48,10 @@ export class OrbitController {
holdContinuousRender('camera-orbit'); holdContinuousRender('camera-orbit');
// Start from the camera's current heading for seamless transition // Start from the camera's current heading for seamless transition
this.angle = this.viewer.camera.heading; this.angle = camera.pose().heading;
let lastTime = Date.now(); let lastTime = Date.now();
this._removeListener = this.viewer.scene.preRender.addEventListener(() => { this._removeListener = frames.preRender.addEventListener(() => {
if (!this.active) return; if (!this.active) return;
const now = Date.now(); const now = Date.now();
@@ -55,12 +60,11 @@ export class OrbitController {
this.angle += this.speed * dt; this.angle += this.speed * dt;
const hpr = new Cesium.HeadingPitchRange( camera.lookAt(this.target, {
this.angle, heading: this.angle,
geo.math.toRadians(this.pitch), pitch: geo.math.toRadians(this.pitch),
this.radius, range: this.radius,
); });
this.viewer.camera.lookAt(this.target, hpr);
}); });
} }
@@ -75,12 +79,12 @@ export class OrbitController {
this._removeListener = null; this._removeListener = null;
} }
// Unlock camera for free interaction // Unlock camera for free interaction
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); this.engine?.camera.release();
} }
/** /**
* Toggle orbit on/off. * Toggle orbit on/off.
* @param {Cesium.Cartesian3} targetCartesian - Required when starting * @param {{x: number, y: number, z: number}} targetCartesian - Required when starting
* @param {object} options - Passed to start() * @param {object} options - Passed to start()
* @returns {boolean} Whether orbit is now active * @returns {boolean} Whether orbit is now active
*/ */
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from './geo/index.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
import { OrbitController } from './orbit.js';
import {
_resetRenderGovernorForTest,
getRenderGovernorDiagnostics,
} from './renderGovernor.js';
test.afterEach(() => _resetRenderGovernorForTest());
test('orbit locks onto the target every frame from the current heading, and stop releases it', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 1000 });
const engine = createFakeEngine();
engine.fake.setPose({
position: geo.cartesian3.fromDegrees(-97.74, 30.26, 2000),
heading: 0.4,
pitch: -0.5,
roll: 0,
});
const target = geo.cartesian3.fromDegrees(-97.74, 30.27, 30);
const orbit = new OrbitController(engine);
assert.equal(orbit.toggle(target, { radius: 800, pitch: -20, speed: 10 }), true);
assert.ok(getRenderGovernorDiagnostics().holds.includes('camera-orbit'));
t.mock.timers.tick(500);
engine.fake.frame();
const [first] = engine.fake.callsOf('camera.lookAt');
assert.deepEqual(first.args[0], target);
assert.ok(Math.abs(first.args[1].heading - (0.4 + geo.math.toRadians(5))) < 1e-9);
assert.equal(first.args[1].pitch, geo.math.toRadians(-20));
assert.equal(first.args[1].range, 800);
assert.equal(orbit.toggle(), false);
assert.equal(engine.fake.callsOf('camera.release').length, 1);
assert.ok(!getRenderGovernorDiagnostics().holds.includes('camera-orbit'));
engine.fake.frame();
assert.equal(engine.fake.callsOf('camera.lookAt').length, 1, 'no frames after stop');
});
test('orbit needs a target', () => {
const engine = createFakeEngine();
const orbit = new OrbitController(engine);
assert.equal(orbit.toggle(null), false);
assert.equal(engine.frames.preRender.numberOfListeners, 0);
});
+5
View File
@@ -24,6 +24,8 @@ import { MAP_STACKS } from '../mapStackController.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js'; import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { presentResult } from './resultDisplay.js'; import { presentResult } from './resultDisplay.js';
import { attachVoiceResult } from './speech.js'; import { attachVoiceResult } from './speech.js';
import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js';
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
test('every live basemap is reachable by its own id — no enum value without a voice alias', () => { test('every live basemap is reachable by its own id — no enum value without a voice alias', () => {
// B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum) // B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum)
@@ -130,6 +132,9 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
lookAtTransform() {}, lookAtTransform() {},
}, },
}; };
// Location flights run on the globe engine registered for the viewer, as
// the app's scene engine is (src/app/globeEngine.js).
registerViewerEngine(viewer, createCesiumEngine(viewer));
const styleManager = { const styleManager = {
runImmediateNavigation(noun, navigate, releaseOptions = undefined) { runImmediateNavigation(noun, navigate, releaseOptions = undefined) {
return runExplicitNavigation({ return runExplicitNavigation({
+16 -13
View File
@@ -1,8 +1,8 @@
/** /**
* One-click camera transfer for layer-owned world targets. * One-click camera transfer for layer-owned world targets.
*/ */
import * as Cesium from 'cesium';
import * as geo from './geo/index.js'; import * as geo from './geo/index.js';
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
export const WORLD_FOCUS_REQUEST_EVENT = 'gev:world-request-focus'; export const WORLD_FOCUS_REQUEST_EVENT = 'gev:world-request-focus';
export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9; export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9;
@@ -70,27 +70,30 @@ export function routeWorldFocusRequest(event, runExplicitFocus, fly) {
return runExplicitFocus(detail, () => fly(detail)); return runExplicitFocus(detail, () => fly(detail));
} }
/** Fly to a world target after ownership has been released. */ /**
export function flyToWorldTarget(viewer, target = {}) { * Fly to a world target after ownership has been released.
const camera = viewer?.camera; * @param {import('./globe/contract.js').GlobeEngine | object | null} engine
* The globe engine, or a viewer registered for one.
*/
export function flyToWorldTarget(engine, target = {}) {
const camera = engineFromViewer(engine)?.camera;
const framing = WORLD_FOCUS_FRAMING[target.kind]; const framing = WORLD_FOCUS_FRAMING[target.kind];
if (!camera || !framing || !isValidWorldFocusTarget(target)) return false; if (!camera || !framing || !isValidWorldFocusTarget(target)) return false;
const heading = Number.isFinite(camera.heading) ? camera.heading : 0; const { heading: current } = camera.pose();
const heading = Number.isFinite(current) ? current : 0;
const duration = const duration =
target.durationSec > 0 target.durationSec > 0
? target.durationSec ? target.durationSec
: WORLD_CLICK_FOCUS_DURATION_SEC; : WORLD_CLICK_FOCUS_DURATION_SEC;
camera.cancelFlight?.(); camera.cancelFlight();
camera.flyToBoundingSphere( camera.flyToSphere(
new Cesium.BoundingSphere(target.position, framing.radiusM), { center: target.position, radius: framing.radiusM },
{ {
offset: new Cesium.HeadingPitchRange(
heading, heading,
geo.math.toRadians(framing.pitchDeg), pitch: geo.math.toRadians(framing.pitchDeg),
framing.rangeM, range: framing.rangeM,
),
duration, duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easing: 'cubicInOut',
}, },
); );
return true; return true;
+34 -17
View File
@@ -13,17 +13,33 @@ import {
requestWorldFocus, requestWorldFocus,
routeWorldFocusRequest, routeWorldFocusRequest,
} from './worldFocus.js'; } from './worldFocus.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
const POSITION = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 0); const POSITION = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 0);
function stubCamera() { /** A globe engine looking north-east (heading 45°) at the test target. */
const calls = { cancelFlight: 0, flights: [] }; function stubEngine() {
return { const engine = createFakeEngine();
calls, engine.fake.setPose({
position: Cesium.Cartesian3.fromDegrees(-97.74, 30.2, 3000),
heading: Cesium.Math.toRadians(45), heading: Cesium.Math.toRadians(45),
cancelFlight() { calls.cancelFlight += 1; }, pitch: -0.5,
flyToBoundingSphere(sphere, options) { calls.flights.push({ sphere, options }); }, roll: 0,
});
const camera = {
heading: engine.camera.pose().heading,
calls: {
get cancelFlight() {
return engine.fake.callsOf('camera.cancelFlight').length;
},
get flights() {
return engine.fake
.callsOf('camera.flyToSphere')
.map(({ args: [sphere, options] }) => ({ sphere, options }));
},
},
}; };
return { engine, camera };
} }
test('a focus request carries the clicked target to any registered listener', () => { test('a focus request carries the clicked target to any registered listener', () => {
@@ -116,25 +132,26 @@ test('an unflyable request never reaches the release policy', () => {
}); });
test('the transfer flight supersedes any flight in progress and keeps the operator heading', () => { test('the transfer flight supersedes any flight in progress and keeps the operator heading', () => {
const camera = stubCamera(); const { engine, camera } = stubEngine();
assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel', id: '123', position: POSITION }), true); assert.equal(flyToWorldTarget(engine, { kind: 'vessel', id: '123', position: POSITION }), true);
assert.equal(camera.calls.cancelFlight, 1, 'a prior flight must be cancelled, not queued'); assert.equal(camera.calls.cancelFlight, 1, 'a prior flight must be cancelled, not queued');
assert.equal(camera.calls.flights.length, 1); assert.equal(camera.calls.flights.length, 1);
const { sphere, options } = camera.calls.flights[0]; const { sphere, options } = camera.calls.flights[0];
assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.vessel.radiusM); assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.vessel.radiusM);
assert.equal(options.duration, WORLD_CLICK_FOCUS_DURATION_SEC); assert.equal(options.duration, WORLD_CLICK_FOCUS_DURATION_SEC);
assert.equal(options.offset.range, WORLD_FOCUS_FRAMING.vessel.rangeM); assert.equal(options.range, WORLD_FOCUS_FRAMING.vessel.rangeM);
// Heading is preserved so the transfer never spins the operator around. // Heading is preserved so the transfer never spins the operator around.
assert.equal(options.offset.heading, camera.heading); assert.equal(options.heading, camera.heading);
assert.equal(options.easingFunction, Cesium.EasingFunction.CUBIC_IN_OUT); assert.ok(Math.abs(camera.heading - Cesium.Math.toRadians(45)) < 1e-9);
assert.equal(options.easing, 'cubicInOut');
}); });
test('fires frame wider than vessels — a fire is read by its surroundings', () => { test('fires frame wider than vessels — a fire is read by its surroundings', () => {
const camera = stubCamera(); const { engine, camera } = stubEngine();
flyToWorldTarget({ camera }, { kind: 'fire', id: 'fire-1', position: POSITION }); flyToWorldTarget(engine, { kind: 'fire', id: 'fire-1', position: POSITION });
const { sphere, options } = camera.calls.flights[0]; const { sphere, options } = camera.calls.flights[0];
assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.fire.radiusM); assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.fire.radiusM);
assert.equal(options.offset.range, WORLD_FOCUS_FRAMING.fire.rangeM); assert.equal(options.range, WORLD_FOCUS_FRAMING.fire.rangeM);
assert.ok(WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM); assert.ok(WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM);
assert.ok(WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM); assert.ok(WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM);
}); });
@@ -161,9 +178,9 @@ test('every framing is a real oblique standoff, not a nadir or an inside-out sph
}); });
test('unknown kinds and missing viewers issue no flight', () => { test('unknown kinds and missing viewers issue no flight', () => {
const camera = stubCamera(); const { engine, camera } = stubEngine();
assert.equal(flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), false); assert.equal(flyToWorldTarget(engine, { kind: 'plane', position: POSITION }), false);
assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel' }), false); assert.equal(flyToWorldTarget(engine, { kind: 'vessel' }), false);
assert.equal(flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), false); assert.equal(flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), false);
assert.equal(camera.calls.flights.length, 0); assert.equal(camera.calls.flights.length, 0);
}); });