diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 13a4fecd7..b64029896 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -551,6 +551,7 @@ "exports": ["./ui/shell"], "modules": [ "src/globe/cesium/engineFromViewer.js", + "src/cameraAngles.js", "src/globe/contract.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -1696,6 +1697,7 @@ "./standalone/catalog" ], "modules": [ + "src/cameraAngles.js", "src/cyberSonarScene.js", "src/cyberSonar.js", "src/hudLayouts.js", diff --git a/src/app/controls.test.mjs b/src/app/controls.test.mjs index 0c0d520fd..74843f105 100644 --- a/src/app/controls.test.mjs +++ b/src/app/controls.test.mjs @@ -17,7 +17,10 @@ test('the controls pass the scene engine to the shell', () => { test('the shell initializes the world overlay on its engine', () => { 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.doesNotMatch(shell, /initWorldOverlay\(viewer\)/); }); diff --git a/src/cameraAngles.js b/src/cameraAngles.js new file mode 100644 index 000000000..91d966bca --- /dev/null +++ b/src/cameraAngles.js @@ -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; diff --git a/src/cameraAngles.test.mjs b/src/cameraAngles.test.mjs new file mode 100644 index 000000000..b0e4d4fc7 --- /dev/null +++ b/src/cameraAngles.test.mjs @@ -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); +}); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 8a09810ea..5fa00a9f4 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -191,10 +191,10 @@ test('accepted navigation releases through PR15-aware ownership before flight', 'origin: trackingOrigin', 'satellitesLayer.stopTracking?.({ origin: trackingOrigin })', 'rocketLaunchesLayer.releaseCameraOwnership?.()', - 'this.viewer.trackedEntity = undefined;', + 'if (this.viewer) this.viewer.trackedEntity = undefined;', "interruptCameraMotion('explicit-navigation')", - 'this.viewer.camera.cancelFlight();', - 'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);', + 'camera?.cancelFlight();', + 'camera?.release();', ], 'follow release'); assert.match(release, /flightsLayer\.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( ui, - /viewer\?\.canvas\?\.addEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler/, + /this\.canvas\?\.addEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler/, ); assert.match( ui, - /viewer\?\.canvas\?\.addEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler/, + /this\.canvas\?\.addEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler/, ); assert.match( ui, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 744423c90..be90d157e 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -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', () => { const releaseStart = ui.indexOf(' _releaseFollowCamera('); 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'); assert.ok(releaseStart >= 0, 'shared Location camera handoff is missing'); diff --git a/src/hud.js b/src/hud.js index b8190556b..da29c4449 100644 --- a/src/hud.js +++ b/src/hud.js @@ -15,6 +15,8 @@ import { applicationServices } from './services/application.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 { CITY_POIS } from './locations.js'; import { composeLocalityTag } from './hudLocality.js'; @@ -92,12 +94,17 @@ const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) => */ export class IntelHUD { /** - * @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for - * camera telemetry and coordinate derivation. + * @param {import('./globe/contract.js').GlobeEngine | object} engine The + * 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( - viewer, + engine, { + viewer = isGlobeEngine(engine) ? null : engine, placeSearch, summaryPolicy = {}, basemapContext = {}, @@ -108,6 +115,7 @@ export class IntelHUD { this.summaryPolicy = summaryPolicy; this.basemapContext = basemapContext; this.placeSearch = placeSearch; + this.engine = engineFromViewer(engine); this.viewer = viewer; this._visible = false; 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._buildDOM(); - this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd); + this._removeCameraMoveEnd = this.engine.camera.moveEnd.addEventListener( + this._onCameraMoveEnd, + ); this._startTimers(); } @@ -332,13 +342,13 @@ export class IntelHUD { /** * 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 * collection timestamp. Stores results in {@link _latestMetrics}. */ _updateCameraData() { - const camera = this.viewer.camera; - const cartographic = camera.positionCartographic; + const pose = this.engine.camera.pose(); + const cartographic = pose.cartographic; if (!cartographic) return; 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), // 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 onaEl = document.getElementById('hud-ona'); 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. */ _viewWindowKm(latDeg) { - const rect = this.viewer.camera.computeViewRectangle(); + const rect = this.engine.camera.viewRectangle(); if (!rect) return null; const north = geo.math.toDegrees(rect.north); const south = geo.math.toDegrees(rect.south); @@ -779,11 +789,14 @@ export class IntelHUD { } async _summaryContext() { - const labels = await getBasemapLabelContext( - this.viewer, - this.placeSearch, - this.basemapContext, - ); + // Basemap labels still read the Cesium viewer (voice, phase 1c). + const labels = this.viewer + ? await getBasemapLabelContext( + this.viewer, + this.placeSearch, + this.basemapContext, + ) + : { placeLabels: [], streetLabels: [], nearbyPlaceLabels: [] }; const enabledLayers = this._dataManager ?.getAll?.() @@ -943,7 +956,8 @@ export class IntelHUD { clearInterval(this._timestampInterval); clearInterval(this._summaryInterval); clearInterval(this._summaryTypingInterval); - this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd); + this._removeCameraMoveEnd?.(); + this._removeCameraMoveEnd = null; this._dataManagerUnsubscribe?.(); this._summaryRequest?.abort(); } diff --git a/src/hudAltitudeDatum.test.mjs b/src/hudAltitudeDatum.test.mjs index eeb407da4..596051326 100644 --- a/src/hudAltitudeDatum.test.mjs +++ b/src/hudAltitudeDatum.test.mjs @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { registerHooks } from 'node:module'; import { ensureGeoidReady } from './data/geoid.js'; +import { createFakeEngine } from './globe/testing/fakeEngine.js'; const MGRS_STUB_URL = 'gev-test-stub:mgrs'; registerHooks({ @@ -57,6 +58,18 @@ const SFO_ELLIPSOIDAL_M = -15; * is deliberately absent so `_buildDOM` bails and the readouts stay the plain * 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() { const elements = new Map( ['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(); let hud; try { - hud = new IntelHUD(env.viewer); + hud = new IntelHUD(engineFor(env.viewer)); const alt = () => env.elements.get('hud-alt').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; let hud; try { - hud = new IntelHUD(env.viewer); + hud = new IntelHUD(engineFor(env.viewer)); hud._updateCameraData(); // cold: requests the grid, paints uncorrected assert.match(env.elements.get('hud-alt').textContent, /^ALT: 100m/); await ensureGeoidReady(); @@ -248,3 +261,31 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig 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(); + } +}); diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index dfd951377..d036f917e 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -42,6 +42,30 @@ import { setScopeMaskFeather, } from './scopeMask.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. const uiSource = readShellSource() @@ -71,7 +95,7 @@ function managerForHash(hash) { roll: 0, }, }; - return new ShareLinkManager(viewer); + return new ShareLinkManager(engineViewer(viewer)); } // --------------------------------------------------------------------------- diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index e862aba66..d6b53c528 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -10,6 +10,30 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.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(); @@ -47,7 +71,7 @@ function makeManager(hash = '') { roll: 0, }, }; - return new ShareLinkManager(viewer); + return new ShareLinkManager(engineViewer(viewer)); } 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; } }, }; - const manager = new ShareLinkManager(viewer); + const manager = new ShareLinkManager(engineViewer(viewer)); manager.applyState({ lat: 40.7669, lon: -73.9909, @@ -453,7 +477,7 @@ test('newer navigation suppresses delayed share camera while non-camera state st flyTo() { flights += 1; }, }, }; - const manager = new ShareLinkManager(viewer, { + const manager = new ShareLinkManager(engineViewer(viewer), { onRestore: (state) => { restored = state; }, isNavigationCurrent: () => false, }); @@ -683,7 +707,7 @@ test('share apply completion waits for both callback work and camera settlement' }, scene: { requestRender() {} }, }; - const manager = new ShareLinkManager(viewer, { onRestore: () => restoreGate }); + const manager = new ShareLinkManager(engineViewer(viewer), { onRestore: () => restoreGate }); let settled = false; const applying = manager.applyState({ 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() {} }, }; - const manager = new ShareLinkManager(viewer, { + const manager = new ShareLinkManager(engineViewer(viewer), { isNavigationCurrent: (token) => token === generation, }); manager.applyState({ @@ -736,7 +760,7 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple }, scene: { requestRender() {} }, }; - const manager = new ShareLinkManager(viewer, { + const manager = new ShareLinkManager(engineViewer(viewer), { isNavigationCurrent: (token) => token === generation, 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(setViews, 0); - const newerManager = new ShareLinkManager(viewer, { + const newerManager = new ShareLinkManager(engineViewer(viewer), { isNavigationCurrent: (token) => token === generation, cancelOwnedNavigation: () => { cancellations += 1; }, }); diff --git a/src/sharelink.js b/src/sharelink.js index 5e62dd266..028282d65 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -1,5 +1,6 @@ -import * as Cesium from 'cesium'; 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 { migrateDetectionState, @@ -86,11 +87,15 @@ const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({ }); export class ShareLinkManager { + /** + * @param {import('./globe/contract.js').GlobeEngine | object} engine The + * globe engine, or a viewer registered for one. + */ constructor( - viewer, + engine, { onRestore, isNavigationCurrent, cancelOwnedNavigation } = {}, ) { - this.viewer = viewer; + this.engine = engineFromViewer(engine); this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void this._debounceTimer = null; this._currentStyle = 'normal'; @@ -143,7 +148,7 @@ export class ShareLinkManager { : null; // Listen for camera changes - this._removeCameraChanged = this.viewer.camera.changed.addEventListener( + this._removeCameraChanged = this.engine.camera.changed.addEventListener( () => { 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 } = {}) { if (this._destroyed || !state) @@ -293,10 +298,10 @@ export class ShareLinkManager { }; // Re-apply the final pose only while this share restoration still owns // navigation. A later user or voice command wins over delayed restore. - this.viewer.camera.flyTo({ + this.engine.camera.flyTo({ ...view, duration: 3.0, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + easing: 'cubicInOut', complete: () => { if ( this._destroyed || @@ -306,8 +311,8 @@ export class ShareLinkManager { releaseOwnedFlight('superseded'); return; } - this.viewer.camera.setView(view); - this.viewer.scene?.requestRender?.(); + this.engine.camera.setView(view); + this.engine.render.request('share-restore'); releaseOwnedFlight('applied'); }, cancel: () => releaseOwnedFlight('cancelled'), @@ -539,8 +544,8 @@ export class ShareLinkManager { /** Build a deterministic snapshot without mutating history. */ _buildHashParams() { if (this._destroyed) return null; - const camera = this.viewer.camera; - const carto = camera.positionCartographic; + const pose = this.engine.camera.pose(); + const carto = pose.cartographic; if (!carto) return null; const params = new URLSearchParams(); @@ -548,15 +553,12 @@ export class ShareLinkManager { params.set('lat', geo.math.toDegrees(carto.latitude).toFixed(4)); params.set('lon', geo.math.toDegrees(carto.longitude).toFixed(4)); params.set('alt', Math.round(carto.height).toString()); - params.set( - 'heading', - Math.round(geo.math.toDegrees(camera.heading)).toString(), - ); - params.set( - 'pitch', - Math.round(geo.math.toDegrees(camera.pitch)).toString(), - ); - params.set('roll', Math.round(geo.math.toDegrees(camera.roll)).toString()); + // Heading and roll as Cesium reports them, so `heading=360` and + // `roll=360` read the same on every engine (src/cameraAngles.js). + const degrees = (angle) => Math.round(geo.math.toDegrees(angle)).toString(); + params.set('heading', degrees(cesiumHeading(pose))); + params.set('pitch', degrees(pose.pitch)); + params.set('roll', degrees(cesiumRoll(pose))); params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal'); params.set('bloom', this._bloomEnabled ? '1' : '0'); params.set('sharpen', this._sharpenEnabled ? '1' : '0'); diff --git a/src/sharelinkPose.test.mjs b/src/sharelinkPose.test.mjs new file mode 100644 index 000000000..de242fbc8 --- /dev/null +++ b/src/sharelinkPose.test.mjs @@ -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); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 0fda3daee..134d52b2f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -29,8 +29,9 @@ import { setCyberSonarControls, } from './cyberSonarControls.js'; -import * as Cesium from 'cesium'; 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'; @@ -62,12 +63,13 @@ export class StyleManager extends ShellFacade { /** * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @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( viewer, { - engine = null, + engine = engineFromViewer(viewer), mapStackController = null, placeSearch, services, @@ -129,11 +131,13 @@ export class StyleManager extends ShellFacade { readLayers: () => this._dataManager?.getAll?.() || [], }); this.viewer = viewer; + this.engine = engine; this.mapStackController = mapStackController; this.placeSearch = placeSearch; this._navigation = new NavigationController({ viewer, + engine, tracking: { flightsLayer, militaryFlightsLayer, @@ -154,6 +158,7 @@ export class StyleManager extends ShellFacade { }); this._shareRestoration = new ShareRestoration({ viewer, + canvas: engine?.host.canvas ?? viewer?.canvas, navigation: this._navigation, syncShareState: () => this._syncShareState(), syncModels3d: (state) => this._syncModels3dFromLayerState(state), @@ -302,6 +307,7 @@ export class StyleManager extends ShellFacade { this._locationNavigation = new LocationNavigation({ viewer, + engine, placeSearch, navigation: this._navigation, readCockpit: () => this.cockpitView, @@ -354,7 +360,8 @@ export class StyleManager extends ShellFacade { this._lastTrafficChipUpdateAt = 0; // Intel HUD - this.hud = new IntelHUD(viewer, { + this.hud = new IntelHUD(engine, { + viewer, placeSearch, summaryService: requestServices?.summary, }); @@ -417,13 +424,13 @@ export class StyleManager extends ShellFacade { }); // Share Link Manager - this.shareLinkManager = new ShareLinkManager(viewer, { + this.shareLinkManager = new ShareLinkManager(engine, { onRestore: async (state) => { return this._visualSettings.restoreShareState(state); }, isNavigationCurrent: (generation) => generation === this._navigationGeneration, - cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), + cancelOwnedNavigation: () => this.engine.camera.cancelFlight(), }); this.shareLinkManager.setPanelStateProvider(() => 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} */ getCameraState() { - const carto = this.viewer.camera.positionCartographic; + const pose = this.engine?.camera.pose(); + const carto = pose?.cartographic; if (!carto) return null; + // Heading and roll as Cesium reports them, as captured shots always held. return { lat: geo.math.toDegrees(carto.latitude), lon: geo.math.toDegrees(carto.longitude), alt: carto.height, - heading: geo.math.toDegrees(this.viewer.camera.heading), - pitch: geo.math.toDegrees(this.viewer.camera.pitch), - roll: geo.math.toDegrees(this.viewer.camera.roll), + heading: geo.math.toDegrees(cesiumHeading(pose)), + pitch: geo.math.toDegrees(pose.pitch), + roll: geo.math.toDegrees(cesiumRoll(pose)), }; } @@ -1342,7 +1351,7 @@ export class StyleManager extends ShellFacade { */ applyCameraState(cameraState, duration = 2.8) { if (!cameraState) return; - this.viewer.camera.flyTo({ + this.engine.camera.flyTo({ destination: geo.cartesian3.fromDegrees( cameraState.lon, cameraState.lat, @@ -1354,7 +1363,7 @@ export class StyleManager extends ShellFacade { roll: geo.math.toRadians(cameraState.roll || 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 = bindCameraOrientationControls({ viewer: this.viewer, + engine: this.engine, elements: { tiltButton: this._tiltMapBtn, northButton: this._northUpBtn, diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index aac5a34a4..255445200 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -1,6 +1,21 @@ -import * as Cesium from 'cesium'; import * as geo from '../geo/index.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) { 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 the world position under the center of the canvas, when available. */ -export function pickViewTarget(viewer) { - const scene = viewer?.scene; - const camera = viewer?.camera; - const canvas = scene?.canvas; - if (!scene || !camera || !canvas) return null; - const width = canvas.clientWidth || canvas.width || 0; - const height = canvas.clientHeight || canvas.height || 0; +/** + * Return the world position under the center of the canvas, when available. + * @param {import('../globe/contract.js').GlobeEngine | object} target The + * globe engine, or a viewer registered for one. + */ +export function pickViewTarget(target) { + const engine = engineOf(target); + if (!engine) return null; + const { width, height } = engine.host.size(); if (!width || !height) return null; - const center = new Cesium.Cartesian2(width / 2, height / 2); - let target = null; - - if (scene.pickPositionSupported && typeof scene.pickPosition === 'function') { + const center = { x: width / 2, y: height / 2 }; + const { picker, camera } = engine; + // Depth first, then terrain before the ellipsoid: the globe pick + // 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 { - target = scene.pickPosition(center); + picked = stage(); } catch { - target = null; + picked = null; } + if (isPickedWorldPosition(picked)) return picked; } - // Terrain before the ellipsoid: globe.pick 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. - 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; + return null; } -/** Describe the camera as an orbit around the current viewport center. */ -export function readCameraTargetFrame(viewer) { - const camera = viewer?.camera; - const target = viewer?.trackedEntity +/** + * Describe the camera as an orbit around the current viewport center. + * @param {import('../globe/contract.js').GlobeEngine | object} target The + * 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) - : pickViewTarget(viewer); - if (!camera || !target || !isPickedWorldPosition(camera.positionWC)) - return null; + : pickViewTarget(engine); + const pose = engine.camera.pose(); + if (!center || !isPickedWorldPosition(pose.position)) return null; - const transform = geo.transforms.eastNorthUpToFixedFrame(target); + const transform = geo.transforms.eastNorthUpToFixedFrame(center); const inverse = geo.matrix4.inverseTransformation( transform, geo.matrix4.create(), ); const localOffset = geo.matrix4.multiplyByPoint( inverse, - camera.positionWC, + pose.position, geo.cartesian3.create(), ); 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. const targetHeading = Math.atan2(-localOffset.x, -localOffset.y); 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) { - const camera = viewer?.camera; - if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false; +/** + * Apply an orbit frame, then return the camera to the fixed-world frame. + * @param {import('../globe/contract.js').GlobeEngine | object} target The + * 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 { - const trackedTransform = viewer.trackedEntity - ? geo.matrix4.clone(camera.transform) - : null; - camera.lookAt( - frame.target, - new Cesium.HeadingPitchRange( - normalizedHeading(frame.heading), - frame.pitch, - frame.range, - ), - ); + const trackedTransform = viewer?.trackedEntity ? camera.transform() : null; + camera.lookAt(frame.target, { + heading: normalizedHeading(frame.heading), + pitch: frame.pitch, + range: frame.range, + }); if (trackedTransform) { // Preserve EntityView's reference frame (including satellite frames). // Its next update carries this new local offset along with the target. camera.lookAtTransform(trackedTransform); - viewer.scene?.requestRender?.(); + engine.render.request('camera-orientation'); return true; } - const destination = geo.cartesian3.clone(camera.positionWC); - const direction = geo.cartesian3.clone(camera.directionWC); - const up = geo.cartesian3.clone(camera.upWC); - camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + const { position: destination, direction, up } = camera.pose(); + camera.release(); if (destination && direction && up) { camera.setView({ destination, orientation: { direction, up } }); } - viewer.scene?.requestRender?.(); + engine.render.request('camera-orientation'); return true; } catch { return false; @@ -158,32 +171,40 @@ export function frameIsTilted(frame) { } /** Toggle between a straight-down map and a useful oblique map angle. */ -export function toggleCameraTilt(viewer) { - const frame = readCameraTargetFrame(viewer); +export function toggleCameraTilt(target, tracker) { + const frame = readCameraTargetFrame(target, tracker); if (!frame) return false; const tilted = frameIsTilted(frame); const pitch = tilted ? STRAIGHT_DOWN_PITCH : OBLIQUE_PITCH; - return setCameraTargetFrame(viewer, { ...frame, pitch }) + return setCameraTargetFrame(target, { ...frame, pitch }, tracker) ? { tilted: !tilted, pitch } : false; } /** Rotate around the viewport center until north is at the top. */ -export function resetCameraNorth(viewer) { - const frame = readCameraTargetFrame(viewer); +export function resetCameraNorth(target, tracker) { + const frame = readCameraTargetFrame(target, tracker); if (!frame) return false; - return setCameraTargetFrame(viewer, { ...frame, heading: 0 }); + return setCameraTargetFrame(target, { ...frame, heading: 0 }, tracker); } -function headingDegrees(camera) { - return geo.math.toDegrees(normalizedHeading(camera?.heading)); +function headingDegrees(engine) { + 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( - viewer, - { now = () => performance.now(), duration = 650 } = {}, + target, + { now = () => performance.now(), duration = 650, tracker } = {}, ) { + const engine = engineOf(target); + const viewer = trackerOf(target, tracker); let remove = null; let pending = null; const cancel = () => { @@ -193,37 +214,42 @@ export function createCameraOrientationAnimator( }; function animate(frame, destination) { cancel(); - if (!viewer.scene?.preUpdate?.addEventListener || duration <= 0) - return setCameraTargetFrame(viewer, { ...frame, ...destination }); - const entity = viewer.trackedEntity; + if (!engine) return false; + if (duration <= 0) + return setCameraTargetFrame(engine, { ...frame, ...destination }, viewer); + const entity = viewer?.trackedEntity; pending = destination; const start = now(); const headingDelta = geo.math.negativePiToPi( destination.heading - frame.heading, ); - remove = viewer.scene.preUpdate.addEventListener(() => { - if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { + remove = engine.frames.preUpdate.addEventListener(() => { + if (engine.isDestroyed() || viewer?.trackedEntity !== entity) { cancel(); return; } const progress = geo.math.clamp((now() - start) / duration, 0, 1); - const eased = Cesium.EasingFunction.CUBIC_IN_OUT(progress); - const target = entity ? trackedTarget(viewer) : frame.target; + const eased = cubicInOut(progress); + const center = entity ? trackedTarget(viewer) : frame.target; if ( - !isPickedWorldPosition(target) || - !setCameraTargetFrame(viewer, { - ...frame, - target, - heading: frame.heading + headingDelta * eased, - pitch: geo.math.lerp(frame.pitch, destination.pitch, eased), - }) + !isPickedWorldPosition(center) || + !setCameraTargetFrame( + engine, + { + ...frame, + target: center, + heading: frame.heading + headingDelta * eased, + pitch: geo.math.lerp(frame.pitch, destination.pitch, eased), + }, + viewer, + ) ) { cancel(); return; } if (progress === 1) cancel(); }); - viewer.scene.requestRender?.(); + engine.render.request('camera-orientation'); return true; } 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({ - viewer, + viewer = null, + engine = engineOf(viewer), elements, runNavigation, showToast, + animationMs, }) { const tiltButton = elements?.tiltButton; 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 // therefore held between refreshes rather than recomputed per frame. 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. */ const refreshTilt = () => { if (destroyed) return; - tilted = frameIsTilted(readCameraTargetFrame(viewer)); + tilted = frameIsTilted(readFrame()); sync(); }; @@ -272,7 +315,7 @@ export function bindCameraOrientationControls({ if (destroyed) return; const next = { tilted, - heading: Math.round(headingDegrees(viewer?.camera)) % 360, + heading: Math.round(headingDegrees(engine)) % 360, }; if ( applied && @@ -308,7 +351,7 @@ export function bindCameraOrientationControls({ listen(tiltButton, () => { const pending = animator.destination; const result = runNavigation('camera', () => { - const frame = readCameraTargetFrame(viewer); + const frame = readFrame(); if (!frame) return false; const nextTilted = !frameIsTilted(pending || frame); const pitch = nextTilted ? OBLIQUE_PITCH : STRAIGHT_DOWN_PITCH; @@ -330,7 +373,7 @@ export function bindCameraOrientationControls({ listen(northButton, () => { const pending = animator.destination; const result = runNavigation('camera', () => { - const frame = readCameraTargetFrame(viewer); + const frame = readFrame(); return ( frame && animator.animate(frame, { @@ -350,13 +393,13 @@ export function bindCameraOrientationControls({ // The needle follows every frame the scene draws. `camera.changed` is NOT // enough: it only fires past `camera.percentageChanged`, so a rotation of a // 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 — // 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); for (const type of ['pointerdown', 'wheel']) { - const canvas = viewer?.scene?.canvas; + const canvas = engine?.host.canvas; canvas?.addEventListener?.(type, animator.cancel, { passive: true }); removers.push(() => canvas?.removeEventListener?.(type, animator.cancel)); } diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index accd003f8..fbe86a67b 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -12,6 +12,24 @@ import { toggleCameraTilt, } from './cameraOrientationControls.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 } = {}) { const calls = []; @@ -23,7 +41,12 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) { directionWC: new Cesium.Cartesian3(-1, 0, 0), upWC: new Cesium.Cartesian3(0, 0, 1), heading: Cesium.Math.toRadians(90), + pitch: Cesium.Math.toRadians(-90), + roll: 0, + frustum: { fovy: 1, aspectRatio: 1.5 }, changed, + moveStart: new Cesium.Event(), + moveEnd: new Cesium.Event(), pickEllipsoid: () => surface, getPickRay: () => ({}), lookAt(targetValue, offset) { @@ -44,9 +67,12 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) { pickPosition: pickPosition || (() => null), globe: { pick: () => surface }, 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 { @@ -104,8 +130,10 @@ function createRealViewer(target) { // The binding drives the needle from the scene's per-frame signal, not from // `camera.changed`, so the fixture has to carry one. scene.preRender = new Cesium.Event(); + scene.preUpdate = new Cesium.Event(); + scene.postRender = new Cesium.Event(); 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. */ @@ -282,6 +310,8 @@ test('the button shows the state the next click will produce, at every range', ( viewer, elements: { tiltButton, northButton: new FakeButton() }, runNavigation: (_noun, navigate) => navigate(), + // No frame loop drives this fixture: apply each change at once. + animationMs: 0, }); assert.equal( @@ -425,7 +455,7 @@ test('a sky-facing camera yields no frame and both actions decline', () => { scene.requestRender = () => {}; camera.getPickRay = () => ({}); camera.pickEllipsoid = () => undefined; - const viewer = { camera, scene }; + const viewer = withEngine({ camera, scene }); assert.equal(readCameraTargetFrame(viewer), null); 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 }); viewer.scene.globe.pick = () => terrain; viewer.camera.pickEllipsoid = () => sea; - assert.equal( + sameXYZ( pickViewTarget(viewer), terrain, 'the rendered ground wins over sea level', ); viewer.scene.globe.pick = () => null; - assert.equal( + sameXYZ( pickViewTarget(viewer), sea, '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 lookAt = calls.find((call) => call.type === 'lookAt'); 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.pitch, OBLIQUE_PITCH); 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); const { viewer } = createRealViewer(target); - viewer.scene.preUpdate = new Cesium.Event(); viewer.clock = { currentTime: Cesium.JulianDate.now() }; const entity = new Cesium.Entity({ 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', () => { const { viewer } = createRealViewer(Cesium.Cartesian3.fromDegrees(0, 0)); orbit(viewer, Cesium.Cartesian3.fromDegrees(0, 0), 90, OBLIQUE_PITCH, 1000); - viewer.scene.preUpdate = new Cesium.Event(); const animator = createCameraOrientationAnimator(viewer); const frame = readCameraTargetFrame(viewer); 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', () => { const calls = []; const entity = {}; - const viewer = { - trackedEntity: entity, - camera: { cancelFlight: () => calls.push('cancel-flight') }, + const viewer = { trackedEntity: entity }; + const engine = createFakeEngine(); + const cancelFlight = engine.camera.cancelFlight; + engine.camera.cancelFlight = () => { + calls.push('cancel-flight'); + cancelFlight(); }; let cockpit = false; const navigation = new NavigationController({ viewer, + engine, tracking: {}, isCockpitActive: () => cockpit, 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 { viewer } = createRealViewer(target); orbit(viewer, target, 90, OBLIQUE_PITCH, 1000); - viewer.scene.preUpdate = new Cesium.Event(); const tiltButton = new FakeButton(); const northButton = new FakeButton(); let controls; @@ -688,3 +719,45 @@ test('rapid tilt presses reverse direction and north-up keeps the requested tilt controls.destroy(); 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); +}); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index d86511e29..42b3e2f0a 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -1,12 +1,17 @@ -import * as Cesium from 'cesium'; import * as geo from '../geo/index.js'; import { createStateChannel } from '../app/stateChannel.js'; +import { engineFromViewer } from '../globe/cesium/engineFromViewer.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 { constructor({ - viewer, + viewer = null, + engine = engineFromViewer(viewer), placeSearch, services, elements, @@ -16,7 +21,7 @@ export class LocationNavigation { }) { Object.assign( this, - { viewer, placeSearch, services, navigation, readCockpit }, + { viewer, engine, placeSearch, services, navigation, readCockpit }, elements, operations, ); @@ -31,7 +36,7 @@ export class LocationNavigation { this._globeResetPromise = null; this._cancelGlobeReset = null; this._worldJumpActive = false; - this.orbitController = new services.OrbitController(viewer); + this.orbitController = new services.OrbitController(engine); this._orbitIndicator = null; this._locationState = createStateChannel( () => this._locationLookup?.getState() || null, @@ -112,7 +117,7 @@ export class LocationNavigation { !this._disposed && generation === this._navigationGeneration, beforeFly: (generation) => this._reassertNavigationHandoff(generation), search: (query, options) => - searchAndFlyTo(this.viewer, query, { + searchAndFlyTo(this.engine, query, { placeSearch: this.placeSearch, ...options, }), @@ -194,7 +199,7 @@ export class LocationNavigation { const isCityChanged = this._activeLocationId && this._activeLocationId !== cityId; const result = this._flyWithTransition(!!isCityChanged, (hooks) => - flyToPresetLocation(this.viewer, cityId, hooks), + flyToPresetLocation(this.engine, cityId, hooks), ); if (result === false) return; this._expandPOIRow(cityId); @@ -215,7 +220,7 @@ export class LocationNavigation { const isCityChanged = this._activeLocationId && this._activeLocationId !== cityId; const result = this._flyWithTransition(!!isCityChanged, (hooks) => - flyToPOI(this.viewer, cityId, poiIndex, hooks), + flyToPOI(this.engine, cityId, poiIndex, hooks), ); if (result === false) return; this._setActiveLocation(cityId); @@ -350,9 +355,9 @@ export class LocationNavigation { } catch { /* best-effort release */ } - this.viewer.trackedEntity = undefined; - this.viewer.camera.cancelFlight(); - this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + if (this.viewer) this.viewer.trackedEntity = undefined; + this.engine.camera.cancelFlight(); + this.engine.camera.release(); this._beginWorldJumpTransition(); let resolveReset; @@ -367,7 +372,7 @@ export class LocationNavigation { settled = true; clearTimeout(timer); this._endWorldJumpTransition(); - const carto = this.viewer.camera.positionCartographic; + const carto = this.engine.camera.pose().cartographic; const result = { ok: !cancelled, action: 'zoom_to_globe', @@ -392,7 +397,7 @@ export class LocationNavigation { }; this._cancelGlobeReset = () => finish(true); timer = window.setTimeout(() => { - const height = this.viewer.camera.positionCartographic?.height; + const height = this.engine.camera.height(); finish( !Number.isFinite(height) || Math.abs(height - GLOBE_VIEW.heightM) > 1000, @@ -406,7 +411,7 @@ export class LocationNavigation { 'aria-label', 'Resetting cockpit to full globe view', ); - const target = flyToGlobeView(this.viewer, { + const target = flyToGlobeView(this.engine, { onComplete: () => finish(false), onCancel: () => finish(true), }); @@ -414,7 +419,7 @@ export class LocationNavigation { 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() { if (this._disposed) return; this._disposed = true; diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index f94eb46fe..fa671fb1e 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -1,16 +1,21 @@ import { cancelCameraArrival } from '../data/cameraArrival.js'; import { createStateChannel } from '../app/stateChannel.js'; -import * as Cesium from 'cesium'; +import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { beginDeferredNavigation, reassertNavigationHandoff, runExplicitNavigation, } 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 { constructor({ - viewer, + viewer = null, + engine = engineFromViewer(viewer), tracking, searchInput, interruptCameraMotion, @@ -24,6 +29,7 @@ export class NavigationController { }) { Object.assign(this, { viewer, + engine, tracking, searchInput, interruptCameraMotion, @@ -46,7 +52,7 @@ export class NavigationController { cancelPendingSelection = true, clearSearchedLocation = true, } = {}) { - cancelCameraArrival(this.viewer); + cancelCameraArrival(this.engine); this.cancelOrientation(); const { flightsLayer, militaryFlightsLayer, satellitesLayer } = this.tracking; @@ -174,12 +180,13 @@ export class NavigationController { } catch { /* best-effort release */ } - this.viewer.trackedEntity = undefined; + if (this.viewer) this.viewer.trackedEntity = undefined; this.interruptCameraMotion('explicit-navigation'); this.stopOrbit(); - if (!preserveCameraFlight) this.viewer.camera.cancelFlight(); + const camera = this.engine?.camera; + if (!preserveCameraFlight) camera?.cancelFlight(); try { - this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + camera?.release(); } catch { /* teardown race */ } @@ -213,7 +220,7 @@ export class NavigationController { release: () => { this.interruptCameraMotion('camera-orientation'); this.stopOrbit(); - this.viewer.camera.cancelFlight(); + this.engine?.camera.cancelFlight(); }, navigate, }); @@ -259,7 +266,7 @@ export class NavigationController { return this._cameraHandoffs.subscribe(listener, { emitCurrent: false }); } stop() { - cancelCameraArrival(this.viewer); + cancelCameraArrival(this.engine); this._disposed = true; this._cameraHandoffs?.publish(); this._cameraHandoffs?.destroy(); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 135d70d75..412688336 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -3,10 +3,15 @@ import { stampInitialShareGesture } from '../navigationPolicy.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.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 { constructor({ viewer, + canvas = viewer?.canvas, navigation, syncShareState, syncModels3d, @@ -16,6 +21,7 @@ export class ShareRestoration { }) { Object.assign(this, { viewer, + canvas, navigation, syncShareState, syncModels3d, @@ -114,20 +120,16 @@ export class ShareRestoration { this.navigation._stampNavigation(options), ); }; - this.viewer?.canvas?.addEventListener( + this.canvas?.addEventListener( 'pointerdown', this._initialShareGestureHandler, { passive: true, }, ); - this.viewer?.canvas?.addEventListener( - 'wheel', - this._initialShareGestureHandler, - { - passive: true, - }, - ); + this.canvas?.addEventListener('wheel', this._initialShareGestureHandler, { + passive: true, + }); } connect(dataManager) { this._dataManager = dataManager; @@ -292,14 +294,11 @@ export class ShareRestoration { share: null, layers: [], }); - this.viewer?.canvas?.removeEventListener( + this.canvas?.removeEventListener( 'pointerdown', this._initialShareGestureHandler, ); - this.viewer?.canvas?.removeEventListener( - 'wheel', - this._initialShareGestureHandler, - ); + this.canvas?.removeEventListener('wheel', this._initialShareGestureHandler); this._initialShareGestureHandler = null; this._lifetime.destroy(); } diff --git a/src/ui/shellLifetime.test.mjs b/src/ui/shellLifetime.test.mjs index 4d34e4271..bee8d5ea2 100644 --- a/src/ui/shellLifetime.test.mjs +++ b/src/ui/shellLifetime.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { LocationNavigation } from './locationNavigation.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { LayerBindings } from './layerBindings.js'; function replaceWindow(t, value) { @@ -17,11 +18,9 @@ function location(t) { replaceWindow(t, { setTimeout }); const calls = []; let hooks; + const engine = createFakeEngine({ altitude: 20000000 }); const owner = new LocationNavigation({ - viewer: { camera: { - cancelFlight() {}, lookAtTransform() {}, - positionCartographic: { latitude: 0, longitude: 0, height: 20000000 }, - } }, + engine, services: { OrbitController: class { stop() { calls.push('orbit-stop'); } }, GLOBE_VIEW: { heightM: 20000000 },