diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4ec5742db..dfe81dbfc 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2490,7 +2490,9 @@ "src/app/operations.js", "src/app/scene.js", "src/render/cesium/appRenderer.js", + "src/render/rendererOf.js", "src/render/cesium/ports.js", + "src/render/camera/events.js", "src/render/contract/index.js", "src/sources/sourceSlot.js", "src/app/sources.js", @@ -5275,6 +5277,7 @@ "src/geo/quaternion.js", "src/render/stub/motion.js", "src/render/stub/camera.js", + "src/render/camera/events.js", "src/render/stub/geodesic.js", "src/render/stub/viewRectangle.js", "src/render/stub/orbits.js", diff --git a/src/hud.js b/src/hud.js index 7e61fe91b..b793410a0 100644 --- a/src/hud.js +++ b/src/hud.js @@ -3,7 +3,7 @@ import { applicationServices } from './services/application.js'; * @module hud * @description Intelligence HUD Overlay — NRO/NGA Satellite Aesthetic. * - * Renders reconnaissance-styled telemetry over the Cesium canvas. The + * Renders reconnaissance-styled telemetry over the map canvas. The * MGRS/lat-lon coordinates (the point below the camera), altitude and * off-nadir angle come from the live camera, and the clocks show the current * UTC time. Sun elevation is an approximate estimate from that position and @@ -19,7 +19,8 @@ import { applicationServices } from './services/application.js'; * Color theming is driven by the active shader mode via CSS custom properties. */ -import * as Cesium from 'cesium'; +import { toDegrees, toRadians } from './geo/index.js'; +import { rendererOf } from './render/rendererOf.js'; import { greatCircleKm } from './geoDistance.js'; import { forward as toMGRS } from 'mgrs'; import { CITY_POIS } from './cityPois.js'; @@ -215,7 +216,7 @@ const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) => ); /** - * Full-screen intelligence HUD overlay rendered on top of the Cesium canvas. + * Full-screen intelligence HUD overlay rendered on top of the map canvas. * * Displays MGRS/lat-lon, altitude and off-nadir readouts from the live * camera, UTC clocks, an approximate sun elevation, the selected vessel and a @@ -227,8 +228,10 @@ 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 {object} viewer - The scene the HUD sits over. + * @param {object} [options] + * @param {import('./render/contract/camera').CameraPort} [options.camera] + * Camera telemetry; default the viewer's renderer's camera. */ constructor( viewer, @@ -238,8 +241,11 @@ export class IntelHUD { basemapContext = {}, summaryService = applicationServices.summary, publish = () => {}, + camera = rendererOf(viewer)?.camera, } = {}, ) { + if (!camera) throw new Error('IntelHUD needs a camera port'); + this._camera = camera; this._publish = publish; this.summaryService = summaryService; this.summaryPolicy = summaryPolicy; @@ -278,7 +284,7 @@ export class IntelHUD { // A) kick a real AI summary once the intro fly-to settles. this._firstMetricsShown = false; this._firstSummaryKicked = false; - // ALT readout datum: the camera height Cesium reports is ELLIPSOIDAL, the + // ALT readout datum: the camera height the renderer reports is ELLIPSOIDAL, the // number a viewer reads is MSL. N comes from the same lazy ~2.7 MB EGM96 // chunk the flight layers use — requested on the first telemetry tick of a // VISIBLE HUD, never at construction, so a hidden HUD costs nothing — and @@ -343,8 +349,10 @@ export class IntelHUD { : { active: false, text: t('AIS: --') }, }), ); - this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart); - this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd); + this._offCamera = [ + camera.on('moveStart', this._onCameraMoveStart), + camera.on('moveEnd', this._onCameraMoveEnd), + ]; this._offLocale = onLocaleChange(() => this._relocalize()); this._startTimers(); } @@ -495,18 +503,17 @@ 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 pose 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; - if (!cartographic) return; - - const lonDeg = Cesium.Math.toDegrees(cartographic.longitude); - const latDeg = Cesium.Math.toDegrees(cartographic.latitude); - const altM = cartographic.height; + const pose = this._camera.pose(); + // No position yet: the readouts keep their last values. + if (![pose.lon, pose.lat, pose.height].every(Number.isFinite)) return; + const lonDeg = pose.lon; + const latDeg = pose.lat; + const altM = pose.height; const latDMS = this._toDMS(latDeg, 'lat'); const lonDMS = this._toDMS(lonDeg, 'lon'); let mgrsLabel = '---'; @@ -563,7 +570,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 = Cesium.Math.toDegrees(camera.pitch); + const pitchDeg = toDegrees(pose.pitch); const ona = Math.max(0, 90 + pitchDeg); // i18n-ignore: sensor codes readouts.ona = `ONA: ${ona.toFixed(1)}°`; @@ -670,19 +677,19 @@ export class IntelHUD { const declination = 23.45 * Math.sin( - Cesium.Math.toRadians( + toRadians( (360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81), ), ); - const latRad = Cesium.Math.toRadians(lat); - const decRad = Cesium.Math.toRadians(declination); - const haRad = Cesium.Math.toRadians(hourAngle); + const latRad = toRadians(lat); + const decRad = toRadians(declination); + const haRad = toRadians(hourAngle); // Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha) const sinEl = Math.sin(latRad) * Math.sin(decRad) + Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad); // Clamp to [-1,1] to guard against floating-point drift before asin - return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl)))); + return toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl)))); } /** @@ -727,22 +734,16 @@ export class IntelHUD { * dimensions, or null if the view rectangle cannot be computed. */ _viewWindowKm(latDeg) { - const rect = this.viewer.camera.computeViewRectangle(); + const rect = this._camera.viewRectangle(); if (!rect) return null; - const north = Cesium.Math.toDegrees(rect.north); - const south = Cesium.Math.toDegrees(rect.south); - let east = Cesium.Math.toDegrees(rect.east); - let west = Cesium.Math.toDegrees(rect.west); + const { north, south, east, west } = rect; let lonSpan = Math.abs(east - west); // Handle antimeridian wrap: if span exceeds 180 deg, take the shorter arc if (lonSpan > 180) lonSpan = 360 - lonSpan; const latSpan = Math.abs(north - south); // 111 km/deg is the approximate surface distance per degree of latitude; // longitude distance is scaled by cos(lat) to account for meridian convergence. - const widthKm = Math.max( - 0, - lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)), - ); + const widthKm = Math.max(0, lonSpan * 111 * Math.cos(toRadians(latDeg))); const heightKm = Math.max(0, latSpan * 111); return { widthKm, heightKm }; } @@ -1218,8 +1219,7 @@ export class IntelHUD { clearInterval(this._timestampInterval); clearInterval(this._summaryInterval); clearInterval(this._summaryTypingInterval); - this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart); - this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd); + for (const off of this._offCamera.splice(0)) off(); this._dataManagerUnsubscribe?.(); this._vesselUnsubscribe?.(); this._summaryRequest?.abort(); diff --git a/src/hudAltitudeDatum.test.mjs b/src/hudAltitudeDatum.test.mjs index 2dc528602..d9fdc9464 100644 --- a/src/hudAltitudeDatum.test.mjs +++ b/src/hudAltitudeDatum.test.mjs @@ -25,6 +25,8 @@ import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { readFileSync } from 'node:fs'; import { registerHooks } from 'node:module'; import { ensureGeoidReady } from './data/geoid.js'; +import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs'; +import { toDegrees as deg } from './geo/index.js'; const MGRS_STUB_URL = 'gev-test-stub:mgrs'; registerHooks({ @@ -65,39 +67,29 @@ const SFO_ELLIPSOIDAL_M = -15; function installHudEnvironment() { const { elements, publish, view } = hudReadouts(); const previousDocument = globalThis.document; - const cameraListeners = {}; + const camera = fakeCameraPort({ + pose: { + pitch: -Math.PI / 2, + lat: deg((SFO.latDeg * Math.PI) / 180), + lon: deg((SFO.lonDeg * Math.PI) / 180), + height: SFO_ELLIPSOIDAL_M, + }, + }); globalThis.document = { getElementById: (id) => elements.get(id) ?? null, querySelectorAll: () => [], addEventListener() {}, removeEventListener() {}, }; - const viewer = { - camera: { - pitch: -Math.PI / 2, - positionCartographic: { - latitude: (SFO.latDeg * Math.PI) / 180, - longitude: (SFO.lonDeg * Math.PI) / 180, - height: SFO_ELLIPSOIDAL_M, - }, - computeViewRectangle: () => undefined, - moveStart: { - addEventListener(listener) { - cameraListeners.moveStart = listener; - }, - removeEventListener() {}, - }, - moveEnd: { - addEventListener(listener) { - cameraListeners.moveEnd = listener; - }, - removeEventListener() {}, - }, - }, + const viewer = {}; + const cameraListeners = { + moveStart: () => camera.emit('moveStart'), + moveEnd: () => camera.emit('moveEnd'), }; return { elements, viewer, + camera, publish, view, cameraListeners, @@ -204,7 +196,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, { publish: env.publish }); + hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish }); const alt = () => env.elements.get('hud-alt').textContent; const summary = () => env.elements.get('hud-summary').textContent; @@ -253,12 +245,14 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig const env = installHudEnvironment(); // London: N is +46 m, so the correction moves the readout DOWN. A sign flip // that happens to look right at SFO fails here. - env.viewer.camera.positionCartographic.latitude = (51.5072 * Math.PI) / 180; - env.viewer.camera.positionCartographic.longitude = (-0.1275 * Math.PI) / 180; - env.viewer.camera.positionCartographic.height = 100; + env.camera.setPose({ + lat: deg((51.5072 * Math.PI) / 180), + lon: deg((-0.1275 * Math.PI) / 180), + height: 100, + }); let hud; try { - hud = new IntelHUD(env.viewer, { publish: env.publish }); + hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish }); hud._updateCameraData(); // cold: requests the grid, paints uncorrected assert.match(env.elements.get('hud-alt').textContent, /^ALT: 100m/); await ensureGeoidReady(); @@ -293,7 +287,7 @@ test('a summary asked for one view is dropped once the camera moves', async () = }; let hud; try { - hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); + hud = new IntelHUD(env.viewer, { camera: env.camera.port, summaryService, publish: env.publish }); let place = 'Yellowstone'; hud._summaryContext = async () => ({ placeLabels: [place] }); const summary = () => env.elements.get('hud-summary').textContent; @@ -353,7 +347,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex }; let hud; try { - hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); + hud = new IntelHUD(env.viewer, { camera: env.camera.port, summaryService, publish: env.publish }); hud._summaryContext = async () => ({ placeLabels: ['Yellowstone'] }); const element = env.elements.get('hud-summary'); const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -415,7 +409,7 @@ test('a cancelled summary retries the same context but completed answers stay re }; let hud; try { - hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); + hud = new IntelHUD(env.viewer, { camera: env.camera.port, summaryService, publish: env.publish }); hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] }); hud._updateCameraData(); const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -472,7 +466,7 @@ test('after a dropped or timed-out request the next tick asks again, and a move }; let hud; try { - hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); + hud = new IntelHUD(env.viewer, { camera: env.camera.port, summaryService, publish: env.publish }); // The same place before and after: a slow follow camera, a small pan. hud._summaryContext = async () => ({ placeLabels: ['Lisboa'] }); const element = env.elements.get('hud-summary'); @@ -537,7 +531,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t) }; let hud; try { - hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); + hud = new IntelHUD(env.viewer, { camera: env.camera.port, summaryService, publish: env.publish }); hud._summaryContext = async () => ({ placeLabels: ['San Francisco'] }); hud._updateCameraData(); await hud._updateSummary(true); @@ -578,7 +572,7 @@ test('a retired Operator layout falls back to Tactical in the live HUD', () => { const env = installHudEnvironment(); let hud; try { - hud = new IntelHUD(env.viewer, { publish: env.publish }); + hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish }); hud.setVariant('minimal'); assert.equal(hud.getVariant(), 'minimal'); hud.setVariant('operator'); @@ -589,3 +583,39 @@ test('a retired Operator layout falls back to Tactical in the live HUD', () => { env.restore(); } }); + +test('a camera with no position leaves the readouts as they were', () => { + const env = installHudEnvironment(); + let hud; + try { + hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish }); + hud._updateCameraData(); + const alt = env.elements.get('hud-alt').textContent; + const metrics = hud._latestMetrics; + env.camera.setPose({ lon: NaN, lat: NaN, height: NaN }); + hud._updateCameraData(); + assert.equal(env.elements.get('hud-alt').textContent, alt); + assert.equal(hud._latestMetrics, metrics); + assert.doesNotMatch(alt, /NaN/); + } finally { + hud?.destroy(); + env.restore(); + } +}); + +test('the view window is measured from the camera’s rectangle in degrees', () => { + const env = installHudEnvironment(); + let hud; + try { + hud = new IntelHUD(env.viewer, { camera: env.camera.port, publish: env.publish }); + assert.equal(hud._viewWindowKm(0), null, 'no rectangle, no window'); + env.camera.setViewRectangle({ west: -1, south: -0.5, east: 1, north: 0.5 }); + assert.deepEqual(hud._viewWindowKm(0), { widthKm: 222, heightKm: 111 }); + // Across the antimeridian: the short way round. + env.camera.setViewRectangle({ west: 179, south: 0, east: -179, north: 1 }); + assert.equal(Math.round(hud._viewWindowKm(60).widthKm), 111); + } finally { + hud?.destroy(); + env.restore(); + } +}); diff --git a/src/hudDms.test.mjs b/src/hudDms.test.mjs index 457964072..c5ecc529b 100644 --- a/src/hudDms.test.mjs +++ b/src/hudDms.test.mjs @@ -11,6 +11,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { registerHooks } from 'node:module'; +import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs'; +import { toDegrees as deg } from './geo/index.js'; const MGRS_STUB_URL = 'gev-test-stub:mgrs'; registerHooks({ @@ -65,22 +67,19 @@ test('the telemetry tick paints the carried value into both readouts', () => { addEventListener() {}, removeEventListener() {}, }; - const viewer = { - camera: { - pitch: -Math.PI / 2, - positionCartographic: { - latitude: (37.75 * Math.PI) / 180, - longitude: (-122.35 * Math.PI) / 180, - height: 1500, - }, - computeViewRectangle: () => undefined, - moveStart: { addEventListener() {}, removeEventListener() {} }, - moveEnd: { addEventListener() {}, removeEventListener() {} }, - }, - }; + const viewer = {}; let hud; try { - hud = new IntelHUD(viewer, { publish }); + hud = new IntelHUD(viewer, { + publish, + camera: fakeCameraPort({ + pose: { + lat: deg((37.75 * Math.PI) / 180), + lon: deg((-122.35 * Math.PI) / 180), + height: 1500, + }, + }).port, + }); hud._updateCameraData(); const latlon = elements.get('hud-latlon').textContent; const bottom = elements.get('hud-bottom-line').textContent; diff --git a/src/hudPageVisibility.test.mjs b/src/hudPageVisibility.test.mjs index 97e75c689..c9517d801 100644 --- a/src/hudPageVisibility.test.mjs +++ b/src/hudPageVisibility.test.mjs @@ -8,6 +8,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { registerHooks } from 'node:module'; +import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs'; +import { toDegrees as deg } from './geo/index.js'; const MGRS_STUB_URL = 'gev-test-stub:mgrs'; registerHooks({ @@ -41,13 +43,6 @@ function installHiddenPage() { removeEventListener() {}, }; const viewer = { - camera: { - pitch: -Math.PI / 2, - positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 }, - computeViewRectangle: () => undefined, - moveStart: { addEventListener() {}, removeEventListener() {} }, - moveEnd: { addEventListener() {}, removeEventListener() {} }, - }, }; return { alt: () => elements.get('hud-alt').textContent, @@ -65,7 +60,10 @@ test('the HUD readout pauses in a hidden page but keeps updating in an inline pa const page = installHiddenPage(); let hud; try { - hud = new IntelHUD(page.viewer, { publish: page.publish }); + hud = new IntelHUD(page.viewer, { + publish: page.publish, + camera: fakeCameraPort({ pose: { lat: deg(0.7), lon: deg(-1.2), height: 500 } }).port, + }); hud._visible = true; t.mock.timers.tick(250); diff --git a/src/hudSummaryGate.test.mjs b/src/hudSummaryGate.test.mjs index a744bc859..0b36a6cf2 100644 --- a/src/hudSummaryGate.test.mjs +++ b/src/hudSummaryGate.test.mjs @@ -8,6 +8,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { registerHooks } from 'node:module'; +import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs'; +import { toDegrees as deg } from './geo/index.js'; const MGRS_STUB_URL = 'gev-test-stub:mgrs'; registerHooks({ @@ -48,13 +50,6 @@ function installPage() { clearInterval, }; const viewer = { - camera: { - pitch: -Math.PI / 2, - positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 }, - computeViewRectangle: () => undefined, - moveStart: { addEventListener() {}, removeEventListener() {} }, - moveEnd: { addEventListener() {}, removeEventListener() {} }, - }, }; return { viewer, @@ -73,6 +68,7 @@ function createHud(t, summaryService) { t.mock.timers.enable({ apis: ['setInterval'] }); const page = installPage(); const hud = new IntelHUD(page.viewer, { + camera: fakeCameraPort({ pose: { lat: deg(0.53), lon: deg(-1.7), height: 600 } }).port, summaryService, publish: page.publish, }); diff --git a/src/hudSummaryLine.test.mjs b/src/hudSummaryLine.test.mjs index 2e8845e88..d2d502ee9 100644 --- a/src/hudSummaryLine.test.mjs +++ b/src/hudSummaryLine.test.mjs @@ -2,6 +2,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import './testSupport/stubMgrs.mjs'; import { hudReadouts } from './testSupport/hudReadouts.mjs'; +import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs'; +import { toDegrees as deg } from './geo/index.js'; const { IntelHUD } = await import('./hud.js'); const { setLocale } = await import('./i18n/index.js'); @@ -50,6 +52,7 @@ function createHud( }; const sent = []; const hud = new IntelHUD(viewer, { + camera: fakeCameraPort({ pose: { lat: deg(0.53), lon: deg(-1.7), height: 600 } }).port, publish, summaryService: { configured: async () => true, diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index 9f90ba1be..d0bd9ee74 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -42,6 +42,7 @@ import { setScopeMaskFeather, } from './scopeMask.js'; import { ShareLinkManager } from './sharelink.js'; +import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs'; // Follow the UI wiring and its extracted preset definitions. const uiSource = readShellSource() @@ -71,7 +72,7 @@ function managerForHash(hash) { roll: 0, }, }; - return new ShareLinkManager(viewer); + return new ShareLinkManager(bindFakeCesiumCamera(viewer)); } // --------------------------------------------------------------------------- diff --git a/src/render/camera/events.js b/src/render/camera/events.js new file mode 100644 index 000000000..7a73fc5ae --- /dev/null +++ b/src/render/camera/events.js @@ -0,0 +1,118 @@ +/** + * @module render/camera/events + * @description The camera port's events, shared by every renderer: the + * listener lists, delivery (a throwing listener is reported, the rest still + * run), and the contract's per-listener `changed` measure. A renderer calls + * `stepChanged()` once per frame and `emit()` for the events its engine + * raises. + */ + +import { acosClamped, distance, dot } from '../../geo/index.js'; + +const TWO_PI = Math.PI * 2; + +/** The contract's camera events. */ +export const CAMERA_EVENTS = Object.freeze([ + 'changed', + 'moveStart', + 'moveEnd', + 'followChanged', + 'flightEnd', +]); + +/** How far an angle moved, as a fraction of π, the short way round. */ +const angleDelta = (a, b) => { + const d = Math.abs(a - b) % TWO_PI; + return (d > Math.PI ? TWO_PI - d : d) / Math.PI; +}; + +/** + * @param {{pose: () => import('../contract/camera').CameraPose, + * report: (error: unknown) => void}} options `pose` is read only while a + * `changed` listener is registered. + */ +export function createCameraEvents({ pose, report }) { + const listeners = Object.fromEntries(CAMERA_EVENTS.map((e) => [e, []])); + + function deliver(entry) { + if (!entry.live) return; + try { + entry.cb(); + } catch (error) { + report(error); + } + } + + function emit(event) { + for (const entry of listeners[event].slice()) deliver(entry); + } + + /** + * @param {string} event + * @param {() => void} cb + * @param {{threshold?: number}} [options] + * @returns {() => void} + */ + function on(event, cb, options = {}) { + const list = listeners[event]; + if (!list) throw new Error(`Unknown camera event: ${event}`); + const entry = { cb, live: true, threshold: options.threshold ?? 0.5 }; + list.push(entry); + return () => { + entry.live = false; + const i = list.indexOf(entry); + if (i >= 0) list.splice(i, 1); + }; + } + + /** + * Per listener, the reference camera's formula: heading and roll each + * against their own last reported value, then direction and position + * against the last reported view. Each can report in the same frame. + */ + function stepChanged() { + const list = listeners.changed; + if (!list.length) return; + const current = pose(); + for (const entry of list.slice()) { + if (!entry.live) continue; + const t = entry.threshold; + entry.heading ??= current.heading; + entry.roll ??= current.roll; + const headingF = angleDelta(entry.heading, current.heading); + const rollF = angleDelta(entry.roll, current.roll); + if (headingF > t) entry.heading = current.heading; + if (rollF > t) entry.roll = current.roll; + if (headingF > t || rollF > t) deliver(entry); + if (!entry.view) { + entry.view = { + position: current.position, + direction: current.direction, + }; + continue; + } + const dirF = + acosClamped(dot(current.direction, entry.view.direction)) / + (current.fovy * 0.5); + const heightF = + distance(current.position, entry.view.position) / current.height; + if (dirF > t || heightF > t) { + entry.view = { + position: current.position, + direction: current.direction, + }; + deliver(entry); + } + } + } + + /** Drop every listener, including those of an event under way. */ + function clear() { + for (const list of Object.values(listeners)) { + for (const entry of list) entry.live = false; + list.length = 0; + } + } + + return { on, emit, stepChanged, clear }; +} diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index c99019ca6..8e7341107 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -14,10 +14,12 @@ * still missing. * Until a family is ported, its code reaches the Cesium viewer through * `cesiumViewerOf`, never through a renderer member, so no engine type - * crosses the contract and each call marks code still to port. + * crosses the contract and each call marks code still to port. Ported code + * that still holds the viewer reaches the renderer through `rendererOf`. */ import { createApplicationViewer } from '../../app/viewer.js'; import { CONTRACT_VERSION } from '../contract/index.js'; +import { bindRenderer } from '../rendererOf.js'; import { createCesiumInput } from './input.js'; import { cesiumCapabilities, createCesiumPorts } from './ports.js'; @@ -101,6 +103,7 @@ export function createCesiumRendererPlugin({ creditContainer.remove(); throw error; } + let unbind = () => {}; const renderer = Object.freeze({ id: 'cesium', contractVersion: CONTRACT_VERSION, @@ -118,6 +121,7 @@ export function createCesiumRendererPlugin({ }, destroy() { try { + unbind(); input.dispose(); ports.dispose(); if (!viewer.isDestroyed()) viewer.destroy(); @@ -127,6 +131,7 @@ export function createCesiumRendererPlugin({ }, }); viewers.set(renderer, viewer); + unbind = bindRenderer(viewer, renderer); return renderer; }, }); diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index b502ff947..d6eb0735d 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { CONTRACT_VERSION, checkRendererShape } from '../contract/index.js'; +import { rendererOf } from '../rendererOf.js'; import { ReadonlySet, cesiumRenderer, @@ -49,10 +50,14 @@ function fakeViewer(log, { failDestroy = false } = {}) { return { listeners, scene: { - camera: {}, + camera: { + moveStart: fakeEvent(listeners), + moveEnd: fakeEvent(listeners), + }, primitives: { add() {}, remove() {} }, requestRender() {}, preUpdate: fakeEvent(listeners), + postUpdate: fakeEvent(listeners), preRender: fakeEvent(listeners), postRender: fakeEvent(listeners), }, @@ -121,6 +126,7 @@ test('the plugin wraps the viewer it is given, behind the accessor only', async assert.throws(() => renderer.capabilities.clear(), TypeError); assert.ok(!('viewer' in renderer), 'no engine object is a member'); assert.equal(cesiumViewerOf(renderer).canvas, renderer.canvas); + assert.equal(rendererOf(viewer), renderer, 'the viewer finds its renderer'); assert.ok(viewer.listeners.size > 0, 'the ports follow the frame phases'); assert.equal(typeof renderer.draw.icons, 'function'); @@ -129,6 +135,7 @@ test('the plugin wraps the viewer it is given, behind the accessor only', async renderer.resize(); renderer.destroy(); assert.equal(viewer.listeners.size, 0, 'and stop on destroy'); + assert.equal(rendererOf(viewer), null, 'and lets go of the viewer'); assert.deepEqual(log, ['resize', 'destroy']); assert.deepEqual(doc.children, [], 'credits leave with the renderer'); renderer.destroy(); @@ -340,12 +347,10 @@ const EXPECTED_MISSING = [ 'camera.lookAt', 'camera.matrices', 'camera.move', - 'camera.on', 'camera.onCommand', 'camera.release', 'camera.rotateAround', 'camera.setFov', - 'camera.viewRectangle', 'camera.zoom', 'draw.atlas', 'draw.clampState', diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 1a0dc4b27..54869d192 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -14,6 +14,7 @@ import { createCesiumSceneRenderer, pointerFeedOf } from './sceneRenderer.js'; /** @type {ReadonlySet} */ export const CESIUM_CHECKS = new Set([ + 'camera.viewRectangle', 'draw.drillPick', 'draw.ids', 'draw.models', diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index 626f42c1e..329fd2e7e 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -13,6 +13,7 @@ */ import * as Cesium from 'cesium'; import { heightsFor } from '../../queries/heights.js'; +import { createCameraEvents } from '../camera/events.js'; import { atEndOfTick, billboardLoadError, @@ -788,7 +789,23 @@ export function createCesiumPorts(widget, { capabilities }) { models: (spec) => makeSet('models', { ...spec }, buildModels), }; + // Camera events: Cesium's own moveStart and moveEnd (500 ms after the + // last change, never while following), and `changed` measured per + // listener on every tick, where Cesium measures its single threshold. + const cameraEvents = createCameraEvents({ + pose: () => cameraPort.pose(), + report, + }); + removers.push( + scene.postUpdate.addEventListener(() => { + if (!disposed) cameraEvents.stepChanged(); + }), + camera.moveStart.addEventListener(() => cameraEvents.emit('moveStart')), + camera.moveEnd.addEventListener(() => cameraEvents.emit('moveEnd')), + ); + const cameraPort = { + on: (event, cb, options) => cameraEvents.on(event, cb, options), setView(spec) { const d = spec.destination; if (d && 'rect' in d) @@ -804,9 +821,29 @@ export function createCesiumPorts(widget, { capabilities }) { }); scene.requestRender(); }, + viewRectangle() { + const rect = camera.computeViewRectangle(Cesium.Ellipsoid.WGS84); + if (!rect) return null; + const deg = Cesium.Math.toDegrees; + return { + west: deg(rect.west), + south: deg(rect.south), + east: deg(rect.east), + north: deg(rect.north), + }; + }, pose() { const v = (c) => ({ x: c.x, y: c.y, z: c.z }); + // Cesium has no cartographic position for an eye at the earth's + // centre; the pose then says so with NaN. + const carto = camera.positionCartographic ?? { + longitude: NaN, + latitude: NaN, + height: NaN, + }; return { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), position: v(camera.positionWC), direction: v(camera.directionWC), up: v(camera.upWC), @@ -814,7 +851,7 @@ export function createCesiumPorts(widget, { capabilities }) { heading: camera.heading, pitch: camera.pitch, roll: camera.roll, - height: camera.positionCartographic.height, + height: carto.height, fovy: camera.frustum.fovy, aspectRatio: camera.frustum.aspectRatio, near: camera.frustum.near, @@ -1183,6 +1220,7 @@ export function createCesiumPorts(widget, { capabilities }) { // A listener that disposes the renderer stops the rest of its event. for (const list of Object.values(rendererListeners)) list.clear(); for (const list of Object.values(frameListeners)) list.clear(); + cameraEvents.clear(); for (const done of [...pendingRenders]) done(); // Late model loads find no host and destroy themselves. for (const record of sets.values()) record.host = null; diff --git a/src/render/cesium/ports.test.mjs b/src/render/cesium/ports.test.mjs index e333c9b3b..9157ce853 100644 --- a/src/render/cesium/ports.test.mjs +++ b/src/render/cesium/ports.test.mjs @@ -6,6 +6,7 @@ import { createCesiumPorts, listenerList, } from './ports.js'; +import { updateCameraChanged } from './internals.js'; test('capabilities follow what the scene supports', () => { assert.deepEqual(cesiumCapabilities({}), ['models']); @@ -85,13 +86,14 @@ function fakeWidget(overrides = {}) { const calls = []; const primitives = []; const scene = { - camera: {}, + camera: { moveStart: event(), moveEnd: event() }, primitives: { add: (p) => primitives.push(p), remove: (p) => primitives.splice(primitives.indexOf(p), 1), }, requestRender() {}, preUpdate: event(), + postUpdate: event(), preRender: event(), postRender: event(), sampleHeightSupported: true, @@ -266,6 +268,7 @@ function tickingWidget() { scene._renderRequested = true; }, preUpdate: event('preUpdate'), + postUpdate: event('postUpdate'), preRender: event('preRender'), postRender: event('postRender'), }; @@ -500,3 +503,116 @@ test('the loop starts on demand and never draws for scene time alone', () => { assert.equal(ports.frame.diagnostics().targetFps, 60, 'the widget’s cap'); ports.dispose(); }); + +/** A city view over Austin, looking part-way down. */ +function cityView(camera) { + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 2000), + orientation: { heading: 0, pitch: -0.5, roll: 0 }, + }); +} + +/** How often Cesium's own `changed` fires at one threshold, over `steps`. */ +function cesiumChangedCount(threshold, steps) { + const { scene } = tickingWidget(); + const camera = scene.camera; + cityView(camera); + camera.percentageChanged = threshold; + let count = 0; + camera.changed.addEventListener(() => (count += 1)); + updateCameraChanged(camera); + for (const step of steps) { + step(camera); + updateCameraChanged(camera); + } + return count; +} + +test('changed fires per listener, at each threshold as Cesium would', () => { + const steps = [ + ...Array.from({ length: 24 }, () => (c) => c.lookRight(0.05)), + ...Array.from({ length: 12 }, () => (c) => c.twistLeft(0.2)), + ...Array.from({ length: 10 }, () => (c) => c.moveForward(400)), + ]; + const { widget, scene, raise } = tickingWidget(); + cityView(scene.camera); + const ports = createCesiumPorts(widget, { capabilities: all }); + const counts = { 0.5: 0, 0.1: 0 }; + for (const t of [0.5, 0.1]) + ports.camera.on('changed', () => (counts[t] += 1), { threshold: t }); + raise('postUpdate'); + for (const step of steps) { + step(scene.camera); + raise('postUpdate'); + } + assert.ok(counts[0.1] > counts[0.5], JSON.stringify(counts)); + for (const t of [0.5, 0.1]) + assert.equal(counts[t], cesiumChangedCount(t, steps), `threshold ${t}`); + ports.dispose(); +}); + +test('camera events: Cesium’s moves relayed, a throw reported, off and dispose stop them', () => { + const { widget, scene, raise } = tickingWidget(); + cityView(scene.camera); + const ports = createCesiumPorts(widget, { capabilities: all }); + const errors = []; + ports.on('error', (e) => errors.push(e.message)); + const seen = []; + ports.camera.on('moveStart', () => { + throw new Error('first'); + }); + const off = ports.camera.on('moveStart', () => seen.push('start')); + ports.camera.on('moveEnd', () => seen.push('end')); + ports.camera.on('changed', () => seen.push('changed'), { threshold: 0.01 }); + scene.camera.moveStart.raiseEvent(); + scene.camera.moveEnd.raiseEvent(); + assert.deepEqual(seen, ['start', 'end']); + assert.deepEqual(errors, ['first']); + off(); + scene.camera.moveStart.raiseEvent(); + assert.deepEqual(seen, ['start', 'end']); + assert.throws(() => ports.camera.on('turned', () => {}), /Unknown camera event/); + ports.dispose(); + scene.camera.moveEnd.raiseEvent(); + scene.camera.lookRight(1); + raise('postUpdate'); + assert.deepEqual(seen, ['start', 'end'], 'nothing after dispose'); +}); + +test('pose: degrees, and NaN where Cesium has no cartographic position', () => { + const { widget, scene } = tickingWidget(); + cityView(scene.camera); + const ports = createCesiumPorts(widget, { capabilities: all }); + const pose = ports.camera.pose(); + assert.ok(Math.abs(pose.lon + 97.74) < 1e-9 && Math.abs(pose.lat - 30.27) < 1e-9); + assert.ok(Math.abs(pose.height - 2000) < 1e-6); + scene.camera.position = new Cesium.Cartesian3(0, 0, 0); + const centre = ports.camera.pose(); + assert.ok(Number.isNaN(centre.lon) && Number.isNaN(centre.height)); + ports.dispose(); +}); + +test('a camera listener that disposes the port stops the rest of its event', () => { + const { widget, scene } = tickingWidget(); + const ports = createCesiumPorts(widget, { capabilities: all }); + const seen = []; + ports.camera.on('moveStart', () => ports.dispose()); + ports.camera.on('moveStart', () => seen.push('second')); + scene.camera.moveStart.raiseEvent(); + assert.deepEqual(seen, []); +}); + +test('viewRectangle is in degrees, around the point below a downward eye', () => { + const { widget, scene } = tickingWidget(); + scene.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 5000), + }); + const ports = createCesiumPorts(widget, { capabilities: all }); + const rect = ports.camera.viewRectangle(); + assert.ok( + rect.west < -97.74 && rect.east > -97.74 && rect.east - rect.west < 0.2, + JSON.stringify(rect), + ); + assert.ok(rect.south < 30.27 && rect.north > 30.27, JSON.stringify(rect)); + ports.dispose(); +}); diff --git a/src/render/cesiumAllowList.json b/src/render/cesiumAllowList.json index 67d3e4c04..282701b34 100644 --- a/src/render/cesiumAllowList.json +++ b/src/render/cesiumAllowList.json @@ -41,7 +41,6 @@ "src/data/osmOverlays.js", "src/data/trackedCamera.js", "src/data/trailRenderer.js", - "src/hud.js", "src/input/dispatcher.js", "src/input/sensorPick.js", "src/layers/airQuality/index.js", diff --git a/src/render/rendererOf.js b/src/render/rendererOf.js new file mode 100644 index 000000000..81a314f3e --- /dev/null +++ b/src/render/rendererOf.js @@ -0,0 +1,29 @@ +/** + * @module render/rendererOf + * @description The renderer that draws a scene, found from the scene object + * the code already holds. It bridges the migration: code that still holds + * the scene reaches the renderer's ports through it, and the lookup goes + * once that code is handed the renderer itself. + */ + +const renderers = new WeakMap(); + +/** + * Bind a renderer to its scene object. + * @param {object} host The scene object callers hold. + * @param {object} renderer + * @returns {() => void} Unbinds, if the binding is still this one. + */ +export function bindRenderer(host, renderer) { + if (renderers.has(host)) throw new Error('This scene already has a renderer'); + renderers.set(host, renderer); + return () => { + if (renderers.get(host) === renderer) renderers.delete(host); + }; +} + +/** + * @param {object|null|undefined} host + * @returns {object|null} The renderer bound to `host`, or null. + */ +export const rendererOf = (host) => (host && renderers.get(host)) || null; diff --git a/src/render/rendererOf.test.mjs b/src/render/rendererOf.test.mjs new file mode 100644 index 000000000..f3d7ac777 --- /dev/null +++ b/src/render/rendererOf.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bindRenderer, rendererOf } from './rendererOf.js'; + +test('a scene finds its renderer until it is unbound', () => { + const scene = {}; + const renderer = { id: 'r' }; + assert.equal(rendererOf(scene), null); + assert.equal(rendererOf(null), null); + const unbind = bindRenderer(scene, renderer); + assert.equal(rendererOf(scene), renderer); + assert.throws(() => bindRenderer(scene, { id: 'other' }), /already has/); + unbind(); + assert.equal(rendererOf(scene), null); +}); + +test('a stale unbind leaves a later binding alone', () => { + const scene = {}; + const unbindFirst = bindRenderer(scene, { id: 'first' }); + unbindFirst(); + const second = { id: 'second' }; + bindRenderer(scene, second); + unbindFirst(); + assert.equal(rendererOf(scene), second); +}); diff --git a/src/render/stub/camera.js b/src/render/stub/camera.js index 1632dccf1..cef9fda92 100644 --- a/src/render/stub/camera.js +++ b/src/render/stub/camera.js @@ -11,7 +11,6 @@ import { IDENTITY, PI_OVER_TWO, TWO_PI, - acosClamped, add, clone, cross, @@ -44,6 +43,7 @@ import { vec, zeroToTwoPi, } from './math.js'; +import { createCameraEvents } from '../camera/events.js'; import { geodesicAt } from './geodesic.js'; const DEFAULT_FOV = Math.PI / 3; @@ -94,13 +94,8 @@ export function createStubCamera({ const near = 1; const far = 5e8; - const listeners = { - changed: [], - moveStart: [], - moveEnd: [], - followChanged: [], - flightEnd: [], - }; + const events = createCameraEvents({ pose, report }); + const { emit, on, stepChanged } = events; const commandHooks = []; const holds = new Map(); let holdSeq = 0; @@ -727,89 +722,6 @@ export function createStubCamera({ // --- Events ---------------------------------------------------------------- - function emit(event) { - for (const entry of listeners[event].slice()) { - if (!entry.live) continue; - try { - entry.cb(); - } catch (error) { - report(error); - } - } - } - - function on(event, cb, options = {}) { - const entry = { - cb, - live: true, - threshold: options.threshold ?? 0.5, - ref: null, - }; - listeners[event].push(entry); - return () => { - entry.live = false; - const list = listeners[event]; - const i = list.indexOf(entry); - if (i >= 0) list.splice(i, 1); - }; - } - - /** How far the view moved from a listener's reference, as a fraction. */ - const angleDelta = (a, b) => { - const d = Math.abs(a - b) % TWO_PI; - return (d > Math.PI ? TWO_PI - d : d) / Math.PI; - }; - - /** - * Per listener, the reference camera's formula: heading and roll each - * against their own last reported value, then direction and position - * against the last reported view. Each can report in the same frame. - */ - function stepChanged() { - const list = listeners.changed; - if (!list.length) return; - const current = pose(); - for (const entry of list.slice()) { - if (!entry.live) continue; - const t = entry.threshold; - entry.heading ??= current.heading; - entry.roll ??= current.roll; - const headingF = angleDelta(entry.heading, current.heading); - const rollF = angleDelta(entry.roll, current.roll); - if (headingF > t) entry.heading = current.heading; - if (rollF > t) entry.roll = current.roll; - if (headingF > t || rollF > t) deliver(entry); - if (!entry.view) { - entry.view = { - position: current.position, - direction: current.direction, - }; - continue; - } - const dirF = - acosClamped(dot(current.direction, entry.view.direction)) / - (current.fovy * 0.5); - const heightF = - distance(current.position, entry.view.position) / current.height; - if (dirF > t || heightF > t) { - entry.view = { - position: current.position, - direction: current.direction, - }; - deliver(entry); - } - } - } - - function deliver(entry) { - if (!entry.live) return; - try { - entry.cb(); - } catch (error) { - report(error); - } - } - function sameView(a, b) { return ( a && diff --git a/src/share/legacyShareState.test.mjs b/src/share/legacyShareState.test.mjs index f48c7adeb..31f9d7253 100644 --- a/src/share/legacyShareState.test.mjs +++ b/src/share/legacyShareState.test.mjs @@ -22,6 +22,7 @@ import { fromLegacyShareState, toLegacyShareState, } from './legacyShareState.js'; +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; const DEG = Math.PI / 180; @@ -47,7 +48,7 @@ function parseLegacy(hash) { globalThis.window = { location: { hash: body, href: `http://localhost/${body}` }, }; - const manager = new ShareLinkManager(stubViewer()); + const manager = new ShareLinkManager(bindFakeCesiumCamera(stubViewer())); const parsed = manager.parseInitialHash(); manager.destroy(); return parsed; @@ -209,7 +210,7 @@ function generatedLegacyHash(random) { const int = (min, max) => min + Math.floor(random() * (max - min + 1)); globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; const manager = new ShareLinkManager( - stubViewer({ + bindFakeCesiumCamera(stubViewer({ position: { latitude: (int(-899999, 899999) / 1e4) * DEG, longitude: (int(-1799999, 1799999) / 1e4) * DEG, @@ -218,7 +219,7 @@ function generatedLegacyHash(random) { heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0), pitch: int(-90, 0) * DEG, roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]), - }), + })), ); const style = pick(random, VIEW_STYLES); manager.onStyleChange(style); @@ -305,7 +306,7 @@ test('the converter ignores non-objects and restore bookkeeping', () => { test('the converter knows every key of the long form’s parsed state', () => { // A key the converter does not list would silently drop out of share codes. globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; - const manager = new ShareLinkManager(stubViewer({})); + const manager = new ShareLinkManager(bindFakeCesiumCamera(stubViewer({}))); const parsed = manager._parseShareHash('v=2&lat=1&lon=2'); assert.deepEqual( Object.keys(parsed).sort(), diff --git a/src/sharelink.camera.test.mjs b/src/sharelink.camera.test.mjs new file mode 100644 index 000000000..4f206d709 --- /dev/null +++ b/src/sharelink.camera.test.mjs @@ -0,0 +1,63 @@ +// The share link's hash follows the camera through the camera port: on +// `changed` at the port's own threshold, and on `moveEnd` when it settles. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from './sharelink.js'; +import { fakeCameraPort } from './testSupport/fakeCameraPort.mjs'; + +function setup(t) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const written = []; + globalThis.window = { + location: { href: 'https://gev.example/', hash: '' }, + }; + globalThis.history = { replaceState: (_s, _t, href) => written.push(href) }; + t.after(() => { + delete globalThis.window; + delete globalThis.history; + }); + const camera = fakeCameraPort({ + pose: { lon: -97.7431, lat: 30.2672, height: 600 }, + }); + const manager = new ShareLinkManager({}, { camera: camera.port }); + const lat = () => new URL(written.at(-1)).hash.match(/lat=([^&]*)/)?.[1]; + return { camera, manager, written, lat }; +} + +test('the hash catches up when the camera comes to rest', (t) => { + const { camera, written, lat } = setup(t); + camera.setPose({ lat: 30.3 }); + camera.emit('moveEnd'); + t.mock.timers.tick(500); + assert.equal(written.length, 1); + assert.equal(lat(), '30.3000'); +}); + +test('the hash follows a move past the default threshold, not below it', (t) => { + const { camera, written, lat } = setup(t); + // The first step takes the reference view. + camera.stepChanged(); + // A tenth of the height: below the threshold, no write. + camera.setPose({ position: { x: 60, y: 0, z: 0 } }); + camera.stepChanged(); + t.mock.timers.tick(500); + assert.equal(written.length, 0); + // Past half the height: written. + camera.setPose({ lat: 30.31, position: { x: 400, y: 0, z: 0 } }); + camera.stepChanged(); + t.mock.timers.tick(500); + assert.equal(written.length, 1); + assert.equal(lat(), '30.3100'); +}); + +test('destroy lets go of both camera listeners', (t) => { + const { camera, manager, written } = setup(t); + assert.equal(camera.listenerCount('changed'), 1); + assert.equal(camera.listenerCount('moveEnd'), 1); + manager.destroy(); + assert.equal(camera.listenerCount('changed'), 0); + assert.equal(camera.listenerCount('moveEnd'), 0); + camera.emit('moveEnd'); + t.mock.timers.tick(500); + assert.equal(written.length, 0); +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 0e77e8303..3ae1e41a2 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -10,6 +10,7 @@ import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; import { parseSharePath } from './share/shareLinkPath.js'; import { decodeShareView } from './share/viewCodec.js'; +import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs'; /** The decoded view of a copied `/share/` link. */ const sharedState = (href) => @@ -51,7 +52,7 @@ function makeManager(hash = '') { roll: 0, }, }; - return new ShareLinkManager(viewer); + return new ShareLinkManager(bindFakeCesiumCamera(viewer)); } function installClipboard(writeText) { @@ -443,7 +444,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(bindFakeCesiumCamera(viewer)); manager.applyState({ lat: 40.7669, lon: -73.9909, @@ -472,7 +473,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(bindFakeCesiumCamera(viewer), { onRestore: (state) => { restored = state; }, isNavigationCurrent: () => false, }); @@ -702,7 +703,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(bindFakeCesiumCamera(viewer), { onRestore: () => restoreGate }); let settled = false; const applying = manager.applyState({ lat: 40, lon: -74, alt: 500, heading: 0, pitch: -30, roll: 0, @@ -731,7 +732,7 @@ test('a later navigation prevents share completion from resetting the final pose }, scene: { requestRender() {} }, }; - const manager = new ShareLinkManager(viewer, { + const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), { isNavigationCurrent: (token) => token === generation, }); manager.applyState({ @@ -755,7 +756,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(bindFakeCesiumCamera(viewer), { isNavigationCurrent: (token) => token === generation, cancelOwnedNavigation: () => { cancellations += 1; flight?.cancel?.(); }, }); @@ -767,7 +768,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(bindFakeCesiumCamera(viewer), { isNavigationCurrent: (token) => token === generation, cancelOwnedNavigation: () => { cancellations += 1; }, }); diff --git a/src/sharelink.js b/src/sharelink.js index c7bf451db..f41f56c26 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { fromDegrees, toDegrees, toRadians } from './geo/index.js'; +import { rendererOf } from './render/rendererOf.js'; import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js'; import { migrateDetectionState, @@ -69,9 +70,12 @@ export class ShareLinkManager { isNavigationCurrent, cancelOwnedNavigation, initialShareCode = null, + camera = rendererOf(viewer)?.camera, } = {}, ) { + if (!camera) throw new Error('ShareLinkManager needs a camera port'); this.viewer = viewer; + this._camera = camera; // A host may hand over the share code to open instead of the address. this._initialShareCode = typeof initialShareCode === 'string' ? initialShareCode : null; @@ -133,12 +137,13 @@ export class ShareLinkManager { ? cancelOwnedNavigation : null; - // Listen for camera changes - this._removeCameraChanged = this.viewer.camera.changed.addEventListener( - () => { - this._scheduleUpdate(); - }, - ); + // The hash follows the camera as it moves, and catches up with where + // it comes to rest. + const schedule = () => this._scheduleUpdate(); + this._cameraOffs = [ + camera.on('changed', schedule), + camera.on('moveEnd', schedule), + ]; } /** @@ -645,18 +650,17 @@ 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; - if (!carto) return null; + const pose = this._camera.pose(); + if (!Number.isFinite(pose.lat) || !Number.isFinite(pose.lon)) return null; const params = new URLSearchParams(); params.set('v', '2'); - params.set('lat', toDegrees(carto.latitude).toFixed(4)); - params.set('lon', toDegrees(carto.longitude).toFixed(4)); - params.set('alt', Math.round(carto.height).toString()); - params.set('heading', Math.round(toDegrees(camera.heading)).toString()); - params.set('pitch', Math.round(toDegrees(camera.pitch)).toString()); - params.set('roll', Math.round(toDegrees(camera.roll)).toString()); + params.set('lat', pose.lat.toFixed(4)); + params.set('lon', pose.lon.toFixed(4)); + params.set('alt', Math.round(pose.height).toString()); + params.set('heading', Math.round(toDegrees(pose.heading)).toString()); + params.set('pitch', Math.round(toDegrees(pose.pitch)).toString()); + params.set('roll', Math.round(toDegrees(pose.roll)).toString()); params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal'); params.set('bloom', this._bloomEnabled ? '1' : '0'); params.set('sharpen', this._sharpenEnabled ? '1' : '0'); @@ -711,8 +715,7 @@ export class ShareLinkManager { this._destroyed = true; clearTimeout(this._debounceTimer); this._debounceTimer = null; - this._removeCameraChanged?.(); - this._removeCameraChanged = null; + for (const off of this._cameraOffs.splice(0)) off(); this._unwatchNavigation?.(); this._layerStateProvider = null; this._panelStateProvider = null; diff --git a/src/sharelink.lang.test.mjs b/src/sharelink.lang.test.mjs index 4cb48ddc7..774c74e07 100644 --- a/src/sharelink.lang.test.mjs +++ b/src/sharelink.lang.test.mjs @@ -5,6 +5,7 @@ import assert from 'node:assert/strict'; import { ShareLinkManager } from './sharelink.js'; import { resolveLocale } from './i18n/locale.js'; import { persistLocale } from './i18n/index.js'; +import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs'; function makeManager(href) { globalThis.window = { location: { href, hash: new URL(href).hash } }; @@ -18,7 +19,7 @@ function makeManager(href) { roll: 0, }, }; - return new ShareLinkManager(viewer); + return new ShareLinkManager(bindFakeCesiumCamera(viewer)); } function installClipboard(writeText) { diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs index e4e70b629..19ad14c9f 100644 --- a/src/sharelink.navigation.test.mjs +++ b/src/sharelink.navigation.test.mjs @@ -10,6 +10,7 @@ import { decodeLayerStateParams, } from './data/layerState.js'; import { LayerStateCoordinator } from './data/layerStateCoordinator.js'; +import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs'; const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e'; const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f'; @@ -102,7 +103,7 @@ function fakeViewer() { test('a hash navigation to a different share restores it and holds URL writes', (t) => { const tab = installTab(t, SHARE_A); - const manager = new ShareLinkManager(fakeViewer()); + const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer())); const initial = manager.parseInitialHash(); manager.completeInitialRestore(initial.restoreToken); const navigated = []; @@ -124,7 +125,7 @@ test('a hash navigation to a different share restores it and holds URL writes', test("the app's own hash writes never trigger a restore", (t) => { installTab(t, SHARE_A); - const manager = new ShareLinkManager(fakeViewer()); + const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer())); manager.completeInitialRestore(manager.parseInitialHash().restoreToken); let restores = 0; manager.watchNavigation(() => { @@ -142,7 +143,7 @@ test("the app's own hash writes never trigger a restore", (t) => { test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => { const tab = installTab(t, SHARE_A); - const manager = new ShareLinkManager(fakeViewer()); + const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer())); manager.completeInitialRestore(manager.parseInitialHash().restoreToken); // The app rewrites entry 0 with its own state; Back must return to that. manager._updateHash(); @@ -176,7 +177,7 @@ test('a newer navigation supersedes an older restore and its pending write', (t) const tab = installTab(t, SHARE_A); const timers = installManualTimers(t); - const manager = new ShareLinkManager(fakeViewer()); + const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer())); manager.completeInitialRestore(manager.parseInitialHash().restoreToken); assert.equal(timers.size, 1, 'releasing suppression schedules a write'); const navigated = []; @@ -204,7 +205,7 @@ test('a newer navigation supersedes an older restore and its pending write', (t) test('destroy removes the navigation listeners', (t) => { const tab = installTab(t, SHARE_A); - const manager = new ShareLinkManager(fakeViewer()); + const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer())); manager.parseInitialHash(); let restores = 0; manager.watchNavigation(() => { @@ -240,7 +241,7 @@ function restorationHarness(t, initialHash, navigationOverrides = {}) { const restores = []; let navigationGeneration = 0; - const manager = new ShareLinkManager(fakeViewer(), { + const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer()), { onRestore: (state) => { const gate = deferred(); restores.push({ state, gate }); diff --git a/src/testSupport/fakeCameraPort.mjs b/src/testSupport/fakeCameraPort.mjs new file mode 100644 index 000000000..053003431 --- /dev/null +++ b/src/testSupport/fakeCameraPort.mjs @@ -0,0 +1,101 @@ +/** + * A camera port for tests of code that reads the camera: a settable pose, + * a settable view rectangle, and events the test raises itself. + */ +import { createCameraEvents } from '../render/camera/events.js'; +import { toDegrees } from '../geo/index.js'; +import { bindRenderer, rendererOf } from '../render/rendererOf.js'; + +const DEFAULT_POSE = Object.freeze({ + lon: 0, + lat: 0, + height: 1000, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + position: { x: 0, y: 0, z: 0 }, + direction: { x: 0, y: 0, z: -1 }, + up: { x: 0, y: 1, z: 0 }, + right: { x: 1, y: 0, z: 0 }, + fovy: Math.PI / 3, + aspectRatio: 1, + near: 1, + far: 5e8, + frameIsIdentity: true, +}); + +/** + * @param {{pose?: object, viewRectangle?: object|null}} [initial] + */ +export function fakeCameraPort({ pose = {}, viewRectangle = null } = {}) { + let current = { ...DEFAULT_POSE, ...pose }; + let rect = viewRectangle; + const events = createCameraEvents({ + pose: () => ({ ...current }), + report: (error) => { + throw error; + }, + }); + const counts = {}; + const port = { + pose: () => ({ ...current }), + viewRectangle: () => (rect ? { ...rect } : null), + on(event, cb, options) { + const off = events.on(event, cb, options); + counts[event] = (counts[event] ?? 0) + 1; + let live = true; + return () => { + if (live) counts[event] -= 1; + live = false; + off(); + }; + }, + }; + return { + port, + /** Change the pose the port reports. */ + setPose: (fields) => (current = { ...current, ...fields }), + setViewRectangle: (next) => (rect = next), + emit: (event) => events.emit(event), + /** Run the contract's `changed` measure, as a frame would. */ + stepChanged: () => events.stepChanged(), + listenerCount: (event) => counts[event] ?? 0, + }; +} + +/** + * Bind to a fake viewer a camera port that reads its Cesium-shaped fake + * camera live (`positionCartographic`, `heading`, `pitch`, `roll`, and the + * `changed`/`moveStart`/`moveEnd` events it has), for tests whose code + * still moves that camera directly. Returns the viewer. + * @template {{camera: object}} V + * @param {V} viewer + * @returns {V} + */ +export function bindFakeCesiumCamera(viewer) { + if (rendererOf(viewer)) return viewer; + const camera = viewer.camera; + const port = { + pose() { + const c = camera.positionCartographic; + return { + ...DEFAULT_POSE, + lon: c ? toDegrees(c.longitude) : NaN, + lat: c ? toDegrees(c.latitude) : NaN, + height: c ? c.height : NaN, + heading: camera.heading ?? 0, + pitch: camera.pitch ?? -Math.PI / 2, + roll: camera.roll ?? 0, + }; + }, + viewRectangle: () => null, + on(event, cb) { + const source = camera[event]; + const off = source?.addEventListener?.(cb); + if (typeof off === 'function') return off; + return () => source?.removeEventListener?.(cb); + }, + }; + bindRenderer(viewer, { camera: port }); + return viewer; +} diff --git a/src/ui/shareButton.test.mjs b/src/ui/shareButton.test.mjs index 7de669a83..fd91708d6 100644 --- a/src/ui/shareButton.test.mjs +++ b/src/ui/shareButton.test.mjs @@ -8,6 +8,7 @@ import { parseSharePath } from '../share/shareLinkPath.js'; import { decodeShareView } from '../share/viewCodec.js'; import { applicationPinia } from './vueRuntime.js'; import { useGlobeActionsStore } from './stores/globeActions.js'; +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; /** The decoded view of a `/share/` link. */ const sharedState = (href) => @@ -28,7 +29,7 @@ function makeManager(href, { positioned = true } = {}) { roll: 0, }, }; - return new ShareLinkManager(viewer); + return new ShareLinkManager(bindFakeCesiumCamera(viewer)); } function installClipboard(writeText) { diff --git a/src/ui/shareLinks.test.mjs b/src/ui/shareLinks.test.mjs index 7347ce1cf..1ed147b89 100644 --- a/src/ui/shareLinks.test.mjs +++ b/src/ui/shareLinks.test.mjs @@ -19,6 +19,7 @@ import { followOffsetToEnu, } from '../share/viewCodec.js'; import { entityRecord, withEntityRecord } from '../data/entityRecords.js'; +import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs'; const NOW_MS = Date.now(); const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 }; @@ -127,7 +128,7 @@ function openPage(t, href, { managerOptions = {}, tracking = null } = {}) { const clock = installManualTimers(t); const viewer = fakeViewer(); const restores = []; - const manager = new ShareLinkManager(viewer, { + const manager = new ShareLinkManager(bindFakeCesiumCamera(viewer), { onRestore: async (state) => restores.push(state), isNavigationCurrent: () => true, ...managerOptions, @@ -401,7 +402,7 @@ test('a shared vessel is selected once the vessel feed has it', async (t) => { test('the share button links to /share/, following the followed entity', (t) => { installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2'); - const manager = new ShareLinkManager(fakeViewer()); + const manager = new ShareLinkManager(bindFakeCesiumCamera(fakeViewer())); // A fresh session's look, as the app reports it at start-up. manager.onToggleChange(false, true, { hudVisible: true, diff --git a/src/ui/views/intelHudView.test.mjs b/src/ui/views/intelHudView.test.mjs index de89c952a..cced95bc1 100644 --- a/src/ui/views/intelHudView.test.mjs +++ b/src/ui/views/intelHudView.test.mjs @@ -13,6 +13,8 @@ import { registerHooks } from 'node:module'; import { presentSelectedVessel } from '../../hudReadouts.js'; import { useHudStore } from '../stores/hud.js'; import IntelHudView from './IntelHudView.vue'; +import { fakeCameraPort } from '../../testSupport/fakeCameraPort.mjs'; +import { toDegrees as deg } from '../../geo/index.js'; const MGRS_STUB_URL = 'gev-test-stub:mgrs'; registerHooks({ @@ -35,7 +37,6 @@ registerHooks({ const { IntelHUD } = await import('../../hud.js'); -const listeners = () => ({ addEventListener() {}, removeEventListener() {} }); function mount(t) { document.body.replaceChildren(); @@ -44,16 +45,9 @@ function mount(t) { const app = createApp(IntelHudView).use(pinia); app.mount(document.body.appendChild(document.createElement('div'))); const hud = new IntelHUD( + {}, { - camera: { - pitch: -Math.PI / 2, - positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 }, - computeViewRectangle: () => undefined, - moveStart: listeners(), - moveEnd: listeners(), - }, - }, - { + camera: fakeCameraPort({ pose: { lat: deg(0.7), lon: deg(-1.2), height: 500 } }).port, // No AI summary: the readouts are what this test renders. summaryService: { configured: async () => false }, publish: (view) => store.publish(view),