refactor(ui): navigation, orientation, share links and HUD use the globe engine

The application shell keeps the page's globe engine and hands it to the
navigation controller, location navigation (search, city and POI flights,
orbit, globe reset), the tilt and north-up controls, share-link save and
restore, share restoration's first-gesture listeners and the HUD. The
viewer is passed only where entity tracking still lives on it (phase 1c)
and to the HUD's basemap label context (voice); every such use is null-safe.

Share links and captured shots write heading and roll as Cesium's camera
reports them (src/cameraAngles.js): the contract's [0, 2π) pose maps an
exact 0 back to Cesium's 2π, except the roll of a vertical view, so
links stay byte-identical (heading=360, roll=360) on every engine.

Source-reading tests follow the moved code with the same checks.
This commit is contained in:
German Khokhlov committed 2026-10-15 19:47:51 +04:00
1 parent 16e63f4c50
commit ec38e6ef16
19 files changed
+587 -219

No files matched your search

+2
View File
@@ -551,6 +551,7 @@
"exports": ["./ui/shell"], "exports": ["./ui/shell"],
"modules": [ "modules": [
"src/globe/cesium/engineFromViewer.js", "src/globe/cesium/engineFromViewer.js",
"src/cameraAngles.js",
"src/globe/contract.js", "src/globe/contract.js",
"src/hudLayouts.js", "src/hudLayouts.js",
"src/hudLayoutPolicy.js", "src/hudLayoutPolicy.js",
@@ -1696,6 +1697,7 @@
"./standalone/catalog" "./standalone/catalog"
], ],
"modules": [ "modules": [
"src/cameraAngles.js",
"src/cyberSonarScene.js", "src/cyberSonarScene.js",
"src/cyberSonar.js", "src/cyberSonar.js",
"src/hudLayouts.js", "src/hudLayouts.js",
+4 -1
View File
@@ -17,7 +17,10 @@ test('the controls pass the scene engine to the shell', () => {
test('the shell initializes the world overlay on its engine', () => { test('the shell initializes the world overlay on its engine', () => {
const shell = read('../ui/applicationShell.js'); const shell = read('../ui/applicationShell.js');
assert.match(shell, /\{\n\s+engine = null,\n\s+mapStackController = null,/); assert.match(
shell,
/\{\n\s+engine = engineFromViewer\(viewer\),\n\s+mapStackController = null,/,
);
assert.match(shell, /initWorldOverlay\(engine\);/); assert.match(shell, /initWorldOverlay\(engine\);/);
assert.doesNotMatch(shell, /initWorldOverlay\(viewer\)/); assert.doesNotMatch(shell, /initWorldOverlay\(viewer\)/);
}); });
+32
View File
@@ -0,0 +1,32 @@
// Camera heading and roll as Cesium's camera reports them, from an engine pose.
//
// The globe contract keeps pose angles in [0, 2π) (src/globe/contract.js).
// Cesium's own Camera.heading and .roll lie in (0, 2π]: a view facing exactly
// north reports heading 2π, a level view reports roll 2π, and roll is exactly
// 0 only in Cesium's vertical branch (looking within 1e-3 of straight up or
// down). Share links and captured scene shots have always carried Cesium's
// numbers (`heading=360`, `roll=360`), so they are written from these, and a
// link or shot made on any engine reads as one made on Cesium. The contract
// folds Cesium's values within 1e-12 of 0 and 2π onto 0; a reading that was a
// hair above 0 therefore comes back as 2π here, the only case that differs.
import * as geo from './geo/index.js';
const { TWO_PI } = geo.math;
/** Whether a pose looks (nearly, within 1e-3) straight up or down, as Cesium tests it. */
export function poseIsVertical({ position, direction }) {
const up = geo.ellipsoid.WGS84.geodeticSurfaceNormal(
position,
geo.cartesian3.create(),
);
return Math.abs(1 - Math.abs(geo.cartesian3.dot(direction, up))) <= 1e-3;
}
/** The pose heading as Cesium reports it, in (0, 2π]. */
export const cesiumHeading = (pose) =>
pose.heading === 0 ? TWO_PI : pose.heading;
/** The pose roll as Cesium reports it: (0, 2π], or exactly 0 for a vertical view. */
export const cesiumRoll = (pose) =>
pose.roll === 0 && !poseIsVertical(pose) ? TWO_PI : pose.roll;
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from './geo/index.js';
import { cesiumHeading, cesiumRoll, poseIsVertical } from './cameraAngles.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
const poseAt = (heading, pitch, roll = 0) => {
const engine = createFakeEngine();
engine.fake.setPose({
position: geo.cartesian3.fromDegrees(10, 45, 2000),
heading,
pitch,
roll,
});
return engine.camera.pose();
};
test('a level north-facing pose reports Cesium heading and roll 2π', () => {
const pose = poseAt(0, -0.5);
assert.equal(pose.heading, 0);
assert.equal(cesiumHeading(pose), 2 * Math.PI);
assert.equal(cesiumRoll({ ...pose, roll: 0 }), 2 * Math.PI);
assert.equal(poseIsVertical(pose), false);
});
test('a straight-down pose keeps roll 0; other angles pass through', () => {
const down = poseAt(0, -Math.PI / 2);
assert.equal(poseIsVertical(down), true);
assert.equal(cesiumRoll(down), 0);
const oblique = { ...poseAt(1, -0.5), heading: 1.25, roll: 0.5 };
assert.equal(cesiumHeading(oblique), 1.25);
assert.equal(cesiumRoll(oblique), 0.5);
});
+5 -5
View File
@@ -191,10 +191,10 @@ test('accepted navigation releases through PR15-aware ownership before flight',
'origin: trackingOrigin', 'origin: trackingOrigin',
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })', 'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
'rocketLaunchesLayer.releaseCameraOwnership?.()', 'rocketLaunchesLayer.releaseCameraOwnership?.()',
'this.viewer.trackedEntity = undefined;', 'if (this.viewer) this.viewer.trackedEntity = undefined;',
"interruptCameraMotion('explicit-navigation')", "interruptCameraMotion('explicit-navigation')",
'this.viewer.camera.cancelFlight();', 'camera?.cancelFlight();',
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);', 'camera?.release();',
], 'follow release'); ], 'follow release');
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/); assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/); assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
@@ -273,11 +273,11 @@ test('a direct globe gesture retires delayed camera and selection restore only',
); );
assert.match( assert.match(
ui, ui,
/viewer\?\.canvas\?\.addEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler/, /this\.canvas\?\.addEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler/,
); );
assert.match( assert.match(
ui, ui,
/viewer\?\.canvas\?\.addEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler/, /this\.canvas\?\.addEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler/,
); );
assert.match( assert.match(
ui, ui,
+1 -1
View File
@@ -533,7 +533,7 @@ test('Reset releases Contact camera ownership through its selection-preserving r
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => { test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
const releaseStart = ui.indexOf(' _releaseFollowCamera('); const releaseStart = ui.indexOf(' _releaseFollowCamera(');
const locationFlight = ui.indexOf('_flyWithTransition(cityChanged, flyAction)'); const locationFlight = ui.indexOf('_flyWithTransition(cityChanged, flyAction)');
const search = ui.indexOf('searchAndFlyTo(this.viewer, query, {'); const search = ui.indexOf('searchAndFlyTo(this.engine, query, {');
const voiceStart = voiceActions.indexOf('beginDeferredLocationNavigation'); const voiceStart = voiceActions.indexOf('beginDeferredLocationNavigation');
assert.ok(releaseStart >= 0, 'shared Location camera handoff is missing'); assert.ok(releaseStart >= 0, 'shared Location camera handoff is missing');
+26 -12
View File
@@ -15,6 +15,8 @@ import { applicationServices } from './services/application.js';
*/ */
import * as geo from './geo/index.js'; import * as geo from './geo/index.js';
import { isGlobeEngine } from './globe/contract.js';
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
import { forward as toMGRS } from 'mgrs'; import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './locations.js'; import { CITY_POIS } from './locations.js';
import { composeLocalityTag } from './hudLocality.js'; import { composeLocalityTag } from './hudLocality.js';
@@ -92,12 +94,17 @@ const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
*/ */
export class IntelHUD { export class IntelHUD {
/** /**
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for * @param {import('./globe/contract.js').GlobeEngine | object} engine The
* camera telemetry and coordinate derivation. * globe engine whose camera drives the telemetry, or a viewer registered
* for one.
* @param {object} [options] `viewer`: the Cesium viewer the basemap label
* context (voice, until phase 1c) reads; without one the summary has no
* place labels. Defaults to the first argument when that is a viewer.
*/ */
constructor( constructor(
viewer, engine,
{ {
viewer = isGlobeEngine(engine) ? null : engine,
placeSearch, placeSearch,
summaryPolicy = {}, summaryPolicy = {},
basemapContext = {}, basemapContext = {},
@@ -108,6 +115,7 @@ export class IntelHUD {
this.summaryPolicy = summaryPolicy; this.summaryPolicy = summaryPolicy;
this.basemapContext = basemapContext; this.basemapContext = basemapContext;
this.placeSearch = placeSearch; this.placeSearch = placeSearch;
this.engine = engineFromViewer(engine);
this.viewer = viewer; this.viewer = viewer;
this._visible = false; this._visible = false;
this._autoMode = true; // auto show/hide based on style this._autoMode = true; // auto show/hide based on style
@@ -172,7 +180,9 @@ export class IntelHUD {
this._passNum = 100 + Math.floor(Math.random() * 200); this._passNum = 100 + Math.floor(Math.random() * 200);
this._buildDOM(); this._buildDOM();
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd); this._removeCameraMoveEnd = this.engine.camera.moveEnd.addEventListener(
this._onCameraMoveEnd,
);
this._startTimers(); this._startTimers();
} }
@@ -332,13 +342,13 @@ export class IntelHUD {
/** /**
* Derive all camera-based telemetry and push values to the DOM. * Derive all camera-based telemetry and push values to the DOM.
* Reads the viewer camera's cartographic position and computes MGRS, * Reads the camera's cartographic position and computes MGRS,
* lat/lon DMS, GSD, NIIRS, sun elevation, off-nadir angle, and * lat/lon DMS, GSD, NIIRS, sun elevation, off-nadir angle, and
* collection timestamp. Stores results in {@link _latestMetrics}. * collection timestamp. Stores results in {@link _latestMetrics}.
*/ */
_updateCameraData() { _updateCameraData() {
const camera = this.viewer.camera; const pose = this.engine.camera.pose();
const cartographic = camera.positionCartographic; const cartographic = pose.cartographic;
if (!cartographic) return; if (!cartographic) return;
const lonDeg = geo.math.toDegrees(cartographic.longitude); const lonDeg = geo.math.toDegrees(cartographic.longitude);
@@ -407,7 +417,7 @@ export class IntelHUD {
// Off-nadir angle (ONA): camera pitch of -90 deg is nadir (straight down), // Off-nadir angle (ONA): camera pitch of -90 deg is nadir (straight down),
// so ONA = 90 + pitch gives 0 at nadir and increases toward the horizon. // so ONA = 90 + pitch gives 0 at nadir and increases toward the horizon.
const pitchDeg = geo.math.toDegrees(camera.pitch); const pitchDeg = geo.math.toDegrees(pose.pitch);
const ona = Math.max(0, 90 + pitchDeg); const ona = Math.max(0, 90 + pitchDeg);
const onaEl = document.getElementById('hud-ona'); const onaEl = document.getElementById('hud-ona');
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`; if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
@@ -569,7 +579,7 @@ export class IntelHUD {
* dimensions, or null if the view rectangle cannot be computed. * dimensions, or null if the view rectangle cannot be computed.
*/ */
_viewWindowKm(latDeg) { _viewWindowKm(latDeg) {
const rect = this.viewer.camera.computeViewRectangle(); const rect = this.engine.camera.viewRectangle();
if (!rect) return null; if (!rect) return null;
const north = geo.math.toDegrees(rect.north); const north = geo.math.toDegrees(rect.north);
const south = geo.math.toDegrees(rect.south); const south = geo.math.toDegrees(rect.south);
@@ -779,11 +789,14 @@ export class IntelHUD {
} }
async _summaryContext() { async _summaryContext() {
const labels = await getBasemapLabelContext( // Basemap labels still read the Cesium viewer (voice, phase 1c).
const labels = this.viewer
? await getBasemapLabelContext(
this.viewer, this.viewer,
this.placeSearch, this.placeSearch,
this.basemapContext, this.basemapContext,
); )
: { placeLabels: [], streetLabels: [], nearbyPlaceLabels: [] };
const enabledLayers = const enabledLayers =
this._dataManager this._dataManager
?.getAll?.() ?.getAll?.()
@@ -943,7 +956,8 @@ export class IntelHUD {
clearInterval(this._timestampInterval); clearInterval(this._timestampInterval);
clearInterval(this._summaryInterval); clearInterval(this._summaryInterval);
clearInterval(this._summaryTypingInterval); clearInterval(this._summaryTypingInterval);
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd); this._removeCameraMoveEnd?.();
this._removeCameraMoveEnd = null;
this._dataManagerUnsubscribe?.(); this._dataManagerUnsubscribe?.();
this._summaryRequest?.abort(); this._summaryRequest?.abort();
} }
+43 -2
View File
@@ -21,6 +21,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module'; import { registerHooks } from 'node:module';
import { ensureGeoidReady } from './data/geoid.js'; import { ensureGeoidReady } from './data/geoid.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
const MGRS_STUB_URL = 'gev-test-stub:mgrs'; const MGRS_STUB_URL = 'gev-test-stub:mgrs';
registerHooks({ registerHooks({
@@ -57,6 +58,18 @@ const SFO_ELLIPSOIDAL_M = -15;
* is deliberately absent so `_buildDOM` bails and the readouts stay the plain * is deliberately absent so `_buildDOM` bails and the readouts stay the plain
* text sinks this test reads. * text sinks this test reads.
*/ */
/**
* The HUD reads its camera through a globe engine: one standing where the
* viewer double's camera stands (cartographic, pitch, no view rectangle).
*/
function engineFor(viewer) {
const engine = createFakeEngine();
const { longitude, latitude, height } = viewer.camera.positionCartographic;
engine.fake.setPose({ longitude, latitude, height, heading: 0, pitch: viewer.camera.pitch, roll: 0 });
engine.fake.script('viewRectangle', undefined);
return engine;
}
function installHudEnvironment() { function installHudEnvironment() {
const elements = new Map( const elements = new Map(
['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode'] ['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode']
@@ -181,7 +194,7 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in
const env = installHudEnvironment(); const env = installHudEnvironment();
let hud; let hud;
try { try {
hud = new IntelHUD(env.viewer); hud = new IntelHUD(engineFor(env.viewer));
const alt = () => env.elements.get('hud-alt').textContent; const alt = () => env.elements.get('hud-alt').textContent;
const summary = () => env.elements.get('hud-summary').textContent; const summary = () => env.elements.get('hud-summary').textContent;
@@ -231,7 +244,7 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig
env.viewer.camera.positionCartographic.height = 100; env.viewer.camera.positionCartographic.height = 100;
let hud; let hud;
try { try {
hud = new IntelHUD(env.viewer); hud = new IntelHUD(engineFor(env.viewer));
hud._updateCameraData(); // cold: requests the grid, paints uncorrected hud._updateCameraData(); // cold: requests the grid, paints uncorrected
assert.match(env.elements.get('hud-alt').textContent, /^ALT: 100m/); assert.match(env.elements.get('hud-alt').textContent, /^ALT: 100m/);
await ensureGeoidReady(); await ensureGeoidReady();
@@ -248,3 +261,31 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig
env.restore(); env.restore();
} }
}); });
test('the HUD follows the engine camera: moveEnd repaints, the view window reads the engine, destroy unsubscribes', () => {
const env = installHudEnvironment();
const engine = engineFor(env.viewer);
let hud;
try {
hud = new IntelHUD(engine);
assert.equal(engine.camera.moveEnd.numberOfListeners, 1);
engine.fake.script('viewRectangle', {
west: (-122.5 * Math.PI) / 180,
south: (37.5 * Math.PI) / 180,
east: (-122.4 * Math.PI) / 180,
north: (37.6 * Math.PI) / 180,
});
const window = hud._viewWindowKm(37.55);
assert.ok(Math.abs(window.heightKm - 11.1) < 1e-9);
hud._visible = true;
hud._firstSummaryKicked = true; // no AI summary request from this test
engine.fake.emit('camera.moveEnd');
assert.match(env.elements.get('hud-mgrs').textContent, /^MGRS: 10S/);
hud.destroy();
hud = null;
assert.equal(engine.camera.moveEnd.numberOfListeners, 0);
} finally {
hud?.destroy();
env.restore();
}
});
+25 -1
View File
@@ -42,6 +42,30 @@ import {
setScopeMaskFeather, setScopeMaskFeather,
} from './scopeMask.js'; } from './scopeMask.js';
import { ShareLinkManager } from './sharelink.js'; import { ShareLinkManager } from './sharelink.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
/**
* A globe engine standing in for the viewer double this test used to build:
* the camera pose comes from `camera.positionCartographic`/heading/pitch/roll,
* and `camera.flyTo`/`setView` and `scene.requestRender` replace the engine's.
*/
function engineViewer({ camera = {}, scene = {} } = {}) {
const engine = createFakeEngine();
const carto = camera.positionCartographic;
if (carto)
engine.fake.setPose({
longitude: carto.longitude,
latitude: carto.latitude,
height: carto.height,
heading: camera.heading ?? 0,
pitch: camera.pitch ?? -Math.PI / 2,
roll: camera.roll ?? 0,
});
if (camera.flyTo) engine.camera.flyTo = camera.flyTo;
if (camera.setView) engine.camera.setView = camera.setView;
if (scene.requestRender) engine.render.request = () => scene.requestRender();
return engine;
}
// Follow the UI wiring and its extracted preset definitions. // Follow the UI wiring and its extracted preset definitions.
const uiSource = readShellSource() const uiSource = readShellSource()
@@ -71,7 +95,7 @@ function managerForHash(hash) {
roll: 0, roll: 0,
}, },
}; };
return new ShareLinkManager(viewer); return new ShareLinkManager(engineViewer(viewer));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+31 -7
View File
@@ -10,6 +10,30 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
import { createDefaultLayerState } from './data/layerState.js'; import { createDefaultLayerState } from './data/layerState.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
/**
* A globe engine standing in for the viewer double this test used to build:
* the camera pose comes from `camera.positionCartographic`/heading/pitch/roll,
* and `camera.flyTo`/`setView` and `scene.requestRender` replace the engine's.
*/
function engineViewer({ camera = {}, scene = {} } = {}) {
const engine = createFakeEngine();
const carto = camera.positionCartographic;
if (carto)
engine.fake.setPose({
longitude: carto.longitude,
latitude: carto.latitude,
height: carto.height,
heading: camera.heading ?? 0,
pitch: camera.pitch ?? -Math.PI / 2,
roll: camera.roll ?? 0,
});
if (camera.flyTo) engine.camera.flyTo = camera.flyTo;
if (camera.setView) engine.camera.setView = camera.setView;
if (scene.requestRender) engine.render.request = () => scene.requestRender();
return engine;
}
const uiSource = readShellSource(); const uiSource = readShellSource();
@@ -47,7 +71,7 @@ function makeManager(hash = '') {
roll: 0, roll: 0,
}, },
}; };
return new ShareLinkManager(viewer); return new ShareLinkManager(engineViewer(viewer));
} }
function installClipboard(writeText) { function installClipboard(writeText) {
@@ -424,7 +448,7 @@ test('share-link restore forces a final stationary render for Google 3D Tiles',
}, },
scene: { requestRender() { calls.renders += 1; } }, scene: { requestRender() { calls.renders += 1; } },
}; };
const manager = new ShareLinkManager(viewer); const manager = new ShareLinkManager(engineViewer(viewer));
manager.applyState({ manager.applyState({
lat: 40.7669, lat: 40.7669,
lon: -73.9909, lon: -73.9909,
@@ -453,7 +477,7 @@ test('newer navigation suppresses delayed share camera while non-camera state st
flyTo() { flights += 1; }, flyTo() { flights += 1; },
}, },
}; };
const manager = new ShareLinkManager(viewer, { const manager = new ShareLinkManager(engineViewer(viewer), {
onRestore: (state) => { restored = state; }, onRestore: (state) => { restored = state; },
isNavigationCurrent: () => false, isNavigationCurrent: () => false,
}); });
@@ -683,7 +707,7 @@ test('share apply completion waits for both callback work and camera settlement'
}, },
scene: { requestRender() {} }, scene: { requestRender() {} },
}; };
const manager = new ShareLinkManager(viewer, { onRestore: () => restoreGate }); const manager = new ShareLinkManager(engineViewer(viewer), { onRestore: () => restoreGate });
let settled = false; let settled = false;
const applying = manager.applyState({ const applying = manager.applyState({
lat: 40, lon: -74, alt: 500, heading: 0, pitch: -30, roll: 0, lat: 40, lon: -74, alt: 500, heading: 0, pitch: -30, roll: 0,
@@ -712,7 +736,7 @@ test('a later navigation prevents share completion from resetting the final pose
}, },
scene: { requestRender() {} }, scene: { requestRender() {} },
}; };
const manager = new ShareLinkManager(viewer, { const manager = new ShareLinkManager(engineViewer(viewer), {
isNavigationCurrent: (token) => token === generation, isNavigationCurrent: (token) => token === generation,
}); });
manager.applyState({ manager.applyState({
@@ -736,7 +760,7 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
}, },
scene: { requestRender() {} }, scene: { requestRender() {} },
}; };
const manager = new ShareLinkManager(viewer, { const manager = new ShareLinkManager(engineViewer(viewer), {
isNavigationCurrent: (token) => token === generation, isNavigationCurrent: (token) => token === generation,
cancelOwnedNavigation: () => { cancellations += 1; flight?.cancel?.(); }, cancelOwnedNavigation: () => { cancellations += 1; flight?.cancel?.(); },
}); });
@@ -748,7 +772,7 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
assert.equal(cancellations, 1); assert.equal(cancellations, 1);
assert.equal(setViews, 0); assert.equal(setViews, 0);
const newerManager = new ShareLinkManager(viewer, { const newerManager = new ShareLinkManager(engineViewer(viewer), {
isNavigationCurrent: (token) => token === generation, isNavigationCurrent: (token) => token === generation,
cancelOwnedNavigation: () => { cancellations += 1; }, cancelOwnedNavigation: () => { cancellations += 1; },
}); });
+22 -20
View File
@@ -1,5 +1,6 @@
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 { cesiumHeading, cesiumRoll } from './cameraAngles.js';
import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js'; import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js';
import { import {
migrateDetectionState, migrateDetectionState,
@@ -86,11 +87,15 @@ const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
}); });
export class ShareLinkManager { export class ShareLinkManager {
/**
* @param {import('./globe/contract.js').GlobeEngine | object} engine The
* globe engine, or a viewer registered for one.
*/
constructor( constructor(
viewer, engine,
{ onRestore, isNavigationCurrent, cancelOwnedNavigation } = {}, { onRestore, isNavigationCurrent, cancelOwnedNavigation } = {},
) { ) {
this.viewer = viewer; this.engine = engineFromViewer(engine);
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
this._debounceTimer = null; this._debounceTimer = null;
this._currentStyle = 'normal'; this._currentStyle = 'normal';
@@ -143,7 +148,7 @@ export class ShareLinkManager {
: null; : null;
// Listen for camera changes // Listen for camera changes
this._removeCameraChanged = this.viewer.camera.changed.addEventListener( this._removeCameraChanged = this.engine.camera.changed.addEventListener(
() => { () => {
this._scheduleUpdate(); this._scheduleUpdate();
}, },
@@ -258,7 +263,7 @@ export class ShareLinkManager {
} }
/** /**
* Apply a parsed state to the viewer + style manager. * Apply a parsed state to the camera + style manager.
*/ */
async applyState(state, { applyCamera = true, navigationToken = null } = {}) { async applyState(state, { applyCamera = true, navigationToken = null } = {}) {
if (this._destroyed || !state) if (this._destroyed || !state)
@@ -293,10 +298,10 @@ export class ShareLinkManager {
}; };
// Re-apply the final pose only while this share restoration still owns // Re-apply the final pose only while this share restoration still owns
// navigation. A later user or voice command wins over delayed restore. // navigation. A later user or voice command wins over delayed restore.
this.viewer.camera.flyTo({ this.engine.camera.flyTo({
...view, ...view,
duration: 3.0, duration: 3.0,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easing: 'cubicInOut',
complete: () => { complete: () => {
if ( if (
this._destroyed || this._destroyed ||
@@ -306,8 +311,8 @@ export class ShareLinkManager {
releaseOwnedFlight('superseded'); releaseOwnedFlight('superseded');
return; return;
} }
this.viewer.camera.setView(view); this.engine.camera.setView(view);
this.viewer.scene?.requestRender?.(); this.engine.render.request('share-restore');
releaseOwnedFlight('applied'); releaseOwnedFlight('applied');
}, },
cancel: () => releaseOwnedFlight('cancelled'), cancel: () => releaseOwnedFlight('cancelled'),
@@ -539,8 +544,8 @@ export class ShareLinkManager {
/** Build a deterministic snapshot without mutating history. */ /** Build a deterministic snapshot without mutating history. */
_buildHashParams() { _buildHashParams() {
if (this._destroyed) return null; if (this._destroyed) return null;
const camera = this.viewer.camera; const pose = this.engine.camera.pose();
const carto = camera.positionCartographic; const carto = pose.cartographic;
if (!carto) return null; if (!carto) return null;
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -548,15 +553,12 @@ export class ShareLinkManager {
params.set('lat', geo.math.toDegrees(carto.latitude).toFixed(4)); params.set('lat', geo.math.toDegrees(carto.latitude).toFixed(4));
params.set('lon', geo.math.toDegrees(carto.longitude).toFixed(4)); params.set('lon', geo.math.toDegrees(carto.longitude).toFixed(4));
params.set('alt', Math.round(carto.height).toString()); params.set('alt', Math.round(carto.height).toString());
params.set( // Heading and roll as Cesium reports them, so `heading=360` and
'heading', // `roll=360` read the same on every engine (src/cameraAngles.js).
Math.round(geo.math.toDegrees(camera.heading)).toString(), const degrees = (angle) => Math.round(geo.math.toDegrees(angle)).toString();
); params.set('heading', degrees(cesiumHeading(pose)));
params.set( params.set('pitch', degrees(pose.pitch));
'pitch', params.set('roll', degrees(cesiumRoll(pose)));
Math.round(geo.math.toDegrees(camera.pitch)).toString(),
);
params.set('roll', Math.round(geo.math.toDegrees(camera.roll)).toString());
params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal'); params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal');
params.set('bloom', this._bloomEnabled ? '1' : '0'); params.set('bloom', this._bloomEnabled ? '1' : '0');
params.set('sharpen', this._sharpenEnabled ? '1' : '0'); params.set('sharpen', this._sharpenEnabled ? '1' : '0');
+57
View File
@@ -0,0 +1,57 @@
// Share links read the camera through the globe engine and write heading and
// roll the way Cesium's camera reports them (in (0, 2π]), so a link made on
// any engine reads as one made on Cesium.
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 { ShareLinkManager } from './sharelink.js';
function linkFor(pose) {
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
const engine = createFakeEngine();
engine.fake.setPose(pose);
const manager = new ShareLinkManager(engine);
const params = new URLSearchParams(manager._buildHashParams().toString());
manager.destroy();
return Object.fromEntries(
['lat', 'lon', 'alt', 'heading', 'pitch', 'roll'].map((k) => [k, params.get(k)]),
);
}
const at = (heading, pitch, roll = 0) => ({
position: geo.cartesian3.fromDegrees(-73.9857, 40.7484, 1800),
heading: geo.math.toRadians(heading),
pitch: geo.math.toRadians(pitch),
roll: geo.math.toRadians(roll),
});
test('a level north-facing view writes heading and roll 360, as Cesium reports them', () => {
assert.deepEqual(linkFor(at(0, -30)), {
lat: '40.7484',
lon: '-73.9857',
alt: '1800',
heading: '360',
pitch: '-30',
roll: '360',
});
});
test('a straight-down view writes roll 0; other angles round as before', () => {
const down = linkFor(at(0, -90));
assert.equal(down.heading, '360');
assert.equal(down.roll, '0');
assert.equal(down.pitch, '-90');
const oblique = linkFor(at(45.4, -29.6, 2));
assert.deepEqual([oblique.heading, oblique.pitch, oblique.roll], ['45', '-30', '2']);
assert.equal(linkFor(at(359.7, -30)).heading, '360');
});
test('the camera changed event schedules a hash update and destroy unsubscribes', () => {
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
const engine = createFakeEngine();
const manager = new ShareLinkManager(engine);
assert.equal(engine.camera.changed.numberOfListeners, 1);
manager.destroy();
assert.equal(engine.camera.changed.numberOfListeners, 0);
});
+22 -12
View File
@@ -29,8 +29,9 @@ import {
setCyberSonarControls, setCyberSonarControls,
} from './cyberSonarControls.js'; } from './cyberSonarControls.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 { cesiumHeading, cesiumRoll } from '../cameraAngles.js';
import { aircraftTrackingTarget } from '../cockpitTracking.js'; import { aircraftTrackingTarget } from '../cockpitTracking.js';
@@ -62,12 +63,13 @@ export class StyleManager extends ShellFacade {
/** /**
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
* @param {object} [options] `engine`: the page's globe engine (the world * @param {object} [options] `engine`: the page's globe engine (the world
* overlay host runs on it). * overlay host, camera navigation, HUD and share links run on it; defaults
* to the one registered for the viewer).
*/ */
constructor( constructor(
viewer, viewer,
{ {
engine = null, engine = engineFromViewer(viewer),
mapStackController = null, mapStackController = null,
placeSearch, placeSearch,
services, services,
@@ -129,11 +131,13 @@ export class StyleManager extends ShellFacade {
readLayers: () => this._dataManager?.getAll?.() || [], readLayers: () => this._dataManager?.getAll?.() || [],
}); });
this.viewer = viewer; this.viewer = viewer;
this.engine = engine;
this.mapStackController = mapStackController; this.mapStackController = mapStackController;
this.placeSearch = placeSearch; this.placeSearch = placeSearch;
this._navigation = new NavigationController({ this._navigation = new NavigationController({
viewer, viewer,
engine,
tracking: { tracking: {
flightsLayer, flightsLayer,
militaryFlightsLayer, militaryFlightsLayer,
@@ -154,6 +158,7 @@ export class StyleManager extends ShellFacade {
}); });
this._shareRestoration = new ShareRestoration({ this._shareRestoration = new ShareRestoration({
viewer, viewer,
canvas: engine?.host.canvas ?? viewer?.canvas,
navigation: this._navigation, navigation: this._navigation,
syncShareState: () => this._syncShareState(), syncShareState: () => this._syncShareState(),
syncModels3d: (state) => this._syncModels3dFromLayerState(state), syncModels3d: (state) => this._syncModels3dFromLayerState(state),
@@ -302,6 +307,7 @@ export class StyleManager extends ShellFacade {
this._locationNavigation = new LocationNavigation({ this._locationNavigation = new LocationNavigation({
viewer, viewer,
engine,
placeSearch, placeSearch,
navigation: this._navigation, navigation: this._navigation,
readCockpit: () => this.cockpitView, readCockpit: () => this.cockpitView,
@@ -354,7 +360,8 @@ export class StyleManager extends ShellFacade {
this._lastTrafficChipUpdateAt = 0; this._lastTrafficChipUpdateAt = 0;
// Intel HUD // Intel HUD
this.hud = new IntelHUD(viewer, { this.hud = new IntelHUD(engine, {
viewer,
placeSearch, placeSearch,
summaryService: requestServices?.summary, summaryService: requestServices?.summary,
}); });
@@ -417,13 +424,13 @@ export class StyleManager extends ShellFacade {
}); });
// Share Link Manager // Share Link Manager
this.shareLinkManager = new ShareLinkManager(viewer, { this.shareLinkManager = new ShareLinkManager(engine, {
onRestore: async (state) => { onRestore: async (state) => {
return this._visualSettings.restoreShareState(state); return this._visualSettings.restoreShareState(state);
}, },
isNavigationCurrent: (generation) => isNavigationCurrent: (generation) =>
generation === this._navigationGeneration, generation === this._navigationGeneration,
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), cancelOwnedNavigation: () => this.engine.camera.cancelFlight(),
}); });
this.shareLinkManager.setPanelStateProvider(() => this.shareLinkManager.setPanelStateProvider(() =>
this._buildSharePanelState(), this._buildSharePanelState(),
@@ -1322,15 +1329,17 @@ export class StyleManager extends ShellFacade {
* @returns {{lat: number, lon: number, alt: number, heading: number, pitch: number, roll: number}|null} * @returns {{lat: number, lon: number, alt: number, heading: number, pitch: number, roll: number}|null}
*/ */
getCameraState() { getCameraState() {
const carto = this.viewer.camera.positionCartographic; const pose = this.engine?.camera.pose();
const carto = pose?.cartographic;
if (!carto) return null; if (!carto) return null;
// Heading and roll as Cesium reports them, as captured shots always held.
return { return {
lat: geo.math.toDegrees(carto.latitude), lat: geo.math.toDegrees(carto.latitude),
lon: geo.math.toDegrees(carto.longitude), lon: geo.math.toDegrees(carto.longitude),
alt: carto.height, alt: carto.height,
heading: geo.math.toDegrees(this.viewer.camera.heading), heading: geo.math.toDegrees(cesiumHeading(pose)),
pitch: geo.math.toDegrees(this.viewer.camera.pitch), pitch: geo.math.toDegrees(pose.pitch),
roll: geo.math.toDegrees(this.viewer.camera.roll), roll: geo.math.toDegrees(cesiumRoll(pose)),
}; };
} }
@@ -1342,7 +1351,7 @@ export class StyleManager extends ShellFacade {
*/ */
applyCameraState(cameraState, duration = 2.8) { applyCameraState(cameraState, duration = 2.8) {
if (!cameraState) return; if (!cameraState) return;
this.viewer.camera.flyTo({ this.engine.camera.flyTo({
destination: geo.cartesian3.fromDegrees( destination: geo.cartesian3.fromDegrees(
cameraState.lon, cameraState.lon,
cameraState.lat, cameraState.lat,
@@ -1354,7 +1363,7 @@ export class StyleManager extends ShellFacade {
roll: geo.math.toRadians(cameraState.roll || 0), roll: geo.math.toRadians(cameraState.roll || 0),
}, },
duration: Math.max(0.2, duration || 0), duration: Math.max(0.2, duration || 0),
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easing: 'cubicInOut',
}); });
} }
@@ -1466,6 +1475,7 @@ export class StyleManager extends ShellFacade {
this._cameraOrientationControls?.destroy(); this._cameraOrientationControls?.destroy();
this._cameraOrientationControls = bindCameraOrientationControls({ this._cameraOrientationControls = bindCameraOrientationControls({
viewer: this.viewer, viewer: this.viewer,
engine: this.engine,
elements: { elements: {
tiltButton: this._tiltMapBtn, tiltButton: this._tiltMapBtn,
northButton: this._northUpBtn, northButton: this._northUpBtn,
+148 -105
View File
@@ -1,6 +1,21 @@
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { isPickedWorldPosition } from '../data/scenePick.js'; import { isPickedWorldPosition } from '../data/scenePick.js';
import { isGlobeEngine } from '../globe/contract.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
// The camera is the globe engine's. Entity tracking stays on the Cesium viewer
// until phase 1c, so the viewer, when there is one, is only read for its
// tracked entity: callers pass an engine and that viewer, or a viewer whose
// engine is registered (engineFromViewer).
const engineOf = (target) => engineFromViewer(target);
const trackerOf = (target, tracker) =>
tracker ?? (target && !isGlobeEngine(target) ? target : null);
// Cesium's EasingFunction.CUBIC_IN_OUT (tween.js Cubic.InOut).
function cubicInOut(amount) {
if ((amount *= 2) < 1) return 0.5 * amount * amount * amount;
return 0.5 * ((amount -= 2) * amount * amount + 2);
}
function trackedTarget(viewer) { function trackedTarget(viewer) {
const entity = viewer.trackedEntity; const entity = viewer.trackedEntity;
@@ -21,69 +36,66 @@ function normalizedHeading(heading) {
return Math.abs(wrapped - geo.math.TWO_PI) < geo.math.EPSILON10 ? 0 : wrapped; return Math.abs(wrapped - geo.math.TWO_PI) < geo.math.EPSILON10 ? 0 : wrapped;
} }
/** Return the world position under the center of the canvas, when available. */ /**
export function pickViewTarget(viewer) { * Return the world position under the center of the canvas, when available.
const scene = viewer?.scene; * @param {import('../globe/contract.js').GlobeEngine | object} target The
const camera = viewer?.camera; * globe engine, or a viewer registered for one.
const canvas = scene?.canvas; */
if (!scene || !camera || !canvas) return null; export function pickViewTarget(target) {
const width = canvas.clientWidth || canvas.width || 0; const engine = engineOf(target);
const height = canvas.clientHeight || canvas.height || 0; if (!engine) return null;
const { width, height } = engine.host.size();
if (!width || !height) return null; if (!width || !height) return null;
const center = new Cesium.Cartesian2(width / 2, height / 2); const center = { x: width / 2, y: height / 2 };
let target = null; const { picker, camera } = engine;
// Depth first, then terrain before the ellipsoid: the globe pick
if (scene.pickPositionSupported && typeof scene.pickPosition === 'function') { // intersects the rendered surface, so over keyless elevated terrain it
// returns the ground the operator is looking at. pickEllipsoid answers with
// sea level, which on a plateau sits far below the view and would swing the
// camera on every tilt.
const stages = [
() =>
picker.pickPositionSupported ? picker.pickPosition(center) : undefined,
() => picker.pickGlobe(center),
() => camera.pickEllipsoid(center),
];
for (const stage of stages) {
let picked = null;
try { try {
target = scene.pickPosition(center); picked = stage();
} catch { } catch {
target = null; picked = null;
} }
if (isPickedWorldPosition(picked)) return picked;
} }
// Terrain before the ellipsoid: globe.pick intersects the rendered surface, return null;
// so over keyless elevated terrain it returns the ground the operator is
// looking at. pickEllipsoid answers with sea level, which on a plateau sits
// far below the view and would swing the camera on every tilt.
if (
!isPickedWorldPosition(target) &&
typeof camera.getPickRay === 'function'
) {
try {
target = scene.globe?.pick(camera.getPickRay(center), scene) || null;
} catch {
target = null;
}
}
if (
!isPickedWorldPosition(target) &&
typeof camera.pickEllipsoid === 'function'
) {
try {
target = camera.pickEllipsoid(center, Cesium.Ellipsoid.WGS84);
} catch {
target = null;
}
}
return isPickedWorldPosition(target) ? target : null;
} }
/** Describe the camera as an orbit around the current viewport center. */ /**
export function readCameraTargetFrame(viewer) { * Describe the camera as an orbit around the current viewport center.
const camera = viewer?.camera; * @param {import('../globe/contract.js').GlobeEngine | object} target The
const target = viewer?.trackedEntity * globe engine, or a viewer registered for one.
* @param {object} [tracker] The viewer whose tracked entity is the orbit
* target (defaults to `target` when that is a viewer).
*/
export function readCameraTargetFrame(target, tracker) {
const engine = engineOf(target);
if (!engine) return null;
const viewer = trackerOf(target, tracker);
const center = viewer?.trackedEntity
? trackedTarget(viewer) ? trackedTarget(viewer)
: pickViewTarget(viewer); : pickViewTarget(engine);
if (!camera || !target || !isPickedWorldPosition(camera.positionWC)) const pose = engine.camera.pose();
return null; if (!center || !isPickedWorldPosition(pose.position)) return null;
const transform = geo.transforms.eastNorthUpToFixedFrame(target); const transform = geo.transforms.eastNorthUpToFixedFrame(center);
const inverse = geo.matrix4.inverseTransformation( const inverse = geo.matrix4.inverseTransformation(
transform, transform,
geo.matrix4.create(), geo.matrix4.create(),
); );
const localOffset = geo.matrix4.multiplyByPoint( const localOffset = geo.matrix4.multiplyByPoint(
inverse, inverse,
camera.positionWC, pose.position,
geo.cartesian3.create(), geo.cartesian3.create(),
); );
const range = geo.cartesian3.magnitude(localOffset); const range = geo.cartesian3.magnitude(localOffset);
@@ -97,42 +109,43 @@ export function readCameraTargetFrame(viewer) {
// make north-up rotate the wrong way from three quarters of the compass. // make north-up rotate the wrong way from three quarters of the compass.
const targetHeading = Math.atan2(-localOffset.x, -localOffset.y); const targetHeading = Math.atan2(-localOffset.x, -localOffset.y);
const heading = normalizedHeading( const heading = normalizedHeading(
pitch < geo.math.toRadians(-88.5) ? camera.heading : targetHeading, pitch < geo.math.toRadians(-88.5) ? pose.heading : targetHeading,
); );
return { target, range, heading, pitch }; return { target: center, range, heading, pitch };
} }
/** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */ /**
export function setCameraTargetFrame(viewer, frame) { * Apply an orbit frame, then return the camera to the fixed-world frame.
const camera = viewer?.camera; * @param {import('../globe/contract.js').GlobeEngine | object} target The
if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false; * globe engine, or a viewer registered for one.
* @param {object} frame From {@link readCameraTargetFrame}.
* @param {object} [tracker] As in readCameraTargetFrame.
*/
export function setCameraTargetFrame(target, frame, tracker) {
const engine = engineOf(target);
if (!engine || !frame?.target || !Number.isFinite(frame.range)) return false;
const viewer = trackerOf(target, tracker);
const { camera } = engine;
try { try {
const trackedTransform = viewer.trackedEntity const trackedTransform = viewer?.trackedEntity ? camera.transform() : null;
? geo.matrix4.clone(camera.transform) camera.lookAt(frame.target, {
: null; heading: normalizedHeading(frame.heading),
camera.lookAt( pitch: frame.pitch,
frame.target, range: frame.range,
new Cesium.HeadingPitchRange( });
normalizedHeading(frame.heading),
frame.pitch,
frame.range,
),
);
if (trackedTransform) { if (trackedTransform) {
// Preserve EntityView's reference frame (including satellite frames). // Preserve EntityView's reference frame (including satellite frames).
// Its next update carries this new local offset along with the target. // Its next update carries this new local offset along with the target.
camera.lookAtTransform(trackedTransform); camera.lookAtTransform(trackedTransform);
viewer.scene?.requestRender?.(); engine.render.request('camera-orientation');
return true; return true;
} }
const destination = geo.cartesian3.clone(camera.positionWC); const { position: destination, direction, up } = camera.pose();
const direction = geo.cartesian3.clone(camera.directionWC); camera.release();
const up = geo.cartesian3.clone(camera.upWC);
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
if (destination && direction && up) { if (destination && direction && up) {
camera.setView({ destination, orientation: { direction, up } }); camera.setView({ destination, orientation: { direction, up } });
} }
viewer.scene?.requestRender?.(); engine.render.request('camera-orientation');
return true; return true;
} catch { } catch {
return false; return false;
@@ -158,32 +171,40 @@ export function frameIsTilted(frame) {
} }
/** Toggle between a straight-down map and a useful oblique map angle. */ /** Toggle between a straight-down map and a useful oblique map angle. */
export function toggleCameraTilt(viewer) { export function toggleCameraTilt(target, tracker) {
const frame = readCameraTargetFrame(viewer); const frame = readCameraTargetFrame(target, tracker);
if (!frame) return false; if (!frame) return false;
const tilted = frameIsTilted(frame); const tilted = frameIsTilted(frame);
const pitch = tilted ? STRAIGHT_DOWN_PITCH : OBLIQUE_PITCH; const pitch = tilted ? STRAIGHT_DOWN_PITCH : OBLIQUE_PITCH;
return setCameraTargetFrame(viewer, { ...frame, pitch }) return setCameraTargetFrame(target, { ...frame, pitch }, tracker)
? { tilted: !tilted, pitch } ? { tilted: !tilted, pitch }
: false; : false;
} }
/** Rotate around the viewport center until north is at the top. */ /** Rotate around the viewport center until north is at the top. */
export function resetCameraNorth(viewer) { export function resetCameraNorth(target, tracker) {
const frame = readCameraTargetFrame(viewer); const frame = readCameraTargetFrame(target, tracker);
if (!frame) return false; if (!frame) return false;
return setCameraTargetFrame(viewer, { ...frame, heading: 0 }); return setCameraTargetFrame(target, { ...frame, heading: 0 }, tracker);
} }
function headingDegrees(camera) { function headingDegrees(engine) {
return geo.math.toDegrees(normalizedHeading(camera?.heading)); return geo.math.toDegrees(normalizedHeading(engine?.camera.pose().heading));
} }
/** Ease an orbit without handing off the entity that owns the follow camera. */ /**
* Ease an orbit without handing off the entity that owns the follow camera.
* @param {import('../globe/contract.js').GlobeEngine | object} target The
* globe engine, or a viewer registered for one.
* @param {{ now?: () => number, duration?: number, tracker?: object }} [options]
* `tracker` as in readCameraTargetFrame.
*/
export function createCameraOrientationAnimator( export function createCameraOrientationAnimator(
viewer, target,
{ now = () => performance.now(), duration = 650 } = {}, { now = () => performance.now(), duration = 650, tracker } = {},
) { ) {
const engine = engineOf(target);
const viewer = trackerOf(target, tracker);
let remove = null; let remove = null;
let pending = null; let pending = null;
const cancel = () => { const cancel = () => {
@@ -193,37 +214,42 @@ export function createCameraOrientationAnimator(
}; };
function animate(frame, destination) { function animate(frame, destination) {
cancel(); cancel();
if (!viewer.scene?.preUpdate?.addEventListener || duration <= 0) if (!engine) return false;
return setCameraTargetFrame(viewer, { ...frame, ...destination }); if (duration <= 0)
const entity = viewer.trackedEntity; return setCameraTargetFrame(engine, { ...frame, ...destination }, viewer);
const entity = viewer?.trackedEntity;
pending = destination; pending = destination;
const start = now(); const start = now();
const headingDelta = geo.math.negativePiToPi( const headingDelta = geo.math.negativePiToPi(
destination.heading - frame.heading, destination.heading - frame.heading,
); );
remove = viewer.scene.preUpdate.addEventListener(() => { remove = engine.frames.preUpdate.addEventListener(() => {
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { if (engine.isDestroyed() || viewer?.trackedEntity !== entity) {
cancel(); cancel();
return; return;
} }
const progress = geo.math.clamp((now() - start) / duration, 0, 1); const progress = geo.math.clamp((now() - start) / duration, 0, 1);
const eased = Cesium.EasingFunction.CUBIC_IN_OUT(progress); const eased = cubicInOut(progress);
const target = entity ? trackedTarget(viewer) : frame.target; const center = entity ? trackedTarget(viewer) : frame.target;
if ( if (
!isPickedWorldPosition(target) || !isPickedWorldPosition(center) ||
!setCameraTargetFrame(viewer, { !setCameraTargetFrame(
engine,
{
...frame, ...frame,
target, target: center,
heading: frame.heading + headingDelta * eased, heading: frame.heading + headingDelta * eased,
pitch: geo.math.lerp(frame.pitch, destination.pitch, eased), pitch: geo.math.lerp(frame.pitch, destination.pitch, eased),
}) },
viewer,
)
) { ) {
cancel(); cancel();
return; return;
} }
if (progress === 1) cancel(); if (progress === 1) cancel();
}); });
viewer.scene.requestRender?.(); engine.render.request('camera-orientation');
return true; return true;
} }
return { return {
@@ -235,12 +261,25 @@ export function createCameraOrientationAnimator(
}; };
} }
/** Bind the two map-orientation actions and keep their accessible state current. */ /**
* Bind the two map-orientation actions and keep their accessible state current.
* @param {{
* engine?: import('../globe/contract.js').GlobeEngine,
* viewer?: object,
* elements: object,
* runNavigation: Function,
* showToast?: Function,
* animationMs?: number,
* }} options `engine` defaults to the one registered for `viewer`; the viewer
* is otherwise only read for its tracked entity (phase 1c).
*/
export function bindCameraOrientationControls({ export function bindCameraOrientationControls({
viewer, viewer = null,
engine = engineOf(viewer),
elements, elements,
runNavigation, runNavigation,
showToast, showToast,
animationMs,
}) { }) {
const tiltButton = elements?.tiltButton; const tiltButton = elements?.tiltButton;
const northButton = elements?.northButton; const northButton = elements?.northButton;
@@ -251,12 +290,16 @@ export function bindCameraOrientationControls({
// of the screen: 4 ms median and up to 12 ms, measured on this machine. It is // of the screen: 4 ms median and up to 12 ms, measured on this machine. It is
// therefore held between refreshes rather than recomputed per frame. // therefore held between refreshes rather than recomputed per frame.
let tilted = false; let tilted = false;
const animator = createCameraOrientationAnimator(viewer); const animator = createCameraOrientationAnimator(engine, {
tracker: viewer,
...(animationMs === undefined ? {} : { duration: animationMs }),
});
const readFrame = () => readCameraTargetFrame(engine, viewer);
/** Recompute the tilt state exactly, paying for one pick. */ /** Recompute the tilt state exactly, paying for one pick. */
const refreshTilt = () => { const refreshTilt = () => {
if (destroyed) return; if (destroyed) return;
tilted = frameIsTilted(readCameraTargetFrame(viewer)); tilted = frameIsTilted(readFrame());
sync(); sync();
}; };
@@ -272,7 +315,7 @@ export function bindCameraOrientationControls({
if (destroyed) return; if (destroyed) return;
const next = { const next = {
tilted, tilted,
heading: Math.round(headingDegrees(viewer?.camera)) % 360, heading: Math.round(headingDegrees(engine)) % 360,
}; };
if ( if (
applied && applied &&
@@ -308,7 +351,7 @@ export function bindCameraOrientationControls({
listen(tiltButton, () => { listen(tiltButton, () => {
const pending = animator.destination; const pending = animator.destination;
const result = runNavigation('camera', () => { const result = runNavigation('camera', () => {
const frame = readCameraTargetFrame(viewer); const frame = readFrame();
if (!frame) return false; if (!frame) return false;
const nextTilted = !frameIsTilted(pending || frame); const nextTilted = !frameIsTilted(pending || frame);
const pitch = nextTilted ? OBLIQUE_PITCH : STRAIGHT_DOWN_PITCH; const pitch = nextTilted ? OBLIQUE_PITCH : STRAIGHT_DOWN_PITCH;
@@ -330,7 +373,7 @@ export function bindCameraOrientationControls({
listen(northButton, () => { listen(northButton, () => {
const pending = animator.destination; const pending = animator.destination;
const result = runNavigation('camera', () => { const result = runNavigation('camera', () => {
const frame = readCameraTargetFrame(viewer); const frame = readFrame();
return ( return (
frame && frame &&
animator.animate(frame, { animator.animate(frame, {
@@ -350,13 +393,13 @@ export function bindCameraOrientationControls({
// The needle follows every frame the scene draws. `camera.changed` is NOT // The needle follows every frame the scene draws. `camera.changed` is NOT
// enough: it only fires past `camera.percentageChanged`, so a rotation of a // enough: it only fires past `camera.percentageChanged`, so a rotation of a
// few degrees leaves the needle pointing at the old bearing. // few degrees leaves the needle pointing at the old bearing.
subscribe(viewer?.scene?.preRender, sync); subscribe(engine?.frames.preRender, sync);
// The exact tilt state is refreshed once per gesture, not once per frame — // The exact tilt state is refreshed once per gesture, not once per frame —
// moveEnd fires when the camera stops, which is where the pick is affordable. // moveEnd fires when the camera stops, which is where the pick is affordable.
subscribe(viewer?.camera?.moveEnd, refreshTilt); subscribe(engine?.camera.moveEnd, refreshTilt);
subscribe(viewer?.trackedEntityChanged, animator.cancel); subscribe(viewer?.trackedEntityChanged, animator.cancel);
for (const type of ['pointerdown', 'wheel']) { for (const type of ['pointerdown', 'wheel']) {
const canvas = viewer?.scene?.canvas; const canvas = engine?.host.canvas;
canvas?.addEventListener?.(type, animator.cancel, { passive: true }); canvas?.addEventListener?.(type, animator.cancel, { passive: true });
removers.push(() => canvas?.removeEventListener?.(type, animator.cancel)); removers.push(() => canvas?.removeEventListener?.(type, animator.cancel));
} }
+85 -12
View File
@@ -12,6 +12,24 @@ import {
toggleCameraTilt, toggleCameraTilt,
} from './cameraOrientationControls.js'; } from './cameraOrientationControls.js';
import { NavigationController } from './navigationController.js'; import { NavigationController } from './navigationController.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js';
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
// The controls run on the globe engine; these viewer doubles get the Cesium
// engine registered for them, as the app's scene engine is.
function withEngine(viewer) {
registerViewerEngine(viewer, createCesiumEngine(viewer));
return viewer;
}
/** Same Cartesian values (the engine hands out copies, never Cesium's own). */
const sameXYZ = (actual, expected, message) =>
assert.deepEqual(
[actual?.x, actual?.y, actual?.z],
[expected.x, expected.y, expected.z],
message,
);
function createViewer({ cameraPosition, target, pickPosition = null } = {}) { function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
const calls = []; const calls = [];
@@ -23,7 +41,12 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
directionWC: new Cesium.Cartesian3(-1, 0, 0), directionWC: new Cesium.Cartesian3(-1, 0, 0),
upWC: new Cesium.Cartesian3(0, 0, 1), upWC: new Cesium.Cartesian3(0, 0, 1),
heading: Cesium.Math.toRadians(90), heading: Cesium.Math.toRadians(90),
pitch: Cesium.Math.toRadians(-90),
roll: 0,
frustum: { fovy: 1, aspectRatio: 1.5 },
changed, changed,
moveStart: new Cesium.Event(),
moveEnd: new Cesium.Event(),
pickEllipsoid: () => surface, pickEllipsoid: () => surface,
getPickRay: () => ({}), getPickRay: () => ({}),
lookAt(targetValue, offset) { lookAt(targetValue, offset) {
@@ -44,9 +67,12 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
pickPosition: pickPosition || (() => null), pickPosition: pickPosition || (() => null),
globe: { pick: () => surface }, globe: { pick: () => surface },
requestRender: () => calls.push({ type: 'requestRender' }), requestRender: () => calls.push({ type: 'requestRender' }),
preUpdate: new Cesium.Event(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
}, },
}; };
return { viewer, calls, target: surface }; return { viewer: withEngine(viewer), calls, target: surface };
} }
class FakeButton extends EventTarget { class FakeButton extends EventTarget {
@@ -104,8 +130,10 @@ function createRealViewer(target) {
// The binding drives the needle from the scene's per-frame signal, not from // The binding drives the needle from the scene's per-frame signal, not from
// `camera.changed`, so the fixture has to carry one. // `camera.changed`, so the fixture has to carry one.
scene.preRender = new Cesium.Event(); scene.preRender = new Cesium.Event();
scene.preUpdate = new Cesium.Event();
scene.postRender = new Cesium.Event();
camera.getPickRay = () => ({}); camera.getPickRay = () => ({});
return { viewer: { camera, scene }, target }; return { viewer: withEngine({ camera, scene }), target };
} }
/** Place a real camera in an orbit around `target` and release the frame. */ /** Place a real camera in an orbit around `target` and release the frame. */
@@ -282,6 +310,8 @@ test('the button shows the state the next click will produce, at every range', (
viewer, viewer,
elements: { tiltButton, northButton: new FakeButton() }, elements: { tiltButton, northButton: new FakeButton() },
runNavigation: (_noun, navigate) => navigate(), runNavigation: (_noun, navigate) => navigate(),
// No frame loop drives this fixture: apply each change at once.
animationMs: 0,
}); });
assert.equal( assert.equal(
@@ -425,7 +455,7 @@ test('a sky-facing camera yields no frame and both actions decline', () => {
scene.requestRender = () => {}; scene.requestRender = () => {};
camera.getPickRay = () => ({}); camera.getPickRay = () => ({});
camera.pickEllipsoid = () => undefined; camera.pickEllipsoid = () => undefined;
const viewer = { camera, scene }; const viewer = withEngine({ camera, scene });
assert.equal(readCameraTargetFrame(viewer), null); assert.equal(readCameraTargetFrame(viewer), null);
assert.equal(toggleCameraTilt(viewer), false); assert.equal(toggleCameraTilt(viewer), false);
@@ -439,14 +469,14 @@ test('center picking prefers terrain over the ellipsoid and falls through an inv
const { viewer } = createViewer({ pickPosition: () => invalid }); const { viewer } = createViewer({ pickPosition: () => invalid });
viewer.scene.globe.pick = () => terrain; viewer.scene.globe.pick = () => terrain;
viewer.camera.pickEllipsoid = () => sea; viewer.camera.pickEllipsoid = () => sea;
assert.equal( sameXYZ(
pickViewTarget(viewer), pickViewTarget(viewer),
terrain, terrain,
'the rendered ground wins over sea level', 'the rendered ground wins over sea level',
); );
viewer.scene.globe.pick = () => null; viewer.scene.globe.pick = () => null;
assert.equal( sameXYZ(
pickViewTarget(viewer), pickViewTarget(viewer),
sea, sea,
'the ellipsoid still answers when nothing is under the cursor', 'the ellipsoid still answers when nothing is under the cursor',
@@ -468,7 +498,7 @@ test('tilt preserves the picked target and range while choosing oblique pitch',
const result = toggleCameraTilt(viewer); const result = toggleCameraTilt(viewer);
const lookAt = calls.find((call) => call.type === 'lookAt'); const lookAt = calls.find((call) => call.type === 'lookAt');
assert.deepEqual(result, { tilted: true, pitch: OBLIQUE_PITCH }); assert.deepEqual(result, { tilted: true, pitch: OBLIQUE_PITCH });
assert.equal(lookAt.target, target); sameXYZ(lookAt.target, target);
assert.equal(lookAt.offset.range, before.range); assert.equal(lookAt.offset.range, before.range);
assert.equal(lookAt.offset.pitch, OBLIQUE_PITCH); assert.equal(lookAt.offset.pitch, OBLIQUE_PITCH);
assert.ok(calls.some((call) => call.type === 'lookAtTransform')); assert.ok(calls.some((call) => call.type === 'lookAtTransform'));
@@ -533,7 +563,6 @@ for (const [label, altitude, range] of [
); );
let target = Cesium.Cartesian3.fromDegrees(-97, 30, altitude); let target = Cesium.Cartesian3.fromDegrees(-97, 30, altitude);
const { viewer } = createRealViewer(target); const { viewer } = createRealViewer(target);
viewer.scene.preUpdate = new Cesium.Event();
viewer.clock = { currentTime: Cesium.JulianDate.now() }; viewer.clock = { currentTime: Cesium.JulianDate.now() };
const entity = new Cesium.Entity({ const entity = new Cesium.Entity({
position: new Cesium.CallbackPositionProperty(() => target, false), position: new Cesium.CallbackPositionProperty(() => target, false),
@@ -597,7 +626,6 @@ for (const [label, altitude, range] of [
test('orientation animation cancels on replacement and explicit cancellation', () => { test('orientation animation cancels on replacement and explicit cancellation', () => {
const { viewer } = createRealViewer(Cesium.Cartesian3.fromDegrees(0, 0)); const { viewer } = createRealViewer(Cesium.Cartesian3.fromDegrees(0, 0));
orbit(viewer, Cesium.Cartesian3.fromDegrees(0, 0), 90, OBLIQUE_PITCH, 1000); orbit(viewer, Cesium.Cartesian3.fromDegrees(0, 0), 90, OBLIQUE_PITCH, 1000);
viewer.scene.preUpdate = new Cesium.Event();
const animator = createCameraOrientationAnimator(viewer); const animator = createCameraOrientationAnimator(viewer);
const frame = readCameraTargetFrame(viewer); const frame = readCameraTargetFrame(viewer);
animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH }); animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH });
@@ -615,13 +643,17 @@ test('orientation animation cancels on replacement and explicit cancellation', (
test('orientation navigation preserves follow and selection; new navigation cancels animation', () => { test('orientation navigation preserves follow and selection; new navigation cancels animation', () => {
const calls = []; const calls = [];
const entity = {}; const entity = {};
const viewer = { const viewer = { trackedEntity: entity };
trackedEntity: entity, const engine = createFakeEngine();
camera: { cancelFlight: () => calls.push('cancel-flight') }, const cancelFlight = engine.camera.cancelFlight;
engine.camera.cancelFlight = () => {
calls.push('cancel-flight');
cancelFlight();
}; };
let cockpit = false; let cockpit = false;
const navigation = new NavigationController({ const navigation = new NavigationController({
viewer, viewer,
engine,
tracking: {}, tracking: {},
isCockpitActive: () => cockpit, isCockpitActive: () => cockpit,
cancelOrientation: () => calls.push('cancel-orientation'), cancelOrientation: () => calls.push('cancel-orientation'),
@@ -662,7 +694,6 @@ test('rapid tilt presses reverse direction and north-up keeps the requested tilt
const target = Cesium.Cartesian3.fromDegrees(0, 0); const target = Cesium.Cartesian3.fromDegrees(0, 0);
const { viewer } = createRealViewer(target); const { viewer } = createRealViewer(target);
orbit(viewer, target, 90, OBLIQUE_PITCH, 1000); orbit(viewer, target, 90, OBLIQUE_PITCH, 1000);
viewer.scene.preUpdate = new Cesium.Event();
const tiltButton = new FakeButton(); const tiltButton = new FakeButton();
const northButton = new FakeButton(); const northButton = new FakeButton();
let controls; let controls;
@@ -688,3 +719,45 @@ test('rapid tilt presses reverse direction and north-up keeps the requested tilt
controls.destroy(); controls.destroy();
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
}); });
test('the controls run on a globe engine alone', (t) => {
let time = 0;
t.mock.method(performance, 'now', () => time);
const engine = createFakeEngine();
engine.fake.setPose({
position: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 3000),
heading: Cesium.Math.toRadians(70),
pitch: STRAIGHT_DOWN_PITCH,
roll: 0,
});
const tiltButton = new FakeButton();
const northButton = new FakeButton();
const controls = bindCameraOrientationControls({
engine,
elements: { tiltButton, northButton },
runNavigation: (_noun, navigate) => navigate(),
});
assert.equal(tiltButton.getAttribute('aria-pressed'), 'false');
assert.equal(northButton.getAttribute('--camera-heading'), '70deg');
tiltButton.click();
assert.equal(tiltButton.getAttribute('aria-pressed'), 'true');
// The animation runs on the engine's preUpdate frames.
time = 700;
engine.fake.frame(16);
const tilted = readCameraTargetFrame(engine);
assert.ok(Math.abs(tilted.pitch - OBLIQUE_PITCH) < 1e-6);
northButton.click();
time = 1400;
engine.fake.frame(16);
const north = readCameraTargetFrame(engine);
assert.ok(
Math.abs(north.heading) < 1e-6 ||
Math.abs(north.heading - 2 * Math.PI) < 1e-6,
);
assert.equal(northButton.getAttribute('--camera-heading'), '0deg');
assert.ok(engine.fake.renderRequests > 0);
controls.destroy();
assert.equal(engine.frames.preRender.numberOfListeners, 0);
assert.equal(engine.frames.preUpdate.numberOfListeners, 0);
assert.equal(engine.camera.moveEnd.numberOfListeners, 0);
});
+20 -15
View File
@@ -1,12 +1,17 @@
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js'; import * as geo from '../geo/index.js';
import { createStateChannel } from '../app/stateChannel.js'; import { createStateChannel } from '../app/stateChannel.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { LocationControls } from './location.js'; import { LocationControls } from './location.js';
/** Own destination selection, lookup, orbit and world-jump lifetime. */ /**
* Own destination selection, lookup, orbit and world-jump lifetime. The camera
* is the globe `engine`'s; `viewer` is optional and only clears Cesium's
* tracked entity (entity tracking moves to the engine in phase 1c).
*/
export class LocationNavigation { export class LocationNavigation {
constructor({ constructor({
viewer, viewer = null,
engine = engineFromViewer(viewer),
placeSearch, placeSearch,
services, services,
elements, elements,
@@ -16,7 +21,7 @@ export class LocationNavigation {
}) { }) {
Object.assign( Object.assign(
this, this,
{ viewer, placeSearch, services, navigation, readCockpit }, { viewer, engine, placeSearch, services, navigation, readCockpit },
elements, elements,
operations, operations,
); );
@@ -31,7 +36,7 @@ export class LocationNavigation {
this._globeResetPromise = null; this._globeResetPromise = null;
this._cancelGlobeReset = null; this._cancelGlobeReset = null;
this._worldJumpActive = false; this._worldJumpActive = false;
this.orbitController = new services.OrbitController(viewer); this.orbitController = new services.OrbitController(engine);
this._orbitIndicator = null; this._orbitIndicator = null;
this._locationState = createStateChannel( this._locationState = createStateChannel(
() => this._locationLookup?.getState() || null, () => this._locationLookup?.getState() || null,
@@ -112,7 +117,7 @@ export class LocationNavigation {
!this._disposed && generation === this._navigationGeneration, !this._disposed && generation === this._navigationGeneration,
beforeFly: (generation) => this._reassertNavigationHandoff(generation), beforeFly: (generation) => this._reassertNavigationHandoff(generation),
search: (query, options) => search: (query, options) =>
searchAndFlyTo(this.viewer, query, { searchAndFlyTo(this.engine, query, {
placeSearch: this.placeSearch, placeSearch: this.placeSearch,
...options, ...options,
}), }),
@@ -194,7 +199,7 @@ export class LocationNavigation {
const isCityChanged = const isCityChanged =
this._activeLocationId && this._activeLocationId !== cityId; this._activeLocationId && this._activeLocationId !== cityId;
const result = this._flyWithTransition(!!isCityChanged, (hooks) => const result = this._flyWithTransition(!!isCityChanged, (hooks) =>
flyToPresetLocation(this.viewer, cityId, hooks), flyToPresetLocation(this.engine, cityId, hooks),
); );
if (result === false) return; if (result === false) return;
this._expandPOIRow(cityId); this._expandPOIRow(cityId);
@@ -215,7 +220,7 @@ export class LocationNavigation {
const isCityChanged = const isCityChanged =
this._activeLocationId && this._activeLocationId !== cityId; this._activeLocationId && this._activeLocationId !== cityId;
const result = this._flyWithTransition(!!isCityChanged, (hooks) => const result = this._flyWithTransition(!!isCityChanged, (hooks) =>
flyToPOI(this.viewer, cityId, poiIndex, hooks), flyToPOI(this.engine, cityId, poiIndex, hooks),
); );
if (result === false) return; if (result === false) return;
this._setActiveLocation(cityId); this._setActiveLocation(cityId);
@@ -350,9 +355,9 @@ export class LocationNavigation {
} catch { } catch {
/* best-effort release */ /* best-effort release */
} }
this.viewer.trackedEntity = undefined; if (this.viewer) this.viewer.trackedEntity = undefined;
this.viewer.camera.cancelFlight(); this.engine.camera.cancelFlight();
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); this.engine.camera.release();
this._beginWorldJumpTransition(); this._beginWorldJumpTransition();
let resolveReset; let resolveReset;
@@ -367,7 +372,7 @@ export class LocationNavigation {
settled = true; settled = true;
clearTimeout(timer); clearTimeout(timer);
this._endWorldJumpTransition(); this._endWorldJumpTransition();
const carto = this.viewer.camera.positionCartographic; const carto = this.engine.camera.pose().cartographic;
const result = { const result = {
ok: !cancelled, ok: !cancelled,
action: 'zoom_to_globe', action: 'zoom_to_globe',
@@ -392,7 +397,7 @@ export class LocationNavigation {
}; };
this._cancelGlobeReset = () => finish(true); this._cancelGlobeReset = () => finish(true);
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
const height = this.viewer.camera.positionCartographic?.height; const height = this.engine.camera.height();
finish( finish(
!Number.isFinite(height) || !Number.isFinite(height) ||
Math.abs(height - GLOBE_VIEW.heightM) > 1000, Math.abs(height - GLOBE_VIEW.heightM) > 1000,
@@ -406,7 +411,7 @@ export class LocationNavigation {
'aria-label', 'aria-label',
'Resetting cockpit to full globe view', 'Resetting cockpit to full globe view',
); );
const target = flyToGlobeView(this.viewer, { const target = flyToGlobeView(this.engine, {
onComplete: () => finish(false), onComplete: () => finish(false),
onCancel: () => finish(true), onCancel: () => finish(true),
}); });
@@ -414,7 +419,7 @@ export class LocationNavigation {
return resetPromise; return resetPromise;
} }
/** Revoke callbacks and settle owned camera work before the viewer is released. */ /** Revoke callbacks and settle owned camera work before the engine is released. */
destroy() { destroy() {
if (this._disposed) return; if (this._disposed) return;
this._disposed = true; this._disposed = true;
+16 -9
View File
@@ -1,16 +1,21 @@
import { cancelCameraArrival } from '../data/cameraArrival.js'; import { cancelCameraArrival } from '../data/cameraArrival.js';
import { createStateChannel } from '../app/stateChannel.js'; import { createStateChannel } from '../app/stateChannel.js';
import * as Cesium from 'cesium'; import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { import {
beginDeferredNavigation, beginDeferredNavigation,
reassertNavigationHandoff, reassertNavigationHandoff,
runExplicitNavigation, runExplicitNavigation,
} from '../navigationPolicy.js'; } from '../navigationPolicy.js';
/** Own camera authority generations, pending search UI and tracking handoff. */ /**
* Own camera authority generations, pending search UI and tracking handoff.
* The camera is the globe `engine`'s. `viewer` is optional and only clears
* Cesium's tracked entity (entity tracking moves to the engine in phase 1c).
*/
export class NavigationController { export class NavigationController {
constructor({ constructor({
viewer, viewer = null,
engine = engineFromViewer(viewer),
tracking, tracking,
searchInput, searchInput,
interruptCameraMotion, interruptCameraMotion,
@@ -24,6 +29,7 @@ export class NavigationController {
}) { }) {
Object.assign(this, { Object.assign(this, {
viewer, viewer,
engine,
tracking, tracking,
searchInput, searchInput,
interruptCameraMotion, interruptCameraMotion,
@@ -46,7 +52,7 @@ export class NavigationController {
cancelPendingSelection = true, cancelPendingSelection = true,
clearSearchedLocation = true, clearSearchedLocation = true,
} = {}) { } = {}) {
cancelCameraArrival(this.viewer); cancelCameraArrival(this.engine);
this.cancelOrientation(); this.cancelOrientation();
const { flightsLayer, militaryFlightsLayer, satellitesLayer } = const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
this.tracking; this.tracking;
@@ -174,12 +180,13 @@ export class NavigationController {
} catch { } catch {
/* best-effort release */ /* best-effort release */
} }
this.viewer.trackedEntity = undefined; if (this.viewer) this.viewer.trackedEntity = undefined;
this.interruptCameraMotion('explicit-navigation'); this.interruptCameraMotion('explicit-navigation');
this.stopOrbit(); this.stopOrbit();
if (!preserveCameraFlight) this.viewer.camera.cancelFlight(); const camera = this.engine?.camera;
if (!preserveCameraFlight) camera?.cancelFlight();
try { try {
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); camera?.release();
} catch { } catch {
/* teardown race */ /* teardown race */
} }
@@ -213,7 +220,7 @@ export class NavigationController {
release: () => { release: () => {
this.interruptCameraMotion('camera-orientation'); this.interruptCameraMotion('camera-orientation');
this.stopOrbit(); this.stopOrbit();
this.viewer.camera.cancelFlight(); this.engine?.camera.cancelFlight();
}, },
navigate, navigate,
}); });
@@ -259,7 +266,7 @@ export class NavigationController {
return this._cameraHandoffs.subscribe(listener, { emitCurrent: false }); return this._cameraHandoffs.subscribe(listener, { emitCurrent: false });
} }
stop() { stop() {
cancelCameraArrival(this.viewer); cancelCameraArrival(this.engine);
this._disposed = true; this._disposed = true;
this._cameraHandoffs?.publish(); this._cameraHandoffs?.publish();
this._cameraHandoffs?.destroy(); this._cameraHandoffs?.destroy();
+12 -13
View File
@@ -3,10 +3,15 @@ import { stampInitialShareGesture } from '../navigationPolicy.js';
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { UiLifetime } from './uiLifetime.js'; import { UiLifetime } from './uiLifetime.js';
/** Own initial share restoration, durable layer state and restoration notices. */ /**
* Own initial share restoration, durable layer state and restoration notices.
* `canvas` (the globe engine's) receives the first-gesture listeners; it
* defaults to the viewer's.
*/
export class ShareRestoration { export class ShareRestoration {
constructor({ constructor({
viewer, viewer,
canvas = viewer?.canvas,
navigation, navigation,
syncShareState, syncShareState,
syncModels3d, syncModels3d,
@@ -16,6 +21,7 @@ export class ShareRestoration {
}) { }) {
Object.assign(this, { Object.assign(this, {
viewer, viewer,
canvas,
navigation, navigation,
syncShareState, syncShareState,
syncModels3d, syncModels3d,
@@ -114,20 +120,16 @@ export class ShareRestoration {
this.navigation._stampNavigation(options), this.navigation._stampNavigation(options),
); );
}; };
this.viewer?.canvas?.addEventListener( this.canvas?.addEventListener(
'pointerdown', 'pointerdown',
this._initialShareGestureHandler, this._initialShareGestureHandler,
{ {
passive: true, passive: true,
}, },
); );
this.viewer?.canvas?.addEventListener( this.canvas?.addEventListener('wheel', this._initialShareGestureHandler, {
'wheel',
this._initialShareGestureHandler,
{
passive: true, passive: true,
}, });
);
} }
connect(dataManager) { connect(dataManager) {
this._dataManager = dataManager; this._dataManager = dataManager;
@@ -292,14 +294,11 @@ export class ShareRestoration {
share: null, share: null,
layers: [], layers: [],
}); });
this.viewer?.canvas?.removeEventListener( this.canvas?.removeEventListener(
'pointerdown', 'pointerdown',
this._initialShareGestureHandler, this._initialShareGestureHandler,
); );
this.viewer?.canvas?.removeEventListener( this.canvas?.removeEventListener('wheel', this._initialShareGestureHandler);
'wheel',
this._initialShareGestureHandler,
);
this._initialShareGestureHandler = null; this._initialShareGestureHandler = null;
this._lifetime.destroy(); this._lifetime.destroy();
} }
+3 -4
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { LocationNavigation } from './locationNavigation.js'; import { LocationNavigation } from './locationNavigation.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { LayerBindings } from './layerBindings.js'; import { LayerBindings } from './layerBindings.js';
function replaceWindow(t, value) { function replaceWindow(t, value) {
@@ -17,11 +18,9 @@ function location(t) {
replaceWindow(t, { setTimeout }); replaceWindow(t, { setTimeout });
const calls = []; const calls = [];
let hooks; let hooks;
const engine = createFakeEngine({ altitude: 20000000 });
const owner = new LocationNavigation({ const owner = new LocationNavigation({
viewer: { camera: { engine,
cancelFlight() {}, lookAtTransform() {},
positionCartographic: { latitude: 0, longitude: 0, height: 20000000 },
} },
services: { services: {
OrbitController: class { stop() { calls.push('orbit-stop'); } }, OrbitController: class { stop() { calls.push('orbit-stop'); } },
GLOBE_VIEW: { heightM: 20000000 }, GLOBE_VIEW: { heightM: 20000000 },