From a3e929870d8836be329953a69bfadd504a6e6a77 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 19:19:41 +0400 Subject: [PATCH] feat(globe): camera.commanded, picker.pickGlobe, pose.aspectRatio and isDestroyed Shell navigation needs to know when a new owner takes the camera (the arrival ground guard), where the globe surface is under a window position (orientation pivot, view-centred place search), the frustum aspect ratio (landmark framing) and whether the engine is gone. Each member has a contract suite check and is implemented by the Cesium engine, the Navara engine and the fake. On Cesium, commanded hooks the viewer camera's command methods and trackedEntityChanged only while it has listeners, so direct Cesium calls from layers still announce a new owner. --- src/globe/cesium/camera.js | 84 +++++++++++++++++ src/globe/cesium/camera.test.mjs | 66 ++++++++++++++ src/globe/cesium/createCesiumEngine.js | 1 + src/globe/cesium/createCesiumEngine.test.mjs | 2 + src/globe/cesium/picker.js | 10 +++ src/globe/cesium/picker.test.mjs | 21 +++++ src/globe/cesium/testing/fakeViewer.mjs | 2 + src/globe/contract.js | 17 +++- src/globe/contract.test.mjs | 16 ++++ src/globe/navara/cameraModel.js | 10 +++ src/globe/navara/cameraModel.test.mjs | 30 +++++++ src/globe/navara/createNavaraEngine.js | 46 ++++++++++ src/globe/navara/createNavaraEngine.test.mjs | 21 +++++ src/globe/testing/contractSuite.mjs | 94 ++++++++++++++++++++ src/globe/testing/fakeEngine.js | 26 ++++-- src/globe/testing/fakeEngine.test.mjs | 19 ++++ 16 files changed, 459 insertions(+), 6 deletions(-) diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index f3a9375c4..e34a50727 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -3,6 +3,7 @@ import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; +import { createEngineEvent } from '../contract.js'; import { bridgeEvent } from './events.js'; import { toCesium2, toCesium3, toGeo3 } from './convert.js'; @@ -49,6 +50,86 @@ const cesiumOffset = (offset) => ? new Cesium.HeadingPitchRange(offset.heading, offset.pitch, offset.range) : toCesium3(offset); +// The Cesium camera entry points that hand the camera to a new owner. The +// viewer's camera is shared with code that still calls Cesium directly +// (layers, cockpit), so `commanded` hooks these methods themselves. +const COMMANDS = Object.freeze([ + 'flyTo', + 'flyToBoundingSphere', + 'setView', + 'lookAt', + 'cancelFlight', +]); + +/** + * camera.commanded: raised before any COMMANDS call on the Cesium camera and + * when the viewer's tracked entity changes. The hooks exist only while the + * event has listeners and then restore each method exactly (unless something + * newer replaced it), so an idle engine leaves the camera untouched. + */ +function commandedEvent(viewer, cesium) { + const event = createEngineEvent(); + let unhook = null; + const hook = () => { + const removers = []; + for (const name of COMMANDS) { + const original = cesium[name]; + if (typeof original !== 'function') continue; + const descriptor = Object.getOwnPropertyDescriptor(cesium, name); + const wrapped = function (...args) { + event.raiseEvent(); + return original.apply(this, args); + }; + cesium[name] = wrapped; + removers.push(() => { + if (cesium[name] !== wrapped) return; + if (descriptor) Object.defineProperty(cesium, name, descriptor); + else delete cesium[name]; + }); + } + const removeTracking = viewer.trackedEntityChanged?.addEventListener(() => + event.raiseEvent(), + ); + if (removeTracking) removers.push(removeTracking); + return () => { + for (const remove of removers) remove(); + }; + }; + let detached = false; + const sync = () => { + const wanted = !detached && event.numberOfListeners > 0; + if (wanted === (unhook !== null)) return; + if (wanted) unhook = hook(); + else { + const remove = unhook; + unhook = null; + remove(); + } + }; + const add = event.addEventListener; + const remove = event.removeEventListener; + event.addEventListener = (listener) => { + const removeListener = add(listener); + sync(); + return () => { + removeListener(); + sync(); + }; + }; + event.removeEventListener = (listener) => { + const removed = remove(listener); + sync(); + return removed; + }; + return { + event, + detach() { + detached = true; + sync(); + }, + }; +} + /** * @param {Cesium.Viewer} viewer * @returns {{ camera: import('../contract.js').GlobeCamera, destroy: () => void }} @@ -60,6 +141,7 @@ export function createCesiumCamera(viewer) { changed: bridgeEvent(cesium.changed), moveStart: bridgeEvent(cesium.moveStart), moveEnd: bridgeEvent(cesium.moveEnd), + commanded: commandedEvent(viewer, cesium), }; // The engine's own flight, so flightActive is exact even for flights that @@ -109,6 +191,7 @@ export function createCesiumCamera(viewer) { up: toGeo3(cesium.upWC), right: toGeo3(cesium.rightWC), fovy: cesium.frustum.fovy, + aspectRatio: cesium.frustum.aspectRatio, }; }, // Cesium updates positionWC in place; it is the live object. @@ -203,6 +286,7 @@ export function createCesiumCamera(viewer) { changed: bridges.changed.event, moveStart: bridges.moveStart.event, moveEnd: bridges.moveEnd.event, + commanded: bridges.commanded.event, get percentageChanged() { return cesium.percentageChanged; }, diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs index 192ba91e0..7af75097f 100644 --- a/src/globe/cesium/camera.test.mjs +++ b/src/globe/cesium/camera.test.mjs @@ -360,3 +360,69 @@ test('the camera is the viewer camera (Viewer.camera is scene.camera)', () => { const { camera } = createCesiumCamera({ ...viewer, camera: own }); assert.equal(camera.livePosition, own.positionWC); }); + +test('pose carries the frustum aspect ratio', () => { + const { camera, cesium } = setup(); + assert.equal(camera.pose().aspectRatio, cesium.frustum.aspectRatio); +}); + +test('commanded hooks the Cesium camera and tracking only while it has listeners', () => { + const { viewer, camera, cesium, destroy } = setup(); + const names = [ + 'flyTo', + 'flyToBoundingSphere', + 'setView', + 'lookAt', + 'cancelFlight', + ]; + const originals = Object.fromEntries( + names.map((name) => [name, cesium[name]]), + ); + const own = Object.fromEntries( + names.map((name) => [name, Object.getOwnPropertyDescriptor(cesium, name)]), + ); + let raised = 0; + const listener = () => raised++; + const remove = camera.commanded.addEventListener(listener); + for (const name of names) + assert.notEqual(cesium[name], originals[name], name); + // Direct calls on the viewer camera (layers, cockpit) announce themselves. + cesium.setView({}); + assert.equal(raised, 1); + cesium.cancelFlight(); + assert.equal(raised, 2); + // So do engine commands, once per Cesium entry point they reach. + camera.lookAt(geo.cartesian3.fromDegrees(0, 0, 0), { + heading: 0, + pitch: -1, + range: 10, + }); + assert.equal(raised, 3); + assert.equal(viewer.callsOf('camera.lookAt').length, 1); + // A new tracked entity takes the camera too. + viewer.trackedEntityChanged.raiseEvent({ id: 'contact' }); + assert.equal(raised, 4); + camera.release(); + assert.equal(raised, 4, 'release is not a new owner'); + remove(); + for (const name of names) { + assert.equal(cesium[name], originals[name], `${name} restored`); + assert.deepEqual(Object.getOwnPropertyDescriptor(cesium, name), own[name]); + } + assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0); + cesium.setView({}); + assert.equal(raised, 4); + camera.commanded.addEventListener(listener); + destroy(); + assert.equal(cesium.setView, originals.setView, 'destroy unhooks'); + assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0); +}); + +test('commanded leaves a newer hook on the camera in place', () => { + const { camera, cesium } = setup(); + const remove = camera.commanded.addEventListener(() => {}); + const newer = () => {}; + cesium.flyTo = newer; + remove(); + assert.equal(cesium.flyTo, newer); +}); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index 03c348a28..91a232032 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -69,5 +69,6 @@ export function createCesiumEngine(viewer, options = {}) { destroyed = true; for (const part of Object.values(parts)) part.destroy(); }, + isDestroyed: () => destroyed, }; } diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index 3a1a56a61..c2becc91c 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -77,8 +77,10 @@ test('destroy releases holds, listeners, handlers and engine-owned map state', a engine.render.setContinuous(true); engine.credits.addStatic('credit'); await engine.maps.setBaseImagery({ kind: 'osm' }); + assert.equal(engine.isDestroyed(), false); engine.destroy(); engine.destroy(); + assert.equal(engine.isDestroyed(), true); assert.equal(viewer.scene.camera.changed.numberOfListeners, 0); assert.equal(viewer.scene.postRender.numberOfListeners, 0); assert.equal(handlers.handlers[0].destroyed, true); diff --git a/src/globe/cesium/picker.js b/src/globe/cesium/picker.js index 51c5d3622..25d9082db 100644 --- a/src/globe/cesium/picker.js +++ b/src/globe/cesium/picker.js @@ -41,6 +41,16 @@ export function createCesiumPicker(viewer) { get pickPositionSupported() { return Boolean(scene.pickPositionSupported); }, + pickGlobe(screen) { + if (!scene.globe) return undefined; + try { + const camera = viewer.camera ?? scene.camera; + const ray = camera.getPickRay(toCesium2(screen)); + return ray ? toGeo3(scene.globe.pick(ray, scene)) : undefined; + } catch { + return undefined; + } + }, }; return { picker, destroy() {} }; } diff --git a/src/globe/cesium/picker.test.mjs b/src/globe/cesium/picker.test.mjs index 5286b7d58..8d5eff284 100644 --- a/src/globe/cesium/picker.test.mjs +++ b/src/globe/cesium/picker.test.mjs @@ -92,3 +92,24 @@ test('pick results carry 3D tile content for surface checks', () => { scene.pick = () => ({ id: 'entity', primitive: {} }); assert.equal(picker.pick(geo.cartesian2.create(1, 1)).content, undefined); }); + +test('pickGlobe asks the globe along the camera pick ray and returns geo', () => { + const { viewer, picker, scene } = setup(); + const hit = picker.pickGlobe(geo.cartesian2.create(3, 4)); + assert.ok(!(hit instanceof Cesium.Cartesian3)); + assert.deepEqual([hit.x, hit.y, hit.z], [6378137, 3, 4]); + const [screen] = viewer.callsOf('camera.getPickRay')[0].args; + assert.ok(screen instanceof Cesium.Cartesian2); + assert.equal(screen.toString(), '(3, 4)'); + const [ray, pickedScene] = viewer.callsOf('globe.pick')[0].args; + assert.ok(ray instanceof Cesium.Ray); + assert.equal(pickedScene, scene); + scene.globe.pick = () => undefined; + assert.equal(picker.pickGlobe(geo.cartesian2.create(1, 1)), undefined); + scene.globe.pick = () => { + throw new Error('no ray'); + }; + assert.equal(picker.pickGlobe(geo.cartesian2.create(1, 1)), undefined); + scene.globe = undefined; + assert.equal(picker.pickGlobe(geo.cartesian2.create(1, 1)), undefined); +}); diff --git a/src/globe/cesium/testing/fakeViewer.mjs b/src/globe/cesium/testing/fakeViewer.mjs index 18fbd5048..9459ef87c 100644 --- a/src/globe/cesium/testing/fakeViewer.mjs +++ b/src/globe/cesium/testing/fakeViewer.mjs @@ -107,6 +107,7 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) { show: true, tilesLoaded: true, getHeight: record('globe.getHeight', () => 12.5), + pick: record('globe.pick', () => new Cesium.Cartesian3(6378137, 3, 4)), }; const scene = { camera, @@ -163,6 +164,7 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) { creditDisplay, staticCredits, primitiveList, + trackedEntityChanged: new Cesium.Event(), imageryList, terrainProvider: undefined, imageryLayers: { diff --git a/src/globe/contract.js b/src/globe/contract.js index 237bd2522..068071ab1 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -40,6 +40,8 @@ * @property {Cartesian3} up * @property {Cartesian3} right direction × up * @property {number} fovy Vertical field of view. + * @property {number} aspectRatio The view's width over its height (Cesium: + * the frustum's, from the drawing buffer). * * @typedef {{ heading: number, pitch: number, roll: number } * | { direction: Cartesian3, up: Cartesian3 }} Orientation @@ -117,6 +119,11 @@ * @property {EngineEvent} moveEnd Fires once the camera has been still for a * while (Cesium: scene.cameraEventWaitTime, 500 ms), so a move that follows * within that time continues the same movement and raises no new moveStart. + * @property {EngineEvent} commanded Fires (no arguments) when a command, not a + * user gesture, is about to take the camera: setView, flyTo, flyToSphere, + * lookAt and cancelFlight. Cesium also raises it for those calls made on the + * viewer's own camera and when the viewer's tracked entity changes, so a + * pending correction yields to every new camera owner. * @property {number} percentageChanged * * @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType @@ -196,7 +203,10 @@ * @property {'cesium'|'navara'} kind * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host * @property {GlobeCamera} camera - * @property {{ pick: (screen: Cartesian2, width?: number, height?: number) => (PickResult | undefined), drillPick: (screen: Cartesian2, limit?: number, width?: number, height?: number) => PickResult[], pickPosition: (screen: Cartesian2) => (Cartesian3 | undefined), pickPositionSupported: boolean }} picker + * @property {{ pick: (screen: Cartesian2, width?: number, height?: number) => (PickResult | undefined), drillPick: (screen: Cartesian2, limit?: number, width?: number, height?: number) => PickResult[], pickPosition: (screen: Cartesian2) => (Cartesian3 | undefined), pickPositionSupported: boolean, pickGlobe: (screen: Cartesian2) => (Cartesian3 | undefined) }} picker + * pickGlobe: where the ray through a window position meets the globe + * surface (terrain, not tiles or models; Cesium's globe.pick of the camera's + * pick ray); an engine without a terrain surface answers the ellipsoid. * @property {GlobeProjector} projector * @property {{ preUpdate: EngineEvent, preRender: EngineEvent, postRender: EngineEvent }} frames Listeners receive a timestamp in ms. * @property {GlobeRender} render @@ -207,6 +217,7 @@ * @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits * @property {GlobeMaps} maps * @property {() => void} destroy + * @property {() => boolean} isDestroyed True once destroy() has run. */ export const ENGINE_KINDS = Object.freeze(['cesium', 'navara']); @@ -345,6 +356,7 @@ const SHAPE = { changed: isEngineEvent, moveStart: isEngineEvent, moveEnd: isEngineEvent, + commanded: isEngineEvent, percentageChanged: isNumber, }, picker: { @@ -352,6 +364,7 @@ const SHAPE = { drillPick: isFunction, pickPosition: isFunction, pickPositionSupported: isBoolean, + pickGlobe: isFunction, }, projector: { worldToWindow: isFunction, @@ -396,6 +409,7 @@ export const GLOBE_ENGINE_MEMBERS = Object.freeze([ Object.keys(members).map((member) => `${capability}.${member}`), ), 'destroy', + 'isDestroyed', ]); /** @@ -425,6 +439,7 @@ export function assertGlobeEngine(engine) { if (!valid(value[member])) problems.push(`${capability}.${member}`); } if (!isFunction(engine.destroy)) problems.push('destroy'); + if (!isFunction(engine.isDestroyed)) problems.push('isDestroyed'); if (problems.length) throw new TypeError( `GlobeEngine is missing or mistypes: ${problems.join(', ')}`, diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 364ab00a1..e933a4dff 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -206,6 +206,22 @@ test('the contract carries what the shell infrastructure needs', () => { ); }); +test('the contract carries what shell navigation needs', () => { + for (const member of ['camera.commanded', 'picker.pickGlobe', 'isDestroyed']) + assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); + const engine = createFakeEngine(); + const broken = { + ...engine, + camera: { ...engine.camera, commanded: {} }, + picker: { ...engine.picker, pickGlobe: undefined }, + isDestroyed: false, + }; + assert.throws( + () => assertGlobeEngine(broken), + /camera\.commanded.*picker\.pickGlobe.*isDestroyed/, + ); +}); + test('the fake engine passes the engine contract suite', async (t) => { const engine = createFakeEngine(); const checks = createContractSuite(engine, { diff --git a/src/globe/navara/cameraModel.js b/src/globe/navara/cameraModel.js index 0d086f3db..ec6ad9601 100644 --- a/src/globe/navara/cameraModel.js +++ b/src/globe/navara/cameraModel.js @@ -97,7 +97,10 @@ export function createCameraModel({ changed: createEngineEvent(), moveStart: createEngineEvent(), moveEnd: createEngineEvent(), + commanded: createEngineEvent(), }; + // A camera command takes the camera from whoever held it; say so first. + const announce = () => events.commanded.raiseEvent(); const initial = port.read(); let state = initial ? copyPose(initial) @@ -295,19 +298,23 @@ export function createCameraModel({ cartesian3.create(), ), fovy: current.fovy, + aspectRatio: current.width / current.height, }; }, setView(options) { if (!options?.destination) throw new TypeError('setView needs a destination'); + announce(); place(viewFor(options.destination, options.orientation)); }, flyTo(options) { if (!options?.destination) throw new TypeError('flyTo needs a destination'); + announce(); startFlight(options, viewFor(options.destination, options.orientation)); }, flyToSphere(sphere, options = {}) { + announce(); transform = [...matrix4.IDENTITY]; const { width, height } = port.size(); const range = @@ -328,6 +335,7 @@ export function createCameraModel({ }, flightActive: () => flight != null, lookAt(target, offset) { + announce(); camera.lookAtTransform( geo.transforms.eastNorthUpToFixedFrame(target, WGS84), offset, @@ -342,6 +350,7 @@ export function createCameraModel({ transform = [...matrix4.IDENTITY]; }, cancelFlight() { + announce(); cancelCurrentFlight(); }, zoomIn(amount = DEFAULT_ZOOM_AMOUNT) { @@ -367,6 +376,7 @@ export function createCameraModel({ changed: events.changed, moveStart: events.moveStart, moveEnd: events.moveEnd, + commanded: events.commanded, get percentageChanged() { return percentageChanged; }, diff --git a/src/globe/navara/cameraModel.test.mjs b/src/globe/navara/cameraModel.test.mjs index c47718bb4..7ccd12cc0 100644 --- a/src/globe/navara/cameraModel.test.mjs +++ b/src/globe/navara/cameraModel.test.mjs @@ -457,3 +457,33 @@ test('livePosition is one engine-owned object holding the current position; heig const pose = camera.pose(); assert.notEqual(pose.position, live, 'pose shares the live object'); }); + +test('commanded fires before each camera command and never for reads', () => { + const { camera } = setup(); + let raised = 0; + const remove = camera.commanded.addEventListener(() => raised++); + assert.ok(isEngineEvent(camera.commanded)); + camera.pose(); + camera.height(); + camera.viewRectangle(); + assert.equal(raised, 0); + const target = cartesian3.fromDegrees(10, 10, 0); + camera.setView({ destination: cartesian3.fromDegrees(10, 10, 1e6) }); + camera.flyTo({ destination: cartesian3.fromDegrees(11, 10, 1e6), duration: 0 }); + camera.flyToSphere({ center: target, radius: 10 }, { duration: 0 }); + camera.lookAt(target, { heading: 0, pitch: -1, range: 500 }); + camera.cancelFlight(); + assert.equal(raised, 5); + camera.release(); + camera.zoomIn(10); + assert.equal(raised, 5, 'release and zoom are not new owners'); + remove(); + camera.setView({ destination: cartesian3.fromDegrees(10, 10, 1e6) }); + assert.equal(raised, 5); +}); + +test('pose carries the view aspect ratio', () => { + const { camera, port } = setup(); + const { width, height } = port.size(); + assert.equal(camera.pose().aspectRatio, width / height); +}); diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index c80fc132f..22b9bcb8a 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -31,6 +31,31 @@ function readCamera(raw) { }; } +// Nearest hit of a ray with the WGS84 ellipsoid raised by `height` metres +// (each radius grown by it), or undefined. +function rayRaisedEllipsoid({ origin, direction }, height) { + const radii = geo.ellipsoid.WGS84.radii; + const rx = radii.x + height; + const ry = radii.y + height; + const rz = radii.z + height; + const o = [origin.x / rx, origin.y / ry, origin.z / rz]; + const d = [direction.x / rx, direction.y / ry, direction.z / rz]; + const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + const b = 2 * (o[0] * d[0] + o[1] * d[1] + o[2] * d[2]); + const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1; + const discriminant = b * b - 4 * a * c; + if (discriminant < 0) return undefined; + const root = Math.sqrt(discriminant); + const near = (-b - root) / (2 * a); + const t = near > 0 ? near : (-b + root) / (2 * a); + if (!(t > 0)) return undefined; + return cartesian3.add( + origin, + cartesian3.multiplyByScalar(direction, t, cartesian3.create()), + cartesian3.create(), + ); +} + /** * @param {object} view An initialised Navara ThreeView. * @param {{ @@ -115,6 +140,26 @@ export function createNavaraEngine( return hit ? cartesian3.create(hit.x, hit.y, hit.z) : undefined; }, pickPositionSupported: true, + // The globe under a window position: the pick ray's ellipsoid hit, moved + // onto Navara's terrain (a few fixed-point steps; terrain varies slowly + // over the distance the hit moves). + pickGlobe(screen) { + let hit = model.camera.pickEllipsoid(screen); + if (!hit) return undefined; + const ray = model.camera.pickRay(screen); + for (let step = 0; step < 4; step++) { + const height = surface.terrainHeight( + geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84), + ); + if (!Number.isFinite(height)) break; + const next = rayRaisedEllipsoid(ray, height); + if (!next) break; + const moved = cartesian3.distance(next, hit); + hit = next; + if (moved < 0.01) break; + } + return hit; + }, }; let destroyed = false; @@ -151,5 +196,6 @@ export function createNavaraEngine( credits.destroy(); maps.destroy(); }, + isDestroyed: () => destroyed, }; } diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index f824f56b1..47adf2f6f 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -205,3 +205,24 @@ function near(actual, expected, tolerance, label) { `${label}: ${actual} is not within ${tolerance} of ${expected}`, ); } + +test('isDestroyed follows destroy', () => { + const { engine } = setup(); + assert.equal(engine.isDestroyed(), false); + engine.destroy(); + assert.equal(engine.isDestroyed(), true); +}); + +test('pickGlobe meets the terrain along the pick ray, or the ellipsoid', () => { + const { engine, view } = setup(); + const centre = cartesian2.create(640, 400); + const bare = engine.picker.pickGlobe(centre); + const ellipsoid = engine.camera.pickEllipsoid(centre); + near(cartesian3.distance(bare, ellipsoid), 0, 1e-6, 'no terrain: ellipsoid'); + view.sampleTerrainHeight = () => 2000; + const raised = engine.picker.pickGlobe(centre); + const place = geo.cartographic.fromCartesian(raised); + near(place.height, 2000, 0.5, 'terrain height (m)'); + near(math.toDegrees(place.longitude), 0, 1e-6, 'longitude'); + assert.equal(engine.picker.pickGlobe(cartesian2.create(640, -1e7)), undefined); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index d4cf4b74d..f0e0488b8 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -1380,6 +1380,100 @@ export function createContractSuite( ); }, ], + [ + 'isDestroyed: false while the engine lives', + async () => { + ensure(engine.isDestroyed() === false, 'a live engine is destroyed'); + }, + ], + [ + 'camera.pose: aspectRatio is the view width over its height', + async () => { + await nadirView(); + const { width, height } = engine.host.size(); + // Cesium's frustum uses the drawing buffer, rounded to device pixels. + near(camera.pose().aspectRatio, width / height, 0.01, 'aspectRatio'); + }, + ], + [ + 'picker.pickGlobe: the nadir hits the globe and the sky misses', + async () => { + await nadirView(); + const hit = engine.picker.pickGlobe(centre()); + ensure(isCartesian3(hit), 'the nadir missed the globe'); + const point = cartographic.fromCartesian(hit, WGS84); + // Terrain, when shown, stays within Earth's relief of the ellipsoid. + near(point.height, 0, 9000, 'globe height (m)'); + near(math.toDegrees(point.longitude), 20, 0.05, 'longitude (deg)'); + near(math.toDegrees(point.latitude), 10, 0.05, 'latitude (deg)'); + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 1e6), + orientation: { heading: 0, pitch: 0.6, roll: 0 }, + }); + await settle(); + ensure( + engine.picker.pickGlobe(centre()) === undefined, + 'the sky hit the globe', + ); + }, + ], + [ + 'camera.commanded: fires for each camera command, never for reads', + async () => { + await nadirView(); + let raised = 0; + const remove = camera.commanded.addEventListener(() => raised++); + const target = cartesian3.fromDegrees(20, 10, 0); + const commands = [ + [ + 'setView', + () => + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 2e6), + }), + ], + [ + 'flyTo', + () => + camera.flyTo({ + destination: cartesian3.fromDegrees(21, 10, 2e6), + duration: 0, + }), + ], + [ + 'flyToSphere', + () => + camera.flyToSphere( + { center: target, radius: 100 }, + { duration: 0 }, + ), + ], + [ + 'lookAt', + () => camera.lookAt(target, { heading: 0, pitch: -1, range: 5e3 }), + ], + ['cancelFlight', () => camera.cancelFlight()], + ]; + try { + camera.pose(); + camera.height(); + camera.viewRectangle(); + camera.pickEllipsoid(centre()); + ensure(raised === 0, `reads raised camera.commanded ${raised}x`); + for (const [name, command] of commands) { + const before = raised; + command(); + ensure(raised > before, `${name} did not raise camera.commanded`); + } + camera.release(); + } finally { + remove(); + } + const count = raised; + camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) }); + ensure(raised === count, 'a removed listener was called'); + }, + ], ]; if (mapSources?.photoreal && mapSources.imagery?.length >= 2) checks.push(...mapChecks(engine.maps, mapSources)); diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 54b66bb53..a93d827e2 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -35,6 +35,7 @@ const SCRIPTABLE = new Set([ 'pick', 'drillPick', 'pickPosition', + 'pickGlobe', 'pickEllipsoid', 'viewRectangle', 'sampleHeight', @@ -235,6 +236,7 @@ export function createFakeEngine(options = {}) { 'camera.changed': createEngineEvent(), 'camera.moveStart': createEngineEvent(), 'camera.moveEnd': createEngineEvent(), + 'camera.commanded': createEngineEvent(), 'frames.preUpdate': createEngineEvent(), 'frames.preRender': createEngineEvent(), 'frames.postRender': createEngineEvent(), @@ -262,6 +264,11 @@ export function createFakeEngine(options = {}) { destroyed: false, }; const record = (method, args) => fake.calls.push({ method, args }); + // A camera command: recorded, then announced before it runs. + const command = (method, args) => { + record(method, args); + events['camera.commanded'].raiseEvent(); + }; const aspect = () => width / height; const right = () => unit(cross(state.direction, state.up)); @@ -604,6 +611,7 @@ export function createFakeEngine(options = {}) { up: copy3(state.up), right: right(), fovy, + aspectRatio: aspect(), }; }, get livePosition() { @@ -611,13 +619,13 @@ export function createFakeEngine(options = {}) { }, height: () => cartographic.fromCartesian(state.position, WGS84).height, setView(view) { - record('camera.setView', [view]); + command('camera.setView', [view]); if (!view?.destination) throw new TypeError('setView needs a destination'); apply(viewFor(view.destination, view.orientation, CESIUM_DEFAULTS)); }, flyTo(flightOptions) { - record('camera.flyTo', [flightOptions]); + command('camera.flyTo', [flightOptions]); if (!flightOptions?.destination) throw new TypeError('flyTo needs a destination'); startFlight( @@ -630,7 +638,7 @@ export function createFakeEngine(options = {}) { ); }, flyToSphere(sphere, sphereOptions = {}) { - record('camera.flyToSphere', [sphere, sphereOptions]); + command('camera.flyToSphere', [sphere, sphereOptions]); setTransform(matrix4.IDENTITY); let range = sphereOptions.range; if (!range) { @@ -669,7 +677,7 @@ export function createFakeEngine(options = {}) { }, flightActive: () => flight != null, lookAt(target, offset) { - record('camera.lookAt', [target, offset]); + command('camera.lookAt', [target, offset]); const frame = geo.transforms.eastNorthUpToFixedFrame(target, WGS84); setTransform(frame, target); lookAtFrame(frame, offset); @@ -685,7 +693,7 @@ export function createFakeEngine(options = {}) { setTransform(matrix4.IDENTITY); }, cancelFlight() { - record('camera.cancelFlight', []); + command('camera.cancelFlight', []); cancelCurrentFlight(); }, zoomIn(amount = DEFAULT_ZOOM_AMOUNT) { @@ -711,6 +719,7 @@ export function createFakeEngine(options = {}) { changed: events['camera.changed'], moveStart: events['camera.moveStart'], moveEnd: events['camera.moveEnd'], + commanded: events['camera.commanded'], get percentageChanged() { return percentageChanged; }, @@ -745,6 +754,12 @@ export function createFakeEngine(options = {}) { ); }, pickPositionSupported: true, + pickGlobe(screen) { + record('picker.pickGlobe', [screen]); + return scripted('pickGlobe', [screen], () => + camera.pickEllipsoid(screen), + ); + }, }, projector: { worldToWindow(position, result) { @@ -857,6 +872,7 @@ export function createFakeEngine(options = {}) { cancelCurrentFlight(); fake.destroyed = true; }, + isDestroyed: () => fake.destroyed, fake, }; diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index 3b2ad1808..1f845c931 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -378,3 +378,22 @@ test('fake host elements can be supplied', () => { kind: 'fake-container', }); }); + +test('fake isDestroyed, commanded and pickGlobe are inspectable', () => { + const engine = createFakeEngine(); + let commanded = 0; + engine.camera.commanded.addEventListener(() => commanded++); + engine.fake.emit('camera.commanded'); + engine.camera.cancelFlight(); + assert.equal(commanded, 2); + engine.fake.script('pickGlobe', { x: 1, y: 2, z: 3 }); + assert.deepEqual(engine.picker.pickGlobe({ x: 0, y: 0 }), { + x: 1, + y: 2, + z: 3, + }); + assert.equal(engine.fake.callsOf('picker.pickGlobe').length, 1); + assert.equal(engine.isDestroyed(), false); + engine.destroy(); + assert.equal(engine.isDestroyed(), true); +});