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