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); +});