diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7ebdd16c9..e49c8fe39 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -721,6 +721,19 @@ "src/tooling/importDirections.test.mjs", "src/globe/contract.test.mjs", "src/globe/testing/fakeEngine.test.mjs", + "src/globe/cesium/events.test.mjs", + "src/globe/cesium/camera.test.mjs", + "src/globe/cesium/projector.test.mjs", + "src/globe/cesium/picker.test.mjs", + "src/globe/cesium/frames.test.mjs", + "src/globe/cesium/render.test.mjs", + "src/globe/cesium/surface.test.mjs", + "src/globe/cesium/input.test.mjs", + "src/globe/cesium/credits.test.mjs", + "src/globe/cesium/maps.test.mjs", + "src/globe/cesium/createCesiumEngine.test.mjs", + "src/globe/cesium/testing/fakeViewer.mjs", + "scripts/qa-globe-contract.mjs", "src/sources/reference.js", "src/sources/reference.test.mjs", "src/search/nominatim.js", diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js new file mode 100644 index 000000000..ca8e714a6 --- /dev/null +++ b/src/globe/cesium/camera.js @@ -0,0 +1,208 @@ +// GlobeCamera on a Cesium camera. Geo values in, Cesium values into Cesium +// calls, fresh geo values out. + +import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { bridgeEvent } from './events.js'; +import { toCesium2, toCesium3, toGeo3 } from './convert.js'; + +const { math } = geo; +const MINIMUM_SPHERE_RANGE = 100; // Cesium's MINIMUM_ZOOM for flyToBoundingSphere +const EASINGS = { + cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT, + sinusoidalInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT, + linear: Cesium.EasingFunction.LINEAR_NONE, +}; + +// Cesium reports heading and roll in [0, 2π]; the contract wants [0, 2π). +function wrapAngle(angle) { + const value = math.zeroToTwoPi(angle); + return value < 1e-12 || math.TWO_PI - value < 1e-12 ? 0 : value; +} + +function cesiumOrientation(orientation) { + if (!orientation) return undefined; + if (orientation.direction) + return { + direction: toCesium3(orientation.direction), + up: toCesium3(orientation.up), + }; + const { heading, pitch, roll } = orientation; + return { heading, pitch, roll }; +} + +// A HeadingPitchRange or a Cartesian offset, as Cesium's lookAt takes them. +const cesiumOffset = (offset) => + 'heading' in offset + ? new Cesium.HeadingPitchRange(offset.heading, offset.pitch, offset.range) + : toCesium3(offset); + +/** + * @param {Cesium.Viewer} viewer + * @returns {{ camera: import('../contract.js').GlobeCamera, destroy: () => void }} + */ +export function createCesiumCamera(viewer) { + const cesium = viewer.scene.camera; + const bridges = { + changed: bridgeEvent(cesium.changed), + moveStart: bridgeEvent(cesium.moveStart), + moveEnd: bridgeEvent(cesium.moveEnd), + }; + + // The engine's own flight, so flightActive is exact even for flights that + // completed synchronously (Cesium keeps no current flight for those). + let flight = null; + function trackFlight(options) { + const token = {}; + flight = token; + const end = () => { + if (flight === token) flight = null; + }; + return { + end, + complete: () => { + end(); + options.complete?.(); + }, + cancel: () => { + end(); + options.cancel?.(); + }, + }; + } + function startFlight(options, start) { + const tracked = trackFlight(options); + try { + start(tracked); + } catch (error) { + tracked.end(); + throw error; + } + } + + const camera = { + pose() { + const position = toGeo3(cesium.positionWC); + return { + position, + cartographic: geo.cartographic.fromCartesian( + position, + geo.ellipsoid.WGS84, + ), + heading: wrapAngle(cesium.heading), + pitch: cesium.pitch, + roll: wrapAngle(cesium.roll), + direction: toGeo3(cesium.directionWC), + up: toGeo3(cesium.upWC), + right: toGeo3(cesium.rightWC), + fovy: cesium.frustum.fovy, + }; + }, + setView(view) { + if (!view?.destination) + throw new TypeError('setView needs a destination'); + cesium.setView({ + destination: toCesium3(view.destination), + orientation: cesiumOrientation(view.orientation), + }); + }, + flyTo(options) { + if (!options?.destination) + throw new TypeError('flyTo needs a destination'); + startFlight(options, ({ complete, cancel }) => + cesium.flyTo({ + destination: toCesium3(options.destination), + orientation: cesiumOrientation(options.orientation), + duration: options.duration, + easingFunction: options.easing ? EASINGS[options.easing] : undefined, + complete, + cancel, + }), + ); + }, + flyToSphere(sphere, options = {}) { + let range = options.range; + if (!range) { + const { fovy, aspectRatio } = cesium.frustum; + const tanPhi = Math.tan(fovy / 2); + const tanTheta = aspectRatio * tanPhi; + range = Math.max( + MINIMUM_SPHERE_RANGE, + sphere.radius / tanTheta, + sphere.radius / tanPhi, + ); + } + startFlight(options, ({ complete, cancel }) => + cesium.flyToBoundingSphere( + new Cesium.BoundingSphere(toCesium3(sphere.center), sphere.radius), + { + offset: new Cesium.HeadingPitchRange( + options.heading ?? 0, + options.pitch ?? -Math.PI / 4, + range, + ), + duration: options.duration, + complete, + cancel, + }, + ), + ); + }, + flightActive: () => flight != null, + lookAt(target, offset) { + cesium.lookAt(toCesium3(target), cesiumOffset(offset)); + }, + lookAtTransform(transform, offset) { + const matrix = Cesium.Matrix4.fromArray(transform); + if (offset) cesium.lookAtTransform(matrix, cesiumOffset(offset)); + else cesium.lookAtTransform(matrix); + }, + transform: () => Cesium.Matrix4.toArray(cesium.transform), + release() { + cesium.lookAtTransform(Cesium.Matrix4.IDENTITY); + }, + cancelFlight() { + cesium.cancelFlight(); + flight = null; + }, + zoomIn(amount = cesium.defaultZoomAmount) { + cesium.zoomIn(amount); + }, + zoomOut(amount = cesium.defaultZoomAmount) { + cesium.zoomOut(amount); + }, + viewRectangle() { + const rectangle = cesium.computeViewRectangle(Cesium.Ellipsoid.WGS84); + if (!rectangle) return undefined; + const { west, south, east, north } = rectangle; + return { west, south, east, north }; + }, + pickEllipsoid(screen) { + return toGeo3( + cesium.pickEllipsoid(toCesium2(screen), Cesium.Ellipsoid.WGS84), + ); + }, + pickRay(screen) { + const ray = cesium.getPickRay(toCesium2(screen)); + if (!ray) return undefined; + return { origin: toGeo3(ray.origin), direction: toGeo3(ray.direction) }; + }, + changed: bridges.changed.event, + moveStart: bridges.moveStart.event, + moveEnd: bridges.moveEnd.event, + get percentageChanged() { + return cesium.percentageChanged; + }, + set percentageChanged(value) { + cesium.percentageChanged = value; + }, + }; + + return { + camera, + destroy() { + if (flight) camera.cancelFlight(); + for (const bridge of Object.values(bridges)) bridge.detach(); + }, + }; +} diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs new file mode 100644 index 000000000..23b57aad7 --- /dev/null +++ b/src/globe/cesium/camera.test.mjs @@ -0,0 +1,307 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { isEngineEvent } from '../contract.js'; +import { createCesiumCamera } from './camera.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +const notCesium = (value, label) => { + assert.ok(value != null, `${label} is missing`); + for (const type of [ + Cesium.Cartesian2, + Cesium.Cartesian3, + Cesium.Cartographic, + Cesium.Matrix4, + Cesium.Rectangle, + Cesium.Ray, + ]) + assert.ok(!(value instanceof type), `${label} leaks a Cesium ${type.name}`); +}; + +function setup() { + const viewer = createFakeViewer(); + const { camera, destroy } = createCesiumCamera(viewer); + return { viewer, camera, destroy, cesium: viewer.camera }; +} + +test('pose returns fresh geo values and wraps 2π angles to 0', () => { + const { camera, cesium } = setup(); + const pose = camera.pose(); + for (const name of ['position', 'cartographic', 'direction', 'up', 'right']) + notCesium(pose[name], name); + assert.deepEqual( + [pose.position.x, pose.position.y, pose.position.z], + [7e6, 1e5, 2e5], + ); + assert.notEqual(pose.position, cesium.positionWC); + const expected = geo.cartographic.fromCartesian( + pose.position, + geo.ellipsoid.WGS84, + ); + assert.equal(pose.cartographic.longitude, expected.longitude); + assert.equal(pose.cartographic.height, expected.height); + assert.equal(pose.heading, 0); + assert.equal(pose.roll, 0); + assert.equal(pose.pitch, -Math.PI / 2); + assert.equal(pose.fovy, cesium.frustum.fovy); + assert.deepEqual([pose.right.x, pose.right.y, pose.right.z], [0, 1, 0]); + cesium.heading = 1.25; + cesium.roll = 6; + assert.equal(camera.pose().heading, 1.25); + assert.equal(camera.pose().roll, 6); + pose.position.x = 0; + assert.equal(cesium.positionWC.x, 7e6, 'pose values are copies'); +}); + +test('setView converts the destination and passes the orientation through', () => { + const { viewer, camera } = setup(); + const destination = geo.cartesian3.fromDegrees(10, 20, 3000); + camera.setView({ + destination, + orientation: { heading: 1, pitch: -0.5, roll: 0.1 }, + }); + const [options] = viewer.callsOf('camera.setView')[0].args; + assert.ok(options.destination instanceof Cesium.Cartesian3); + assert.equal(options.destination.x, destination.x); + assert.deepEqual(options.orientation, { heading: 1, pitch: -0.5, roll: 0.1 }); + + const direction = geo.cartesian3.create(-1, 0, 0); + const up = geo.cartesian3.create(0, 0, 1); + camera.setView({ destination, orientation: { direction, up } }); + const [vectors] = viewer.callsOf('camera.setView')[1].args; + assert.ok(vectors.orientation.direction instanceof Cesium.Cartesian3); + assert.ok(vectors.orientation.up instanceof Cesium.Cartesian3); + + camera.setView({ destination }); + const [bare] = viewer.callsOf('camera.setView')[2].args; + assert.equal(bare.orientation, undefined, "Cesium's own defaults apply"); + assert.throws(() => camera.setView({}), TypeError); +}); + +test('flyTo maps easing, duration and callbacks and tracks the flight', () => { + const { viewer, camera, cesium } = setup(); + const outcome = []; + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(0, 0, 1e6), + orientation: { heading: 0.5, pitch: -0.8, roll: 0 }, + duration: 4, + easing: 'linear', + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + const [options] = viewer.callsOf('camera.flyTo')[0].args; + assert.ok(options.destination instanceof Cesium.Cartesian3); + assert.equal(options.duration, 4); + assert.equal(options.easingFunction, Cesium.EasingFunction.LINEAR_NONE); + assert.deepEqual(options.orientation, { heading: 0.5, pitch: -0.8, roll: 0 }); + assert.equal(camera.flightActive(), true); + cesium.completeFlight(); + assert.equal(camera.flightActive(), false); + assert.deepEqual(outcome, ['complete']); + + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(0, 0, 1e6), + easing: 'sinusoidalInOut', + cancel: () => outcome.push('cancel'), + }); + assert.equal( + viewer.callsOf('camera.flyTo')[1].args[0].easingFunction, + Cesium.EasingFunction.SINUSOIDAL_IN_OUT, + ); + camera.cancelFlight(); + assert.equal(camera.flightActive(), false); + assert.deepEqual(outcome, ['complete', 'cancel']); + assert.equal(viewer.callsOf('camera.cancelFlight').length, 1); +}); + +test('flyTo with duration 0 completes before it returns and leaves no flight', () => { + const { camera } = setup(); + let done = false; + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(0, 0, 1e6), + duration: 0, + complete: () => (done = true), + }); + assert.equal(done, true); + assert.equal(camera.flightActive(), false); +}); + +test('a newer flight cancels the previous one and stays active', () => { + const { viewer, camera } = setup(); + const outcome = []; + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(0, 0, 1e6), + duration: 5, + cancel: () => outcome.push('first cancel'), + }); + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(1, 0, 1e6), + duration: 5, + }); + assert.deepEqual(outcome, ['first cancel']); + assert.equal(camera.flightActive(), true); + assert.equal( + viewer.callsOf('camera.flyTo')[1].args[0].easingFunction, + undefined, + "without an easing Cesium's own flight easing applies", + ); +}); + +test('flyToSphere builds a BoundingSphere and an offset with Cesium defaults', () => { + const { viewer, camera, cesium } = setup(); + const center = geo.cartesian3.fromDegrees(2.35, 48.85, 100); + let done = false; + camera.flyToSphere( + { center, radius: 1000 }, + { duration: 0, complete: () => (done = true) }, + ); + assert.equal(done, true); + assert.equal(camera.flightActive(), false); + const [sphere, options] = viewer.callsOf('camera.flyToBoundingSphere')[0] + .args; + assert.ok(sphere instanceof Cesium.BoundingSphere); + assert.equal(sphere.center.x, center.x); + assert.equal(sphere.radius, 1000); + assert.ok(options.offset instanceof Cesium.HeadingPitchRange); + assert.equal(options.offset.heading, 0); + assert.equal(options.offset.pitch, -Math.PI / 4); + const tanPhi = Math.tan(cesium.frustum.fovy / 2); + const tanTheta = cesium.frustum.aspectRatio * tanPhi; + assert.equal( + options.offset.range, + Math.max(100, 1000 / tanTheta, 1000 / tanPhi), + ); + assert.equal(options.duration, 0); + + camera.flyToSphere({ center, radius: 0 }, { duration: 0 }); + assert.equal( + viewer.callsOf('camera.flyToBoundingSphere')[1].args[1].offset.range, + 100, + ); + camera.flyToSphere( + { center, radius: 10 }, + { heading: 0.3, pitch: -0.5, range: 5000, duration: 2 }, + ); + const explicit = viewer.callsOf('camera.flyToBoundingSphere')[2].args[1]; + assert.deepEqual( + [explicit.offset.heading, explicit.offset.pitch, explicit.offset.range], + [0.3, -0.5, 5000], + ); + assert.equal(camera.flightActive(), true); +}); + +test('lookAt, lookAtTransform, transform and release cross the boundary', () => { + const { viewer, camera, cesium } = setup(); + const target = geo.cartesian3.fromDegrees(-0.12, 51.5, 0); + camera.lookAt(target, { heading: 0.4, pitch: -0.7, range: 2000 }); + const [cesiumTarget, offset] = viewer.callsOf('camera.lookAt')[0].args; + assert.ok(cesiumTarget instanceof Cesium.Cartesian3); + assert.equal(cesiumTarget.y, target.y); + assert.ok(offset instanceof Cesium.HeadingPitchRange); + assert.deepEqual( + [offset.heading, offset.pitch, offset.range], + [0.4, -0.7, 2000], + ); + + const frame = geo.transforms.eastNorthUpToFixedFrame( + target, + geo.ellipsoid.WGS84, + ); + camera.lookAtTransform(frame, { heading: 1, pitch: -1, range: 10 }); + let [matrix, hpr] = viewer.callsOf('camera.lookAtTransform')[0].args; + assert.ok(matrix instanceof Cesium.Matrix4); + assert.deepEqual(Cesium.Matrix4.toArray(matrix), [...frame]); + assert.ok(hpr instanceof Cesium.HeadingPitchRange); + + camera.lookAtTransform(frame, geo.cartesian3.create(0, -1000, 1000)); + [matrix, hpr] = viewer.callsOf('camera.lookAtTransform')[1].args; + assert.ok(hpr instanceof Cesium.Cartesian3); + assert.deepEqual([hpr.x, hpr.y, hpr.z], [0, -1000, 1000]); + + camera.lookAtTransform(frame); + assert.equal(viewer.callsOf('camera.lookAtTransform')[2].args.length, 1); + + const held = camera.transform(); + assert.ok(Array.isArray(held)); + notCesium(held, 'transform'); + assert.deepEqual(held, [...frame]); + held[0] = 99; + assert.notEqual(cesium.transform[0], 99, 'transform returns a copy'); + + camera.release(); + const [identity] = viewer.callsOf('camera.lookAtTransform')[3].args; + assert.equal(identity, Cesium.Matrix4.IDENTITY); +}); + +test('zoomIn and zoomOut default to Cesium amount', () => { + const { viewer, camera } = setup(); + camera.zoomIn(2000); + camera.zoomOut(); + assert.deepEqual(viewer.callsOf('camera.zoomIn')[0].args, [2000]); + assert.deepEqual(viewer.callsOf('camera.zoomOut')[0].args, [100000]); +}); + +test('viewRectangle, pickEllipsoid and pickRay return geo values', () => { + const { viewer, camera, cesium } = setup(); + const rectangle = camera.viewRectangle(); + notCesium(rectangle, 'viewRectangle'); + assert.deepEqual(rectangle, { west: 0.1, south: 0.2, east: 0.3, north: 0.4 }); + assert.equal( + viewer.callsOf('camera.computeViewRectangle')[0].args[0], + Cesium.Ellipsoid.WGS84, + ); + cesium.computeViewRectangle = () => undefined; + assert.equal(camera.viewRectangle(), undefined); + + const hit = camera.pickEllipsoid(geo.cartesian2.create(10, 20)); + notCesium(hit, 'pickEllipsoid'); + assert.equal(hit.x, 6378137); + const [screen, ellipsoid] = viewer.callsOf('camera.pickEllipsoid')[0].args; + assert.ok(screen instanceof Cesium.Cartesian2); + assert.deepEqual([screen.x, screen.y], [10, 20]); + assert.equal(ellipsoid, Cesium.Ellipsoid.WGS84); + cesium.pickEllipsoid = () => undefined; + assert.equal(camera.pickEllipsoid(geo.cartesian2.create(0, 0)), undefined); + + const ray = camera.pickRay(geo.cartesian2.create(5, 6)); + notCesium(ray.origin, 'pickRay origin'); + notCesium(ray.direction, 'pickRay direction'); + assert.equal(ray.origin.x, 7e6); + assert.equal(ray.direction.x, -1); + assert.ok( + viewer.callsOf('camera.getPickRay')[0].args[0] instanceof Cesium.Cartesian2, + ); +}); + +test('camera events are bridged and percentageChanged reads through', () => { + const { camera, cesium } = setup(); + for (const name of ['changed', 'moveStart', 'moveEnd']) { + assert.ok(isEngineEvent(camera[name]), name); + assert.equal(cesium[name].numberOfListeners, 0, `${name} subscribed early`); + } + const seen = []; + const remove = camera.changed.addEventListener((amount) => seen.push(amount)); + cesium.changed.raiseEvent(0.3); + cesium.moveEnd.raiseEvent(); + assert.deepEqual(seen, [0.3]); + remove(); + assert.equal(cesium.changed.numberOfListeners, 0); + assert.equal(camera.percentageChanged, 0.5); + camera.percentageChanged = 0.1; + assert.equal(cesium.percentageChanged, 0.1); +}); + +test('destroy cancels the engine flight and drops Cesium subscriptions', () => { + const { camera, destroy, cesium } = setup(); + const outcome = []; + camera.changed.addEventListener(() => {}); + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(0, 0, 1e6), + duration: 5, + cancel: () => outcome.push('cancel'), + }); + destroy(); + assert.deepEqual(outcome, ['cancel']); + assert.equal(cesium.changed.numberOfListeners, 0); +}); diff --git a/src/globe/cesium/convert.js b/src/globe/cesium/convert.js new file mode 100644 index 000000000..ddf101376 --- /dev/null +++ b/src/globe/cesium/convert.js @@ -0,0 +1,23 @@ +// The geo ↔ Cesium boundary of the Cesium engine: Cesium values in, fresh +// src/geo values out, so no Cesium instance reaches the shell. + +import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; + +/** A fresh geo Cartesian3 from any {x, y, z}, or undefined. */ +export const toGeo3 = (value) => + value ? geo.cartesian3.create(value.x, value.y, value.z) : undefined; + +/** A fresh geo Cartesian2 from any {x, y}, or undefined. */ +export const toGeo2 = (value) => + value ? geo.cartesian2.create(value.x, value.y) : undefined; + +/** A new Cesium.Cartesian3 for a Cesium call. */ +export const toCesium3 = (value) => + new Cesium.Cartesian3(value.x, value.y, value.z); + +/** + * A new Cesium.Cartesian2 for a window position. Always a new instance: Cesium + * caches pickPosition by windowPosition.toString() (phase 1a rule 9). + */ +export const toCesium2 = (value) => new Cesium.Cartesian2(value.x, value.y); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js new file mode 100644 index 000000000..34ff8d8e5 --- /dev/null +++ b/src/globe/cesium/createCesiumEngine.js @@ -0,0 +1,66 @@ +// The GlobeEngine on an existing Cesium Viewer. Creating it changes nothing +// in the viewer: Cesium subscriptions, the input handler and render holds +// appear only when the shell uses them, and destroy() removes them again +// (the viewer itself stays owned by the app). + +import { createCesiumCamera } from './camera.js'; +import { createCesiumCredits } from './credits.js'; +import { createCesiumFrames } from './frames.js'; +import { createCesiumInput } from './input.js'; +import { createCesiumMaps } from './maps.js'; +import { createCesiumPicker } from './picker.js'; +import { createCesiumProjector } from './projector.js'; +import { createCesiumRender } from './render.js'; +import { createCesiumSurface } from './surface.js'; + +/** + * @param {Cesium.Viewer} viewer + * @param {{ + * tilesetRef?: { current: object | null } | (() => object | null), + * requestRender?: (reason: string) => void, + * createEventHandler?: Function, + * createImageryLayer?: Function, + * loadPhotorealTileset?: Function, + * }} [options] `requestRender` defaults to the render governor; the + * `create*`/`load*` options are test seams. + * @returns {import('../contract.js').GlobeEngine} + */ +export function createCesiumEngine(viewer, options = {}) { + if (!viewer?.scene) throw new TypeError('createCesiumEngine needs a viewer'); + const { scene } = viewer; + const render = createCesiumRender(viewer, options); + const parts = { + camera: createCesiumCamera(viewer), + picker: createCesiumPicker(viewer), + projector: createCesiumProjector(viewer), + frames: createCesiumFrames(viewer), + render, + surface: createCesiumSurface(viewer), + input: createCesiumInput(viewer, options), + credits: createCesiumCredits(viewer), + maps: createCesiumMaps(viewer, { + ...options, + requestRender: () => render.render.request(), + }), + }; + let destroyed = false; + return { + kind: 'cesium', + host: { + container: viewer.container, + canvas: scene.canvas, + size: () => ({ + width: scene.canvas.clientWidth, + height: scene.canvas.clientHeight, + }), + }, + ...Object.fromEntries( + Object.entries(parts).map(([name, part]) => [name, part[name]]), + ), + destroy() { + if (destroyed) return; + destroyed = true; + for (const part of Object.values(parts)) part.destroy(); + }, + }; +} diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs new file mode 100644 index 000000000..d93935c4d --- /dev/null +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { assertGlobeEngine } from '../contract.js'; +import { + _resetRenderGovernorForTest, + getRenderGovernorDiagnostics, + installRenderGovernor, +} from '../../renderGovernor.js'; +import { createCesiumEngine } from './createCesiumEngine.js'; +import { + createFakeEventHandlers, + createFakeViewer, +} from './testing/fakeViewer.mjs'; + +test.afterEach(() => _resetRenderGovernorForTest()); + +function snapshot(viewer) { + const { scene } = viewer; + return { + requestRenderMode: scene.requestRenderMode, + maximumRenderTimeChange: scene.maximumRenderTimeChange, + globeShow: scene.globe.show, + enableInputs: scene.screenSpaceCameraController.enableInputs, + percentageChanged: scene.camera.percentageChanged, + listeners: [ + scene.camera.changed, + scene.camera.moveStart, + scene.camera.moveEnd, + scene.preUpdate, + scene.preRender, + scene.postRender, + ].map((event) => event.numberOfListeners), + }; +} + +test('the engine satisfies the contract shape and changes nothing at creation', () => { + const viewer = createFakeViewer({ width: 800, height: 600 }); + const handlers = createFakeEventHandlers(); + const before = snapshot(viewer); + const engine = createCesiumEngine(viewer, { + createEventHandler: handlers.create, + }); + assertGlobeEngine(engine); + assert.equal(engine.kind, 'cesium'); + assert.deepEqual(viewer.calls, []); + assert.deepEqual(snapshot(viewer), before); + assert.equal(handlers.handlers.length, 0); + assert.equal(engine.host.container, viewer.container); + assert.equal(engine.host.canvas, viewer.scene.canvas); + assert.deepEqual(engine.host.size(), { width: 800, height: 600 }); + viewer.scene.canvas.clientWidth = 640; + assert.deepEqual(engine.host.size(), { width: 640, height: 600 }); +}); + +test('the render option reaches render.request', () => { + const viewer = createFakeViewer(); + const reasons = []; + const engine = createCesiumEngine(viewer, { + requestRender: (reason) => reasons.push(reason), + }); + engine.render.request(); + assert.equal(reasons.length, 1); +}); + +test('destroy releases holds, listeners, handlers and engine-owned map state', async () => { + const viewer = createFakeViewer(); + installRenderGovernor(viewer); + const handlers = createFakeEventHandlers(); + const engine = createCesiumEngine(viewer, { + createEventHandler: handlers.create, + createImageryLayer: (provider) => ({ provider }), + }); + engine.camera.changed.addEventListener(() => {}); + engine.frames.postRender.addEventListener(() => {}); + engine.input.on('click', () => {}); + engine.render.setContinuous(true); + engine.credits.addStatic('credit'); + await engine.maps.setBaseImagery({ kind: 'osm' }); + engine.destroy(); + engine.destroy(); + assert.equal(viewer.scene.camera.changed.numberOfListeners, 0); + assert.equal(viewer.scene.postRender.numberOfListeners, 0); + assert.equal(handlers.handlers[0].destroyed, true); + assert.deepEqual(getRenderGovernorDiagnostics().holds, []); + assert.deepEqual(viewer.staticCredits, []); + assert.deepEqual(viewer.imageryList, []); +}); diff --git a/src/globe/cesium/credits.js b/src/globe/cesium/credits.js new file mode 100644 index 000000000..364247b64 --- /dev/null +++ b/src/globe/cesium/credits.js @@ -0,0 +1,29 @@ +// Static credits on the viewer's credit display. + +import * as Cesium from 'cesium'; + +/** + * @param {Cesium.Viewer} viewer + * @returns {{ credits: { addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }, destroy: () => void }} + */ +export function createCesiumCredits(viewer) { + const added = new Set(); + const display = () => viewer.creditDisplay; + const remove = (credit) => { + if (!added.delete(credit)) return; + display()?.removeStaticCredit(credit); + }; + return { + credits: { + addStatic(html, { onScreen = false } = {}) { + const credit = new Cesium.Credit(html, Boolean(onScreen)); + display().addStaticCredit(credit); + added.add(credit); + return () => remove(credit); + }, + }, + destroy() { + for (const credit of [...added]) remove(credit); + }, + }; +} diff --git a/src/globe/cesium/credits.test.mjs b/src/globe/cesium/credits.test.mjs new file mode 100644 index 000000000..ffb48fdf0 --- /dev/null +++ b/src/globe/cesium/credits.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createCesiumCredits } from './credits.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +test('addStatic adds a Cesium credit and returns an idempotent remover', () => { + const viewer = createFakeViewer(); + const { credits } = createCesiumCredits(viewer); + const remove = credits.addStatic('Source', { onScreen: true }); + assert.equal(viewer.staticCredits.length, 1); + const [credit] = viewer.staticCredits; + assert.ok(credit instanceof Cesium.Credit); + assert.equal(credit.html, 'Source'); + assert.equal(credit.showOnScreen, true); + remove(); + remove(); + assert.equal(viewer.staticCredits.length, 0); + assert.equal(viewer.callsOf('creditDisplay.removeStaticCredit').length, 1); + credits.addStatic('Off screen'); + assert.equal(viewer.staticCredits[0].showOnScreen, false); +}); + +test('destroy removes the credits the engine added', () => { + const viewer = createFakeViewer(); + const { credits, destroy } = createCesiumCredits(viewer); + credits.addStatic('a'); + const removeB = credits.addStatic('b'); + removeB(); + destroy(); + assert.equal(viewer.staticCredits.length, 0); + assert.equal(viewer.callsOf('creditDisplay.removeStaticCredit').length, 2); +}); diff --git a/src/globe/cesium/events.js b/src/globe/cesium/events.js new file mode 100644 index 000000000..755f0ac90 --- /dev/null +++ b/src/globe/cesium/events.js @@ -0,0 +1,49 @@ +// Contract events in front of Cesium events. The Cesium subscription exists +// only while the contract event has listeners, so creating the engine adds +// nothing to the scene (Cesium does per-frame work for camera.changed only +// while someone listens). + +import { createEngineEvent } from '../contract.js'; + +/** + * @param {{ addEventListener: Function, removeEventListener: Function }} source A Cesium.Event. + * @param {(...args: any[]) => any[]} [mapArgs] Cesium arguments → contract arguments. + * @returns {{ event: import('../contract.js').EngineEvent, detach: () => void }} + */ +export function bridgeEvent(source, mapArgs) { + const event = createEngineEvent(); + const forward = mapArgs + ? (...args) => event.raiseEvent(...mapArgs(...args)) + : (...args) => event.raiseEvent(...args); + let attached = false; + let detached = false; + const sync = () => { + const wanted = !detached && event.numberOfListeners > 0; + if (wanted === attached) return; + attached = wanted; + if (wanted) source.addEventListener(forward); + else source.removeEventListener(forward); + }; + 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(); + }, + }; +} diff --git a/src/globe/cesium/events.test.mjs b/src/globe/cesium/events.test.mjs new file mode 100644 index 000000000..9ac5892c2 --- /dev/null +++ b/src/globe/cesium/events.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { isEngineEvent } from '../contract.js'; +import { bridgeEvent } from './events.js'; + +test('a bridged event is a contract event that subscribes to Cesium lazily', () => { + const source = new Cesium.Event(); + const { event, detach } = bridgeEvent(source); + assert.ok(isEngineEvent(event)); + assert.equal(source.numberOfListeners, 0, 'creation must not subscribe'); + const seen = []; + const remove = event.addEventListener((value) => seen.push(value)); + assert.equal(source.numberOfListeners, 1); + source.raiseEvent(0.25); + assert.deepEqual(seen, [0.25]); + remove(); + assert.equal(source.numberOfListeners, 0, 'last removal unsubscribes'); + source.raiseEvent(0.5); + assert.deepEqual(seen, [0.25]); + detach(); +}); + +test('a bridged event maps Cesium arguments and keeps contract semantics', () => { + const source = new Cesium.Event(); + const { event } = bridgeEvent(source, (scene, time) => [time * 2]); + const seen = []; + const listener = (value) => seen.push(value); + event.addEventListener(listener); + event.addEventListener(listener); + assert.equal(source.numberOfListeners, 1); + source.raiseEvent({}, 3); + assert.deepEqual(seen, [6], 'a listener added twice fires once'); + assert.equal(event.removeEventListener(listener), true); + assert.equal(source.numberOfListeners, 0); + assert.equal(event.removeEventListener(listener), false); +}); + +test('a listener removed during dispatch unsubscribes after the dispatch', () => { + const source = new Cesium.Event(); + const { event } = bridgeEvent(source); + let calls = 0; + const once = () => { + calls++; + event.removeEventListener(once); + }; + event.addEventListener(once); + source.raiseEvent(); + source.raiseEvent(); + assert.equal(calls, 1); + assert.equal(source.numberOfListeners, 0); +}); + +test('detach drops the Cesium subscription for good', () => { + const source = new Cesium.Event(); + const { event, detach } = bridgeEvent(source); + event.addEventListener(() => {}); + detach(); + assert.equal(source.numberOfListeners, 0); + event.addEventListener(() => assert.fail('detached event fired')); + assert.equal(source.numberOfListeners, 0); + source.raiseEvent(); +}); diff --git a/src/globe/cesium/frames.js b/src/globe/cesium/frames.js new file mode 100644 index 000000000..de6f5554c --- /dev/null +++ b/src/globe/cesium/frames.js @@ -0,0 +1,29 @@ +// Frame events on a Cesium scene. Listeners receive the frame's scene time +// as Unix milliseconds. + +import * as Cesium from 'cesium'; +import { bridgeEvent } from './events.js'; + +const toMilliseconds = (scene, time) => [ + Cesium.JulianDate.toDate(time).getTime(), +]; + +/** + * @param {Cesium.Viewer} viewer + * @returns {{ frames: object, destroy: () => void }} + */ +export function createCesiumFrames(viewer) { + const { scene } = viewer; + const bridges = ['preUpdate', 'preRender', 'postRender'].map((name) => [ + name, + bridgeEvent(scene[name], toMilliseconds), + ]); + return { + frames: Object.fromEntries( + bridges.map(([name, bridge]) => [name, bridge.event]), + ), + destroy() { + for (const [, bridge] of bridges) bridge.detach(); + }, + }; +} diff --git a/src/globe/cesium/frames.test.mjs b/src/globe/cesium/frames.test.mjs new file mode 100644 index 000000000..c8852d5bd --- /dev/null +++ b/src/globe/cesium/frames.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { isEngineEvent } from '../contract.js'; +import { createCesiumFrames } from './frames.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +test('frame events bridge the scene events and pass a millisecond timestamp', () => { + const viewer = createFakeViewer(); + const { frames, destroy } = createCesiumFrames(viewer); + const { scene } = viewer; + const order = []; + for (const name of ['preUpdate', 'preRender', 'postRender']) { + assert.ok(isEngineEvent(frames[name]), name); + assert.equal(scene[name].numberOfListeners, 0, `${name} subscribed early`); + frames[name].addEventListener((time) => order.push([name, time])); + } + const date = new Date(Date.UTC(2026, 9, 15, 12, 0, 0, 250)); + const time = Cesium.JulianDate.fromDate(date); + scene.preUpdate.raiseEvent(scene, time); + scene.preRender.raiseEvent(scene, time); + scene.postRender.raiseEvent(scene, time); + assert.deepEqual(order, [ + ['preUpdate', date.getTime()], + ['preRender', date.getTime()], + ['postRender', date.getTime()], + ]); + destroy(); + for (const name of ['preUpdate', 'preRender', 'postRender']) + assert.equal(scene[name].numberOfListeners, 0); +}); diff --git a/src/globe/cesium/input.js b/src/globe/cesium/input.js new file mode 100644 index 000000000..d9ada81c5 --- /dev/null +++ b/src/globe/cesium/input.js @@ -0,0 +1,86 @@ +// Screen input on one lazily created ScreenSpaceEventHandler. Cesium keeps +// one action per (type, modifier); the engine fans it out to its listeners. + +import * as Cesium from 'cesium'; +import { INPUT_TYPES, MODIFIERS } from '../contract.js'; +import { toGeo2 } from './convert.js'; + +const TYPES = { + click: Cesium.ScreenSpaceEventType.LEFT_CLICK, + doubleClick: Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, + move: Cesium.ScreenSpaceEventType.MOUSE_MOVE, + down: Cesium.ScreenSpaceEventType.LEFT_DOWN, + up: Cesium.ScreenSpaceEventType.LEFT_UP, +}; +const KEYS = { + ctrl: Cesium.KeyboardEventModifier.CTRL, + shift: Cesium.KeyboardEventModifier.SHIFT, + alt: Cesium.KeyboardEventModifier.ALT, +}; + +// Cesium reuses its event objects; listeners get geo copies. +const toEvent = (event) => { + const result = {}; + for (const name of ['position', 'startPosition', 'endPosition']) + if (event?.[name]) result[name] = toGeo2(event[name]); + return result; +}; + +/** + * @param {Cesium.Viewer} viewer + * @param {{ createEventHandler?: (canvas: HTMLCanvasElement) => Cesium.ScreenSpaceEventHandler }} [options] + * @returns {{ input: object, destroy: () => void }} + */ +export function createCesiumInput( + viewer, + { + createEventHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), + } = {}, +) { + const { scene } = viewer; + let handler = null; + const groups = new Map(); + + const input = { + on(type, listener, { modifier } = {}) { + if (!INPUT_TYPES.includes(type)) + throw new TypeError(`Unknown input type: ${type}`); + if (modifier !== undefined && !MODIFIERS.includes(modifier)) + throw new TypeError(`Unknown input modifier: ${modifier}`); + handler ??= createEventHandler(scene.canvas); + const key = `${type}:${modifier}`; + let listeners = groups.get(key); + if (!listeners) { + listeners = new Set(); + groups.set(key, listeners); + handler.setInputAction( + (event) => { + const converted = toEvent(event); + for (const entry of [...listeners]) entry.listener(converted); + }, + TYPES[type], + KEYS[modifier], + ); + } + const entry = { listener }; + listeners.add(entry); + return () => { + if (!listeners.delete(entry) || listeners.size) return; + groups.delete(key); + if (handler && !handler.isDestroyed?.()) + handler.removeInputAction(TYPES[type], KEYS[modifier]); + }; + }, + setCameraInputs(enabled) { + scene.screenSpaceCameraController.enableInputs = Boolean(enabled); + }, + }; + return { + input, + destroy() { + groups.clear(); + handler?.destroy(); + handler = null; + }, + }; +} diff --git a/src/globe/cesium/input.test.mjs b/src/globe/cesium/input.test.mjs new file mode 100644 index 000000000..dfabf2821 --- /dev/null +++ b/src/globe/cesium/input.test.mjs @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createCesiumInput } from './input.js'; +import { + createFakeEventHandlers, + createFakeViewer, +} from './testing/fakeViewer.mjs'; + +const { ScreenSpaceEventType: Type, KeyboardEventModifier: Key } = Cesium; + +function setup() { + const viewer = createFakeViewer(); + const handlers = createFakeEventHandlers(); + const { input, destroy } = createCesiumInput(viewer, { + createEventHandler: handlers.create, + }); + return { viewer, input, destroy, handlers: handlers.handlers }; +} + +test('no handler is created until the first subscription', () => { + const { handlers, input, viewer } = setup(); + assert.equal(handlers.length, 0); + input.on('click', () => {}); + assert.equal(handlers.length, 1); + assert.equal(handlers[0].canvas, viewer.scene.canvas); + input.on('move', () => {}); + assert.equal(handlers.length, 1, 'one shared handler'); +}); + +test('types map to Cesium events and positions arrive as geo copies', () => { + const { handlers, input } = setup(); + const seen = []; + input.on('click', (event) => seen.push(['click', event])); + input.on('doubleClick', (event) => seen.push(['doubleClick', event])); + input.on('down', (event) => seen.push(['down', event])); + input.on('up', (event) => seen.push(['up', event])); + input.on('move', (event) => seen.push(['move', event])); + const [handler] = handlers; + const position = new Cesium.Cartesian2(1, 2); + handler.fire(Type.LEFT_CLICK, { position }); + handler.fire(Type.LEFT_DOUBLE_CLICK, { position }); + handler.fire(Type.LEFT_DOWN, { position }); + handler.fire(Type.LEFT_UP, { position }); + handler.fire(Type.MOUSE_MOVE, { + startPosition: new Cesium.Cartesian2(3, 4), + endPosition: new Cesium.Cartesian2(5, 6), + }); + assert.deepEqual( + seen.map(([type]) => type), + ['click', 'doubleClick', 'down', 'up', 'move'], + ); + const click = seen[0][1]; + assert.ok(!(click.position instanceof Cesium.Cartesian2)); + assert.deepEqual([click.position.x, click.position.y], [1, 2]); + const move = seen[4][1]; + assert.deepEqual( + [move.startPosition.x, move.startPosition.y, move.endPosition.x], + [3, 4, 5], + ); + assert.ok(!(move.endPosition instanceof Cesium.Cartesian2)); +}); + +test('modifiers register separate Cesium actions', () => { + const { handlers, input } = setup(); + const seen = []; + input.on('click', () => seen.push('plain')); + input.on('click', () => seen.push('ctrl'), { modifier: 'ctrl' }); + input.on('click', () => seen.push('shift'), { modifier: 'shift' }); + input.on('click', () => seen.push('alt'), { modifier: 'alt' }); + const [handler] = handlers; + const position = new Cesium.Cartesian2(0, 0); + handler.fire(Type.LEFT_CLICK, { position }, Key.CTRL); + handler.fire(Type.LEFT_CLICK, { position }, Key.SHIFT); + handler.fire(Type.LEFT_CLICK, { position }, Key.ALT); + handler.fire(Type.LEFT_CLICK, { position }); + assert.deepEqual(seen, ['ctrl', 'shift', 'alt', 'plain']); + assert.throws(() => input.on('click', () => {}, { modifier: 'meta' })); + assert.throws(() => input.on('wheel', () => {})); +}); + +test('listeners share an action, removers are idempotent and the action goes with the last', () => { + const { handlers, input } = setup(); + const seen = []; + const offA = input.on('click', () => seen.push('a')); + const offB = input.on('click', () => seen.push('b')); + const [handler] = handlers; + const key = `${Type.LEFT_CLICK}:undefined`; + handler.fire(Type.LEFT_CLICK, { position: new Cesium.Cartesian2() }); + assert.deepEqual(seen, ['a', 'b']); + offA(); + offA(); + assert.ok(handler.actions.has(key)); + handler.fire(Type.LEFT_CLICK, { position: new Cesium.Cartesian2() }); + assert.deepEqual(seen, ['a', 'b', 'b']); + offB(); + assert.ok(!handler.actions.has(key)); +}); + +test('setCameraInputs toggles the camera controller', () => { + const { viewer, input } = setup(); + input.setCameraInputs(false); + assert.equal(viewer.scene.screenSpaceCameraController.enableInputs, false); + input.setCameraInputs(true); + assert.equal(viewer.scene.screenSpaceCameraController.enableInputs, true); +}); + +test('destroy destroys the handler', () => { + const { handlers, input, destroy } = setup(); + input.on('click', () => {}); + destroy(); + assert.equal(handlers[0].destroyed, true); +}); diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js new file mode 100644 index 000000000..0e4a17110 --- /dev/null +++ b/src/globe/cesium/maps.js @@ -0,0 +1,176 @@ +// Map sources on a Cesium viewer. Sources are maps/registry.js entries +// ({ descriptor, imagery(), credit } and terrain { id, create() }) or plain +// descriptions ({ kind, style?, accessToken? }); providers come from the +// shared factories in src/maps, as MapSourceController builds them. + +import * as Cesium from 'cesium'; +import { loadPhotorealisticTileset } from '../../maps/google3d.js'; +import { createMapCredits } from '../../maps/credits.js'; +import { + ESRI_ATTRIBUTION_HTML, + imageryFactoryFor, +} from '../../maps/imagery.js'; +import { applyTerrain, terrainSourceFor } from '../../maps/terrain.js'; + +const IMAGERY_KINDS = new Set(['osm', 'esri-imagery', 'ion']); + +function imageryCreator(source) { + if (typeof source?.imagery === 'function') return source.imagery; + const descriptor = source?.descriptor ?? source; + if (!IMAGERY_KINDS.has(descriptor?.kind)) + throw new TypeError(`Unknown imagery source: ${descriptor?.kind}`); + return imageryFactoryFor(descriptor, source.accessToken); +} + +function imageryCredit(source) { + if (source?.credit !== undefined) return source.credit; + const kind = (source?.descriptor ?? source)?.kind; + return kind === 'esri-imagery' ? ESRI_ATTRIBUTION_HTML : null; +} + +function terrainCreator(source) { + if (typeof source?.create === 'function') return source.create; + switch (source?.kind) { + case 'ellipsoid': + return () => ({ provider: new Cesium.EllipsoidTerrainProvider() }); + case 'keyless': + return terrainSourceFor('').create; + case 'world': { + const token = String(source.accessToken || '').trim(); + if (!token) + return () => { + throw new Error('World terrain requires an explicit ion token'); + }; + return terrainSourceFor(token).create; + } + default: + throw new TypeError(`Unknown terrain source: ${source?.kind}`); + } +} + +const dispose = (value) => { + if (typeof value?.destroy === 'function' && !value.isDestroyed?.()) + value.destroy(); +}; + +/** + * @param {Cesium.Viewer} viewer + * @param {{ + * tilesetRef?: { current: object | null } | (() => object | null), + * requestRender?: (reason: string) => void, + * createImageryLayer?: (provider: object) => object, + * loadPhotorealTileset?: (credentials: object) => Promise<{ tileset: object | null, errors: Error[] }>, + * }} [options] `tilesetRef` names the startup Google tileset the app added. + * @returns {{ maps: object, destroy: () => void }} + */ +export function createCesiumMaps( + viewer, + { + tilesetRef = null, + requestRender = () => viewer.scene.requestRender?.(), + createImageryLayer = (provider) => new Cesium.ImageryLayer(provider), + loadPhotorealTileset = (credentials) => + loadPhotorealisticTileset(Cesium, credentials), + } = {}, +) { + const abort = new AbortController(); + let credits = null; + let layer = null; + let ownedTileset = null; + let shownTileset = null; + const generations = { imagery: 0, terrain: 0, photoreal: 0 }; + let destroyed = false; + + const startupTileset = () => + (typeof tilesetRef === 'function' ? tilesetRef() : tilesetRef?.current) ?? + null; + const tilesets = () => + [startupTileset(), shownTileset, ownedTileset].filter( + (tileset, index, all) => tileset && all.indexOf(tileset) === index, + ); + const showCredit = (html) => { + credits ??= createMapCredits(viewer); + credits.show(html); + }; + + const maps = { + async setBaseImagery(source) { + const generation = ++generations.imagery; + const create = imageryCreator(source); + const provider = await create({ signal: abort.signal }); + if (destroyed || generation !== generations.imagery) { + dispose(provider); + return; + } + const next = createImageryLayer(provider); + viewer.imageryLayers.add(next, 0); + if (layer) viewer.imageryLayers.remove(layer, true); + layer = next; + showCredit(imageryCredit(source)); + requestRender('map-imagery'); + }, + async setTerrain(source) { + const generation = ++generations.terrain; + const result = await terrainCreator(source)({ signal: abort.signal }); + if (destroyed || generation !== generations.terrain) return; + applyTerrain(viewer, result); + requestRender('map-terrain'); + }, + async setPhotorealTiles(source) { + const generation = ++generations.photoreal; + const { scene } = viewer; + if (source == null) { + for (const tileset of tilesets()) tileset.show = false; + shownTileset = null; + scene.globe.show = true; + requestRender('map-photoreal'); + return; + } + let tileset = source.tileset ?? null; + let owned = false; + if (!tileset) { + const { tileset: loaded, errors = [] } = + await loadPhotorealTileset(source); + if (!loaded) + throw errors.at(-1) ?? new Error('Google 3D Tiles are unavailable'); + tileset = loaded; + owned = true; + } + if (destroyed || generation !== generations.photoreal) { + if (owned) dispose(tileset); + return; + } + if (owned && ownedTileset !== tileset) { + if (ownedTileset) { + scene.primitives.remove(ownedTileset); + dispose(ownedTileset); + } + ownedTileset = tileset; + } + if (!scene.primitives.contains(tileset)) scene.primitives.add(tileset); + for (const other of tilesets()) other.show = other === tileset; + tileset.show = true; + shownTileset = tileset; + scene.globe.show = false; + requestRender('map-photoreal'); + }, + photorealActive: () => tilesets().some((tileset) => tileset.show === true), + }; + + return { + maps, + destroy() { + if (destroyed) return; + destroyed = true; + abort.abort(); + if (layer) viewer.imageryLayers.remove(layer, true); + layer = null; + credits?.destroy(); + if (ownedTileset) { + viewer.scene.primitives.remove(ownedTileset); + dispose(ownedTileset); + ownedTileset = null; + } + }, + }; +} diff --git a/src/globe/cesium/maps.test.mjs b/src/globe/cesium/maps.test.mjs new file mode 100644 index 000000000..3485199f9 --- /dev/null +++ b/src/globe/cesium/maps.test.mjs @@ -0,0 +1,211 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { MAP_STACKS } from '../../maps/catalog.js'; +import { createDefaultMapSources } from '../../maps/defaultSources.js'; +import { ESRI_ATTRIBUTION_HTML } from '../../maps/imagery.js'; +import { createCesiumMaps } from './maps.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +function setup(options = {}) { + const viewer = createFakeViewer(); + const renders = []; + const { maps, destroy } = createCesiumMaps(viewer, { + requestRender: (reason) => renders.push(reason), + createImageryLayer: (provider) => ({ provider }), + ...options, + }); + return { viewer, maps, destroy, renders }; +} + +const tileset = (show = false) => ({ + show, + destroyed: false, + isDestroyed() { + return this.destroyed; + }, + destroy() { + this.destroyed = true; + }, +}); + +test('creation leaves the viewer untouched', () => { + const { viewer, maps } = setup({ tilesetRef: { current: tileset(true) } }); + assert.deepEqual(viewer.calls, []); + assert.equal(viewer.scene.globe.show, true); + assert.equal(maps.photorealActive(), true, 'reads the shown tileset'); +}); + +test('setBaseImagery takes a registry source and puts its layer at the bottom', async () => { + const { viewer, maps, renders } = setup(); + const provider = { name: 'esri' }; + const source = { + descriptor: { id: 'esri-imagery', kind: 'esri-imagery' }, + imagery: async () => provider, + credit: ESRI_ATTRIBUTION_HTML, + }; + viewer.imageryList.push({ name: 'weather overlay' }); + await maps.setBaseImagery(source); + assert.deepEqual(viewer.imageryList[0], { provider }); + assert.equal(viewer.callsOf('imageryLayers.add')[0].args[1], 0); + assert.deepEqual( + viewer.staticCredits.map((credit) => credit.html), + [ESRI_ATTRIBUTION_HTML], + ); + assert.ok(renders.length > 0); + + const osmProvider = { name: 'osm' }; + await maps.setBaseImagery({ + descriptor: { id: 'osm', kind: 'osm' }, + imagery: () => osmProvider, + }); + const [removed, destroyLayer] = viewer.callsOf('imageryLayers.remove')[0] + .args; + assert.deepEqual(removed, { provider }); + assert.equal(destroyLayer, true); + assert.deepEqual( + viewer.imageryList.map((layer) => layer.provider?.name ?? layer.name), + ['osm', 'weather overlay'], + ); + assert.deepEqual(viewer.staticCredits, [], 'the Esri credit leaves with it'); +}); + +test('setBaseImagery builds providers from plain descriptions', async () => { + const { viewer, maps } = setup(); + await maps.setBaseImagery({ kind: 'osm' }); + assert.ok( + viewer.imageryList[0].provider instanceof + Cesium.OpenStreetMapImageryProvider, + ); + await assert.rejects( + maps.setBaseImagery({ kind: 'ion', style: 2, accessToken: '' }), + /explicit token/, + ); + assert.ok( + viewer.imageryList[0].provider instanceof + Cesium.OpenStreetMapImageryProvider, + 'a failed switch keeps the current layer', + ); +}); + +test('every catalog globe stack works as a registry source', async () => { + const { viewer, maps } = setup(); + const registry = createDefaultMapSources(); + const osm = registry.sources.find( + ({ descriptor }) => descriptor.id === 'osm', + ); + await maps.setBaseImagery(osm); + assert.ok( + viewer.imageryList[0].provider instanceof + Cesium.OpenStreetMapImageryProvider, + ); + assert.ok(MAP_STACKS.some(({ id }) => id === 'osm')); +}); + +test('a superseded imagery switch is dropped', async () => { + const { viewer, maps } = setup(); + let release; + const slow = new Promise((resolve) => (release = resolve)); + const first = maps.setBaseImagery({ + descriptor: { id: 'slow', kind: 'esri-imagery' }, + imagery: () => slow, + }); + await maps.setBaseImagery({ + descriptor: { id: 'fast', kind: 'osm' }, + imagery: () => ({ name: 'fast' }), + }); + const stale = { + name: 'slow', + destroyed: false, + destroy() { + this.destroyed = true; + }, + }; + release(stale); + await first; + assert.deepEqual( + viewer.imageryList.map((layer) => layer.provider.name), + ['fast'], + ); + assert.equal(stale.destroyed, true, 'the stale provider is disposed'); +}); + +test('setTerrain applies provider and terrain results', async () => { + const { viewer, maps } = setup(); + await maps.setTerrain({ kind: 'ellipsoid' }); + assert.ok(viewer.terrainProvider instanceof Cesium.EllipsoidTerrainProvider); + const provider = { name: 'keyless' }; + await maps.setTerrain({ id: 'keyless', create: async () => ({ provider }) }); + assert.equal(viewer.terrainProvider, provider); + const terrain = { name: 'terrain object' }; + await maps.setTerrain({ id: 'world', create: async () => ({ terrain }) }); + assert.equal(viewer.callsOf('scene.setTerrain')[0].args[0], terrain); + await assert.rejects( + maps.setTerrain({ kind: 'world', accessToken: ' ' }), + /explicit ion token/, + ); +}); + +test('setPhotorealTiles shows a given tileset and hides the globe; null reverses it', async () => { + const { viewer, maps } = setup(); + const google = tileset(); + await maps.setPhotorealTiles({ tileset: google }); + assert.equal(viewer.primitiveList.includes(google), true); + assert.equal(google.show, true); + assert.equal(viewer.scene.globe.show, false); + assert.equal(maps.photorealActive(), true); + await maps.setPhotorealTiles(null); + assert.equal(google.show, false); + assert.equal(viewer.scene.globe.show, true); + assert.equal(maps.photorealActive(), false); + await maps.setPhotorealTiles({ tileset: google }); + assert.equal(viewer.callsOf('primitives.add').length, 1, 'added once'); +}); + +test('setPhotorealTiles(null) also hides the startup tileset', async () => { + const startup = tileset(true); + const { viewer, maps } = setup({ tilesetRef: () => startup }); + viewer.scene.globe.show = false; + await maps.setPhotorealTiles(null); + assert.equal(startup.show, false); + assert.equal(viewer.scene.globe.show, true); + assert.equal(maps.photorealActive(), false); + await maps.setPhotorealTiles({ tileset: startup }); + assert.equal(startup.show, true); + assert.equal(maps.photorealActive(), true); +}); + +test('setPhotorealTiles loads from credentials and owns the result', async () => { + const loaded = tileset(); + const requests = []; + const { viewer, maps, destroy } = setup({ + loadPhotorealTileset: async (credentials) => { + requests.push(credentials); + return { + tileset: requests.length === 1 ? loaded : null, + errors: [new Error('refused')], + }; + }, + }); + await maps.setPhotorealTiles({ googleApiKey: 'browser-key' }); + assert.deepEqual(requests[0], { googleApiKey: 'browser-key' }); + assert.equal(loaded.show, true); + assert.equal(maps.photorealActive(), true); + await assert.rejects(maps.setPhotorealTiles({ cesiumToken: 'x' }), /refused/); + assert.equal(loaded.show, true, 'a failed load keeps the current tiles'); + destroy(); + assert.equal(viewer.primitiveList.includes(loaded), false); + assert.equal(loaded.destroyed, true); +}); + +test('destroy removes the engine imagery layer and its credit', async () => { + const { viewer, maps, destroy } = setup(); + await maps.setBaseImagery({ + descriptor: { id: 'esri-imagery', kind: 'esri-imagery' }, + imagery: () => ({ name: 'esri' }), + credit: ESRI_ATTRIBUTION_HTML, + }); + destroy(); + assert.deepEqual(viewer.imageryList, []); + assert.deepEqual(viewer.staticCredits, []); +}); diff --git a/src/globe/cesium/picker.js b/src/globe/cesium/picker.js new file mode 100644 index 000000000..ce0ed9bd0 --- /dev/null +++ b/src/globe/cesium/picker.js @@ -0,0 +1,39 @@ +// GlobePicker on a Cesium scene. Pick results keep Cesium's `id` and +// `primitive`; the untouched Cesium result rides along as `object` for +// Cesium-only callers. + +import { toCesium2, toGeo3 } from './convert.js'; + +const normalise = (picked) => + picked == null + ? undefined + : { id: picked.id, primitive: picked.primitive, object: picked }; + +/** + * @param {Cesium.Viewer} viewer + * @returns {{ picker: object, destroy: () => void }} + */ +export function createCesiumPicker(viewer) { + const { scene } = viewer; + const picker = { + pick(screen, width, height) { + return normalise(scene.pick(toCesium2(screen), width, height)); + }, + drillPick(screen, limit) { + return scene.drillPick(toCesium2(screen), limit).map(normalise); + }, + pickPosition(screen) { + if (!scene.pickPositionSupported) return undefined; + try { + // A new Cesium.Cartesian2 per call: Cesium caches by its toString(). + return toGeo3(scene.pickPosition(toCesium2(screen))); + } catch { + return undefined; + } + }, + get pickPositionSupported() { + return Boolean(scene.pickPositionSupported); + }, + }; + return { picker, destroy() {} }; +} diff --git a/src/globe/cesium/picker.test.mjs b/src/globe/cesium/picker.test.mjs new file mode 100644 index 000000000..000f3e0b2 --- /dev/null +++ b/src/globe/cesium/picker.test.mjs @@ -0,0 +1,80 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createCesiumPicker } from './picker.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +function setup() { + const viewer = createFakeViewer(); + const { picker } = createCesiumPicker(viewer); + return { viewer, picker, scene: viewer.scene }; +} + +test('pick passes a Cesium window position and keeps id and primitive', () => { + const { viewer, picker, scene } = setup(); + assert.equal(picker.pick(geo.cartesian2.create(1, 2)), undefined); + const entity = { name: 'entity' }; + const primitive = { name: 'billboard' }; + const raw = { id: entity, primitive }; + scene.pick = (...args) => { + viewer.calls.push({ name: 'scene.pick', args }); + return raw; + }; + const picked = picker.pick(geo.cartesian2.create(3, 4), 5, 6); + assert.equal(picked.id, entity); + assert.equal(picked.primitive, primitive); + assert.equal(picked.object, raw, 'the Cesium pick result rides along'); + const [screen, width, height] = viewer.callsOf('scene.pick')[1].args; + assert.ok(screen instanceof Cesium.Cartesian2); + assert.deepEqual([screen.x, screen.y, width, height], [3, 4, 5, 6]); +}); + +test('drillPick passes the limit and normalises every result', () => { + const { viewer, picker, scene } = setup(); + scene.drillPick = (...args) => { + viewer.calls.push({ name: 'scene.drillPick', args }); + return [{ id: 'a', primitive: 1 }, { primitive: 2 }]; + }; + const drilled = picker.drillPick(geo.cartesian2.create(1, 1), 3); + assert.deepEqual( + drilled.map(({ id, primitive }) => [id, primitive]), + [ + ['a', 1], + [undefined, 2], + ], + ); + assert.ok('id' in drilled[1]); + const [screen, limit] = viewer.callsOf('scene.drillPick')[0].args; + assert.ok(screen instanceof Cesium.Cartesian2); + assert.equal(limit, 3); +}); + +test('pickPosition passes a new Cesium.Cartesian2 per call and returns geo', () => { + const { viewer, picker, scene } = setup(); + const position = picker.pickPosition(geo.cartesian2.create(7, 8)); + assert.ok(!(position instanceof Cesium.Cartesian3)); + assert.deepEqual([position.x, position.y, position.z], [6378137, 1, 2]); + picker.pickPosition(geo.cartesian2.create(9, 10)); + const [first, second] = viewer + .callsOf('scene.pickPosition') + .map((call) => call.args[0]); + // Cesium caches pickPosition by windowPosition.toString(). + assert.ok(first instanceof Cesium.Cartesian2); + assert.notEqual(first, second); + assert.equal(second.toString(), '(9, 10)'); + scene.pickPosition = () => undefined; + assert.equal(picker.pickPosition(geo.cartesian2.create(1, 1)), undefined); + scene.pickPosition = () => { + throw new Error('depth texture missing'); + }; + assert.equal(picker.pickPosition(geo.cartesian2.create(1, 1)), undefined); +}); + +test('pickPositionSupported follows the scene', () => { + const { picker, scene } = setup(); + assert.equal(picker.pickPositionSupported, true); + scene.pickPositionSupported = false; + assert.equal(picker.pickPositionSupported, false); + assert.equal(picker.pickPosition(geo.cartesian2.create(1, 1)), undefined); +}); diff --git a/src/globe/cesium/projector.js b/src/globe/cesium/projector.js new file mode 100644 index 000000000..0345c12c7 --- /dev/null +++ b/src/globe/cesium/projector.js @@ -0,0 +1,52 @@ +// GlobeProjector on a Cesium scene. + +import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; + +/** + * @param {Cesium.Viewer} viewer + * @param {{ worldToWindowCoordinates?: Function }} [options] Test seam for + * Cesium.SceneTransforms.worldToWindowCoordinates. + * @returns {{ projector: import('../contract.js').GlobeProjector, destroy: () => void }} + */ +export function createCesiumProjector( + viewer, + { + worldToWindowCoordinates = Cesium.SceneTransforms.worldToWindowCoordinates, + } = {}, +) { + const { scene } = viewer; + const scratchWindow = new Cesium.Cartesian2(); + const scratchMatrix = new Cesium.Matrix4(); + const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); + + const projector = { + worldToWindow(position, result) { + // Cesium returns undefined in front of the near plane or behind the camera. + const window = worldToWindowCoordinates(scene, position, scratchWindow); + if (!window) return undefined; + if (!result) return geo.cartesian2.create(window.x, window.y); + result.x = window.x; + result.y = window.y; + return result; + }, + // The matrix Cesium's worldToWindowCoordinates uses: frustum projection × + // view. x, y and w do not depend on the multi-frustum near/far planes. + viewProjection(result = geo.matrix4.create()) { + const { camera } = scene; + Cesium.Matrix4.multiply( + camera.frustum.projectionMatrix, + camera.viewMatrix, + scratchMatrix, + ); + for (let index = 0; index < 16; index++) + result[index] = scratchMatrix[index]; + return result; + }, + horizonVisible(position) { + occluder.cameraPosition = scene.camera.positionWC; + return occluder.isPointVisible(position); + }, + }; + return { projector, destroy() {} }; +} diff --git a/src/globe/cesium/projector.test.mjs b/src/globe/cesium/projector.test.mjs new file mode 100644 index 000000000..e3b7fa6e9 --- /dev/null +++ b/src/globe/cesium/projector.test.mjs @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createCesiumProjector } from './projector.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +function setup(transform) { + const viewer = createFakeViewer(); + const calls = []; + const { projector } = createCesiumProjector(viewer, { + worldToWindowCoordinates(scene, position, result) { + calls.push({ scene, position, result }); + return transform ? transform(position, result) : undefined; + }, + }); + return { viewer, projector, calls }; +} + +test('worldToWindow returns a fresh geo value or fills result', () => { + const { viewer, projector, calls } = setup((position, result) => { + result.x = position.x / 10; + result.y = 7; + return result; + }); + const point = geo.cartesian3.create(100, 0, 0); + const window = projector.worldToWindow(point); + assert.ok(!(window instanceof Cesium.Cartesian2), 'leaks a Cesium value'); + assert.deepEqual([window.x, window.y], [10, 7]); + assert.equal(calls[0].scene, viewer.scene); + assert.equal(calls[0].position, point); + const result = geo.cartesian2.create(); + assert.equal(projector.worldToWindow(point, result), result); + assert.deepEqual([result.x, result.y], [10, 7]); + assert.notEqual(window, projector.worldToWindow(point), 'fresh per call'); +}); + +test('worldToWindow is undefined when Cesium finds no window position', () => { + const { projector } = setup(() => undefined); + const result = geo.cartesian2.create(3, 4); + assert.equal( + projector.worldToWindow(geo.cartesian3.create(1, 2, 3), result), + undefined, + ); + assert.deepEqual([result.x, result.y], [3, 4]); +}); + +test('viewProjection is the frustum projection times the view matrix', () => { + const { viewer, projector } = setup(); + const { camera } = viewer.scene; + const expected = Cesium.Matrix4.toArray( + Cesium.Matrix4.multiply( + camera.frustum.projectionMatrix, + camera.viewMatrix, + new Cesium.Matrix4(), + ), + ); + const result = new Array(16).fill(0); + assert.equal(projector.viewProjection(result), result); + assert.deepEqual(result, expected); + const fresh = projector.viewProjection(); + assert.ok(Array.isArray(fresh), 'a plain column-major array'); + assert.ok(!(fresh instanceof Cesium.Matrix4)); + assert.deepEqual(fresh, expected); +}); + +test('horizonVisible uses the WGS84 occluder from the camera position', () => { + const { viewer, projector } = setup(); + viewer.scene.camera.positionWC = Cesium.Cartesian3.fromDegrees(20, 10, 1e6); + assert.equal( + projector.horizonVisible(geo.cartesian3.fromDegrees(20, 10, 0)), + true, + ); + assert.equal( + projector.horizonVisible(geo.cartesian3.fromDegrees(-160, -10, 0)), + false, + ); + viewer.scene.camera.positionWC = Cesium.Cartesian3.fromDegrees( + -160, + -10, + 1e6, + ); + assert.equal( + projector.horizonVisible(geo.cartesian3.fromDegrees(-160, -10, 0)), + true, + 'follows the camera', + ); +}); diff --git a/src/globe/cesium/render.js b/src/globe/cesium/render.js new file mode 100644 index 000000000..2f219396f --- /dev/null +++ b/src/globe/cesium/render.js @@ -0,0 +1,41 @@ +// GlobeRender on the app's render governor, which owns Cesium's +// requestRenderMode and maximumRenderTimeChange; the engine only takes a +// hold under its own owner id. + +import { + governorRequestRender, + holdContinuousRender, + releaseContinuousRender, +} from '../../renderGovernor.js'; + +export const RENDER_OWNER = 'globe-engine'; + +/** + * @param {Cesium.Viewer} viewer + * @param {{ requestRender?: (reason: string) => void }} [options] + * @returns {{ render: import('../contract.js').GlobeRender, destroy: () => void }} + */ +export function createCesiumRender( + viewer, + { requestRender = governorRequestRender } = {}, +) { + let held = false; + const render = { + request() { + requestRender(RENDER_OWNER); + }, + setContinuous(enabled) { + held = Boolean(enabled); + if (held) holdContinuousRender(RENDER_OWNER); + else releaseContinuousRender(RENDER_OWNER); + }, + // The scene's real mode: another owner's hold keeps it continuous. + isContinuous: () => viewer.scene.requestRenderMode === false, + }; + return { + render, + destroy() { + if (held) render.setContinuous(false); + }, + }; +} diff --git a/src/globe/cesium/render.test.mjs b/src/globe/cesium/render.test.mjs new file mode 100644 index 000000000..50d7df9dd --- /dev/null +++ b/src/globe/cesium/render.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + _resetRenderGovernorForTest, + getRenderGovernorDiagnostics, + holdContinuousRender, + installRenderGovernor, +} from '../../renderGovernor.js'; +import { createCesiumRender, RENDER_OWNER } from './render.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +test.beforeEach(() => _resetRenderGovernorForTest()); +test.after(() => _resetRenderGovernorForTest()); + +test('request goes through the injected requestRender with a reason', () => { + const viewer = createFakeViewer(); + const reasons = []; + const { render } = createCesiumRender(viewer, { + requestRender: (reason) => reasons.push(reason), + }); + render.request(); + assert.deepEqual(reasons, [RENDER_OWNER]); + assert.equal(viewer.callsOf('scene.requestRender').length, 0); +}); + +test('request defaults to the render governor', () => { + const viewer = createFakeViewer(); + installRenderGovernor(viewer); + const { render } = createCesiumRender(viewer); + render.request(); + assert.equal(viewer.callsOf('scene.requestRender').length, 1); + assert.deepEqual( + getRenderGovernorDiagnostics().recentRequests.map(({ reason }) => reason), + [RENDER_OWNER], + ); +}); + +test('creation leaves the scene and the governor alone', () => { + const viewer = createFakeViewer(); + viewer.scene.maximumRenderTimeChange = 0.5; + createCesiumRender(viewer); + assert.equal(viewer.scene.requestRenderMode, true); + assert.equal(viewer.scene.maximumRenderTimeChange, 0.5); + assert.equal(getRenderGovernorDiagnostics().installed, false); + assert.deepEqual(getRenderGovernorDiagnostics().holds, []); +}); + +test('setContinuous holds and releases the governor under its own owner', () => { + const viewer = createFakeViewer(); + installRenderGovernor(viewer); + const { render } = createCesiumRender(viewer); + assert.equal(render.isContinuous(), false); + render.setContinuous(true); + assert.deepEqual(getRenderGovernorDiagnostics().holds, [RENDER_OWNER]); + assert.equal(viewer.scene.requestRenderMode, false); + assert.equal(render.isContinuous(), true); + render.setContinuous(false); + assert.deepEqual(getRenderGovernorDiagnostics().holds, []); + assert.equal(render.isContinuous(), false); +}); + +test("isContinuous reports the scene's mode, including other owners' holds", () => { + const viewer = createFakeViewer(); + installRenderGovernor(viewer); + const { render } = createCesiumRender(viewer); + holdContinuousRender('flights'); + render.setContinuous(false); + assert.equal(render.isContinuous(), true); +}); + +test('destroy releases the engine hold', () => { + const viewer = createFakeViewer(); + installRenderGovernor(viewer); + const { render, destroy } = createCesiumRender(viewer); + render.setContinuous(true); + destroy(); + assert.deepEqual(getRenderGovernorDiagnostics().holds, []); +}); diff --git a/src/globe/cesium/surface.js b/src/globe/cesium/surface.js new file mode 100644 index 000000000..b44added3 --- /dev/null +++ b/src/globe/cesium/surface.js @@ -0,0 +1,54 @@ +// GlobeSurface on a Cesium scene: the rendered-surface queries that +// services/groundSnap and services/meshFloorSampler make today. + +import { firstVisibleTileset } from '../../services/groundSnap.js'; +import { toGeo3 } from './convert.js'; + +const finite = (value) => (Number.isFinite(value) ? value : undefined); + +/** + * @param {Cesium.Viewer} viewer + * @returns {{ surface: import('../contract.js').GlobeSurface, destroy: () => void }} + */ +export function createCesiumSurface(viewer) { + const { scene } = viewer; + const surface = { + sampleHeight(cartographic, exclude) { + if (!scene.sampleHeightSupported) return undefined; + try { + // Throws when unsupported (no depth textures); that is a miss. + return finite(scene.sampleHeight(cartographic, exclude)); + } catch { + return undefined; + } + }, + get supported() { + return Boolean(scene.sampleHeightSupported); + }, + terrainHeight(cartographic) { + return scene.globe + ? finite(scene.globe.getHeight(cartographic)) + : undefined; + }, + get terrainHeightSupported() { + return Boolean(scene.globe); + }, + clampToHeight(position, exclude) { + if (!scene.clampToHeightSupported) return undefined; + try { + return toGeo3(scene.clampToHeight(position, exclude)); + } catch { + return undefined; + } + }, + get clampToHeightSupported() { + return Boolean(scene.clampToHeightSupported); + }, + tilesLoaded() { + const tileset = firstVisibleTileset(scene.primitives); + if (tileset) return tileset.tilesLoaded === true; + return scene.globe?.show ? scene.globe.tilesLoaded === true : true; + }, + }; + return { surface, destroy() {} }; +} diff --git a/src/globe/cesium/surface.test.mjs b/src/globe/cesium/surface.test.mjs new file mode 100644 index 000000000..2ff89be20 --- /dev/null +++ b/src/globe/cesium/surface.test.mjs @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createCesiumSurface } from './surface.js'; +import { createFakeViewer } from './testing/fakeViewer.mjs'; + +function setup() { + const viewer = createFakeViewer(); + const { surface } = createCesiumSurface(viewer); + return { viewer, surface, scene: viewer.scene }; +} + +test('sampleHeight forwards the point and exclusions to the scene', () => { + const { viewer, surface, scene } = setup(); + const place = geo.cartographic.fromDegrees(20, 10, 0); + const exclude = [{ primitive: 1 }]; + assert.equal(surface.sampleHeight(place, exclude), 42); + const [position, excluded] = viewer.callsOf('scene.sampleHeight')[0].args; + assert.equal(position.longitude, place.longitude); + assert.equal(position.latitude, place.latitude); + assert.equal(excluded, exclude); + scene.sampleHeight = () => undefined; + assert.equal(surface.sampleHeight(place), undefined); + scene.sampleHeight = () => NaN; + assert.equal(surface.sampleHeight(place), undefined); + scene.sampleHeight = () => { + throw new Error('sampleHeight unsupported'); + }; + assert.equal(surface.sampleHeight(place), undefined, 'a throw is a miss'); +}); + +test('supported flags follow the scene', () => { + const { surface, scene } = setup(); + assert.equal(surface.supported, true); + assert.equal(surface.clampToHeightSupported, true); + assert.equal(surface.terrainHeightSupported, true); + scene.sampleHeightSupported = false; + scene.clampToHeightSupported = false; + scene.globe = undefined; + assert.equal(surface.supported, false); + assert.equal(surface.clampToHeightSupported, false); + assert.equal(surface.terrainHeightSupported, false); + const place = geo.cartographic.fromDegrees(20, 10, 0); + assert.equal(surface.sampleHeight(place), undefined); + assert.equal(surface.terrainHeight(place), undefined); + assert.equal( + surface.clampToHeight(geo.cartesian3.fromDegrees(20, 10, 10)), + undefined, + ); +}); + +test('terrainHeight asks the globe', () => { + const { viewer, surface, scene } = setup(); + const place = geo.cartographic.fromDegrees(20, 10, 0); + assert.equal(surface.terrainHeight(place), 12.5); + assert.equal(viewer.callsOf('globe.getHeight')[0].args[0], place); + scene.globe.getHeight = () => undefined; + assert.equal(surface.terrainHeight(place), undefined); +}); + +test('clampToHeight returns a geo value', () => { + const { viewer, surface } = setup(); + const exclude = []; + const clamped = surface.clampToHeight( + geo.cartesian3.fromDegrees(20, 10, 5000), + exclude, + ); + assert.ok(!(clamped instanceof Cesium.Cartesian3)); + assert.equal(clamped.x, 6378200); + assert.equal(viewer.callsOf('scene.clampToHeight')[0].args[1], exclude); +}); + +test('tilesLoaded follows the first visible tileset, else the shown globe', () => { + const { viewer, surface, scene } = setup(); + assert.equal(surface.tilesLoaded(), true, 'globe loaded'); + scene.globe.tilesLoaded = false; + assert.equal(surface.tilesLoaded(), false, 'globe streaming'); + scene.globe.show = false; + assert.equal(surface.tilesLoaded(), true, 'nothing streams'); + const tileset = { show: true, tilesLoaded: false }; + viewer.primitiveList.push({ show: true }, tileset); + assert.equal(surface.tilesLoaded(), false); + tileset.tilesLoaded = true; + assert.equal(surface.tilesLoaded(), true); +}); diff --git a/src/globe/cesium/testing/fakeViewer.mjs b/src/globe/cesium/testing/fakeViewer.mjs new file mode 100644 index 000000000..18fbd5048 --- /dev/null +++ b/src/globe/cesium/testing/fakeViewer.mjs @@ -0,0 +1,208 @@ +// A recording stand-in for a Cesium Viewer, for the Cesium engine's unit tests. +// It carries real Cesium value types and events (no WebGL) and records every +// call into `viewer.calls` so tests can check what reached which Cesium member. + +import * as Cesium from 'cesium'; + +/** A fake viewer whose members record their calls. */ +export function createFakeViewer({ width = 1024, height = 768 } = {}) { + const calls = []; + const record = + (name, value) => + (...args) => { + calls.push({ name, args }); + return typeof value === 'function' ? value(...args) : value; + }; + const primitiveList = []; + const imageryList = []; + const staticCredits = []; + const creditDisplay = { + addStaticCredit: record('creditDisplay.addStaticCredit', (credit) => + staticCredits.push(credit), + ), + removeStaticCredit: record('creditDisplay.removeStaticCredit', (credit) => { + const index = staticCredits.indexOf(credit); + if (index >= 0) staticCredits.splice(index, 1); + }), + }; + const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) * (height / width)); + const camera = { + positionWC: new Cesium.Cartesian3(7e6, 1e5, 2e5), + directionWC: new Cesium.Cartesian3(-1, 0, 0), + upWC: new Cesium.Cartesian3(0, 0, 1), + rightWC: new Cesium.Cartesian3(0, 1, 0), + heading: Cesium.Math.TWO_PI, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: Cesium.Math.TWO_PI, + frustum: { + fovy, + aspectRatio: width / height, + projectionMatrix: Cesium.Matrix4.fromArray([ + 2, 0, 0, 0, 0, 3, 0, 0, 0, 0, -1, -1, 0, 0, -2, 0, + ]), + }, + viewMatrix: Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(1, 2, 3)), + transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + defaultZoomAmount: 100000, + percentageChanged: 0.5, + changed: new Cesium.Event(), + moveStart: new Cesium.Event(), + moveEnd: new Cesium.Event(), + flights: [], + setView: record('camera.setView'), + flyTo: record('camera.flyTo', (options) => { + // Cesium cancels a running flight before starting the next one. + const previous = camera.flights.at(-1); + if (previous && !previous.done) { + previous.done = true; + previous.options.cancel?.(); + } + const flight = { options, done: false }; + camera.flights.push(flight); + if (options.duration !== undefined && options.duration <= 0) { + flight.done = true; + options.complete?.(); + } + }), + flyToBoundingSphere: record( + 'camera.flyToBoundingSphere', + (sphere, options) => camera.flyTo({ ...options, sphere }), + ), + completeFlight() { + const flight = camera.flights.at(-1); + if (!flight || flight.done) return; + flight.done = true; + flight.options.complete?.(); + }, + cancelFlight: record('camera.cancelFlight', () => { + const flight = camera.flights.at(-1); + if (!flight || flight.done) return; + flight.done = true; + flight.options.cancel?.(); + }), + lookAt: record('camera.lookAt'), + lookAtTransform: record('camera.lookAtTransform', (transform) => + Cesium.Matrix4.clone(transform, camera.transform), + ), + zoomIn: record('camera.zoomIn'), + zoomOut: record('camera.zoomOut'), + computeViewRectangle: record( + 'camera.computeViewRectangle', + () => new Cesium.Rectangle(0.1, 0.2, 0.3, 0.4), + ), + pickEllipsoid: record( + 'camera.pickEllipsoid', + () => new Cesium.Cartesian3(6378137, 0, 0), + ), + getPickRay: record( + 'camera.getPickRay', + () => + new Cesium.Ray( + new Cesium.Cartesian3(7e6, 0, 0), + new Cesium.Cartesian3(-1, 0, 0), + ), + ), + }; + const globe = { + show: true, + tilesLoaded: true, + getHeight: record('globe.getHeight', () => 12.5), + }; + const scene = { + camera, + canvas: { clientWidth: width, clientHeight: height }, + globe, + preUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRenderMode: true, + maximumRenderTimeChange: Infinity, + requestRender: record('scene.requestRender'), + pick: record('scene.pick', () => undefined), + drillPick: record('scene.drillPick', () => []), + pickPositionSupported: true, + pickPosition: record( + 'scene.pickPosition', + () => new Cesium.Cartesian3(6378137, 1, 2), + ), + sampleHeightSupported: true, + sampleHeight: record('scene.sampleHeight', () => 42), + clampToHeightSupported: true, + clampToHeight: record( + 'scene.clampToHeight', + () => new Cesium.Cartesian3(6378200, 0, 0), + ), + screenSpaceCameraController: { enableInputs: true }, + frameState: { creditDisplay }, + primitives: { + get length() { + return primitiveList.length; + }, + get: (index) => primitiveList[index], + add: record('primitives.add', (value) => { + primitiveList.push(value); + return value; + }), + remove: record('primitives.remove', (value) => { + const index = primitiveList.indexOf(value); + if (index < 0) return false; + primitiveList.splice(index, 1); + return true; + }), + contains: (value) => primitiveList.includes(value), + }, + setTerrain: record('scene.setTerrain'), + }; + const viewer = { + calls, + callsOf: (name) => calls.filter((call) => call.name === name), + scene, + camera, + container: { kind: 'container' }, + canvas: scene.canvas, + creditDisplay, + staticCredits, + primitiveList, + imageryList, + terrainProvider: undefined, + imageryLayers: { + add: record('imageryLayers.add', (layer, index) => + imageryList.splice(index ?? imageryList.length, 0, layer), + ), + remove: record('imageryLayers.remove', (layer) => { + const index = imageryList.indexOf(layer); + if (index >= 0) imageryList.splice(index, 1); + return index >= 0; + }), + }, + }; + return viewer; +} + +/** A fake ScreenSpaceEventHandler factory that records its actions. */ +export function createFakeEventHandlers() { + const handlers = []; + const create = (canvas) => { + const actions = new Map(); + const handler = { + canvas, + destroyed: false, + actions, + setInputAction(action, type, modifier) { + actions.set(`${type}:${modifier}`, action); + }, + removeInputAction(type, modifier) { + actions.delete(`${type}:${modifier}`); + }, + fire(type, event, modifier) { + actions.get(`${type}:${modifier}`)?.(event); + }, + destroy() { + handler.destroyed = true; + }, + }; + handlers.push(handler); + return handler; + }; + return { handlers, create }; +} diff --git a/src/globe/contract.js b/src/globe/contract.js index 9537b9bfc..e911b3c43 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -95,8 +95,10 @@ * @property {(screen: Cartesian2) => (Cartesian3 | undefined)} pickEllipsoid * @property {(screen: Cartesian2) => { origin: Cartesian3, direction: Cartesian3 }} pickRay * @property {EngineEvent} changed Listener receives the change fraction. - * @property {EngineEvent} moveStart - * @property {EngineEvent} moveEnd + * @property {EngineEvent} moveStart Fires when the camera starts moving from rest. + * @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 {number} percentageChanged * * @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 7beb28df6..a77a095b0 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -233,7 +233,16 @@ export function createContractSuite( [ 'camera events: changed, moveStart and moveEnd follow a move', async () => { - await nadirView(); + // moveStart marks a move from rest: let the nadir move end first + // (Cesium raises moveEnd only after cameraEventWaitTime, 500 ms). + let rested = 0; + const removeRest = camera.moveEnd.addEventListener(() => rested++); + try { + await nadirView(); + await until(() => rested > 0, 'moveEnd after the nadir view'); + } finally { + removeRest(); + } const previous = camera.percentageChanged; const seen = { changed: 0, moveStart: 0, moveEnd: 0 }; const removers = Object.keys(seen).map((name) => diff --git a/src/maps/controller.js b/src/maps/controller.js index e595ede26..1dad01ccf 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -1,6 +1,7 @@ import { indexMapSources } from './registry.js'; import * as Cesium from 'cesium'; import { createMapCredits } from './credits.js'; +import { applyTerrain } from './terrain.js'; import { acquireImageryComparison } from './imageryComparison.js'; /** @@ -290,8 +291,7 @@ export class MapSourceController { () => terrain.create({ signal: this._abort.signal }), ); if (gen !== this._switchGen) return; - if (result.terrain) this.viewer.scene.setTerrain(result.terrain); - else this.viewer.terrainProvider = result.provider; + applyTerrain(this.viewer, result); this._terrainMode = terrain.id; } return resolution; diff --git a/src/maps/defaultSources.js b/src/maps/defaultSources.js index 99e767418..4062e7a80 100644 --- a/src/maps/defaultSources.js +++ b/src/maps/defaultSources.js @@ -1,13 +1,8 @@ import { MAP_STACKS } from './catalog.js'; import { photorealUnavailableReason } from './availability.js'; import { keySetupRequirement } from '../keySetupCore.mjs'; -import { - createOsmImagery, - createEsriImagery, - createIonImagery, - ESRI_ATTRIBUTION_HTML, -} from './imagery.js'; -import { createWorldTerrain, createKeylessTerrain } from './terrain.js'; +import { imageryFactoryFor, ESRI_ATTRIBUTION_HTML } from './imagery.js'; +import { terrainSourceFor } from './terrain.js'; /** Select sources and setup guidance without putting provider branches in the controller. */ export function createDefaultMapSources({ @@ -18,12 +13,7 @@ export function createDefaultMapSources({ const ionToken = String(cesiumToken || '').trim(); const hasIon = Boolean(ionToken); const hasGoogle = Boolean(String(googleApiKey || '').trim()); - const terrain = { - id: hasIon ? 'world' : 'keyless', - create: hasIon - ? (request) => createWorldTerrain(ionToken, request) - : createKeylessTerrain, - }; + const terrain = terrainSourceFor(ionToken); return { defaultId: googleTileset ? 'photoreal' : 'esri-imagery', unknownId: 'photoreal', @@ -44,12 +34,7 @@ export function createDefaultMapSources({ unavailableReason: photorealUnavailableReason(hasIon || hasGoogle), tileset: googleTileset, }; - const imagery = - descriptor.kind === 'ion' - ? () => createIonImagery(descriptor.style, ionToken) - : descriptor.id === 'osm' - ? createOsmImagery - : createEsriImagery; + const imagery = imageryFactoryFor(descriptor, ionToken); return { ...common, imagery, diff --git a/src/maps/imagery.js b/src/maps/imagery.js index 32fd3f375..82fe9342f 100644 --- a/src/maps/imagery.js +++ b/src/maps/imagery.js @@ -27,3 +27,16 @@ export function createIonImagery(style, accessToken) { if (!accessToken) throw new Error('Ion imagery requires an explicit token'); return Cesium.IonImageryProvider.fromAssetId(style, { accessToken }); } + +/** + * The imagery factory a globe stack descriptor names: ion styles, OSM, or + * Esri. Shared by the default map sources and the Cesium globe engine. + * @param {{ kind: string, style?: number }} descriptor + * @param {string} [ionToken] + * @returns {(request?: { signal?: AbortSignal }) => object | Promise} + */ +export function imageryFactoryFor(descriptor, ionToken = '') { + if (descriptor.kind === 'ion') + return () => createIonImagery(descriptor.style, ionToken); + return descriptor.kind === 'osm' ? createOsmImagery : createEsriImagery; +} diff --git a/src/maps/sourceFactories.test.mjs b/src/maps/sourceFactories.test.mjs index 15571666d..5a26d4f83 100644 --- a/src/maps/sourceFactories.test.mjs +++ b/src/maps/sourceFactories.test.mjs @@ -1,8 +1,18 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createIonImagery } from './imagery.js'; -import { createWorldTerrain, createKeylessTerrain } from './terrain.js'; +import { + createEsriImagery, + createIonImagery, + createOsmImagery, + imageryFactoryFor, +} from './imagery.js'; +import { + applyTerrain, + createWorldTerrain, + createKeylessTerrain, + terrainSourceFor, +} from './terrain.js'; import { createDefaultMapSources } from './defaultSources.js'; test('Esri uses Re:Earth without keys and preserves ion terrain when configured', async () => { @@ -161,3 +171,48 @@ test('both Google 3D routes keep tiles drawing while draped imagery loads', asyn assert.equal(calls[1][1].asynchronouslyLoadImagery, true); assert.equal(calls[1][1].enableCollision, true); }); + +test('imageryFactoryFor picks the factory a stack descriptor names', async () => { + assert.equal(imageryFactoryFor({ id: 'osm', kind: 'osm' }), createOsmImagery); + assert.equal( + imageryFactoryFor({ id: 'esri-imagery', kind: 'esri-imagery' }), + createEsriImagery, + ); + const original = Cesium.IonImageryProvider.fromAssetId; + try { + Cesium.IonImageryProvider.fromAssetId = async (style, options) => ({ + style, + token: options.accessToken, + }); + const ion = imageryFactoryFor( + { id: 'bing-aerial', kind: 'ion', style: 2 }, + 'ion-token', + ); + assert.deepEqual(await ion(), { style: 2, token: 'ion-token' }); + } finally { + Cesium.IonImageryProvider.fromAssetId = original; + } +}); + +test('terrainSourceFor chooses world terrain only with an ion token', () => { + assert.deepEqual(terrainSourceFor(''), { + id: 'keyless', + create: createKeylessTerrain, + }); + const world = terrainSourceFor(' token '); + assert.equal(world.id, 'world'); + assert.equal(typeof world.create, 'function'); +}); + +test('applyTerrain installs a terrain object or a provider', () => { + const calls = []; + const viewer = { + terrainProvider: null, + scene: { setTerrain: (terrain) => calls.push(terrain) }, + }; + applyTerrain(viewer, { provider: 'provider' }); + assert.equal(viewer.terrainProvider, 'provider'); + applyTerrain(viewer, { terrain: 'terrain' }); + assert.deepEqual(calls, ['terrain']); + assert.equal(viewer.terrainProvider, 'provider'); +}); diff --git a/src/maps/terrain.js b/src/maps/terrain.js index f58c1c755..3e19d5d3b 100644 --- a/src/maps/terrain.js +++ b/src/maps/terrain.js @@ -62,3 +62,22 @@ export async function createKeylessTerrain() { return { provider: new Cesium.EllipsoidTerrainProvider() }; } } + +/** + * The terrain source for a session: Cesium World Terrain with an ion token, + * otherwise the keyless Re:Earth mesh. + * @param {string} [ionToken] + * @returns {{ id: 'world' | 'keyless', create: Function }} + */ +export function terrainSourceFor(ionToken = '') { + const token = String(ionToken || '').trim(); + return token + ? { id: 'world', create: (request) => createWorldTerrain(token, request) } + : { id: 'keyless', create: createKeylessTerrain }; +} + +/** Install a terrain factory result (`{ terrain }` or `{ provider }`) on a viewer. */ +export function applyTerrain(viewer, result) { + if (result.terrain) viewer.scene.setTerrain(result.terrain); + else viewer.terrainProvider = result.provider; +} diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js index 2100b19ce..44964f99a 100644 --- a/src/services/groundSnap.js +++ b/src/services/groundSnap.js @@ -144,18 +144,27 @@ const SAMPLE_WINDOW_MS = 250; function _tilesReady(viewer) { const prims = viewer?.scene?.primitives; if (!prims) return false; - for (let i = 0; i < prims.length; i++) { + const tileset = firstVisibleTileset(prims); + return tileset ? tileset.tilesLoaded === true : true; +} + +/** + * The first shown primitive that reports a boolean `tilesLoaded` (a 3D + * tileset, duck-typed), or null. + * @param {{ length: number, get: (index: number) => any }} primitives + * @returns {object | null} + */ +export function firstVisibleTileset(primitives) { + for (let i = 0; i < primitives.length; i++) { let p = null; try { - p = prims.get(i); + p = primitives.get(i); } catch { continue; } - if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') { - return p.tilesLoaded === true; - } + if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') return p; } - return true; + return null; } /**