diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index c0f6eaf05..2c565a710 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -333,8 +333,6 @@ const EXPECTED_MISSING = [ 'camera.controls.suspend', 'camera.controls.zoomFactor', 'camera.controls.zoomInputs', - 'camera.matrices', - 'camera.setFov', 'draw.atlas', 'draw.clampState', 'draw.on', diff --git a/src/render/cesium/camera.js b/src/render/cesium/camera.js index d61f1bc53..75a64debc 100644 --- a/src/render/cesium/camera.js +++ b/src/render/cesium/camera.js @@ -91,7 +91,10 @@ export function createCesiumCamera({ scene, camera, report, widget }) { report, }); removers.push( - scene.postUpdate.addEventListener(() => cameraEvents.stepChanged()), + scene.postUpdate.addEventListener(() => { + applyFov(); + cameraEvents.stepChanged(); + }), camera.moveStart.addEventListener(() => cameraEvents.emit('moveStart')), camera.moveEnd.addEventListener(() => cameraEvents.emit('moveEnd')), ); @@ -176,6 +179,23 @@ export function createCesiumCamera({ scene, camera, report, widget }) { return handle; } + /** + * The vertical field of view `setFov` asked for, held across resizes: + * Cesium's `fov` is the wider of the two angles (the horizontal one when + * the view is wider than tall), and it keeps that one when the aspect + * changes. Before the first resize, and on a frustum without a field of + * view, it waits. + */ + let heldFovy = null; + function applyFov() { + const frustum = camera.frustum; + const aspect = frustum?.aspectRatio; + if (heldFovy === null || !('fov' in frustum) || !(aspect > 0)) return; + const fov = + aspect > 1 ? 2 * Math.atan(Math.tan(heldFovy / 2) * aspect) : heldFovy; + if (frustum.fov !== fov) frustum.fov = fov; + } + const cameraPort = { on: (event, cb, options) => cameraEvents.on(event, cb, options), onCommand: (cb) => commandHooks.on(cb), @@ -288,6 +308,24 @@ export function createCesiumCamera({ scene, camera, report, widget }) { else camera.zoomIn(metres); scene.requestRender(); }, + setFov({ vertical }) { + // Cesium would take a bad angle and draw a garbage projection. + if (!(vertical > 0 && vertical < Math.PI)) + throw new RangeError(`no vertical field of view of ${vertical}`); + heldFovy = vertical; + applyFov(); + scene.requestRender(); + }, + // The scene's own frame: ECEF in 3D, the only mode Core runs (Cesium's + // 2D and Columbus view frames are projected map coordinates). + matrices() { + return { + view: Float64Array.from(Cesium.Matrix4.toArray(camera.viewMatrix)), + projection: Float64Array.from( + Cesium.Matrix4.toArray(camera.frustum.projectionMatrix), + ), + }; + }, viewRectangle() { const rect = camera.computeViewRectangle(Cesium.Ellipsoid.WGS84); if (!rect) return null; diff --git a/src/render/cesium/cameraPort.test.mjs b/src/render/cesium/cameraPort.test.mjs index a12572d26..efd9f73a9 100644 --- a/src/render/cesium/cameraPort.test.mjs +++ b/src/render/cesium/cameraPort.test.mjs @@ -25,7 +25,7 @@ const QUERY_RECORDS = new Set([ 'occluded', ]); /** Commands and records the port does not have yet. */ -const MISSING = new Set(['matrices']); +const MISSING = new Set([]); const runnable = CAMERA_SCRIPTS.filter((script) => script.steps.every( @@ -232,3 +232,21 @@ test('a Cesium viewer with no renderer bound gets a camera port over its own cam assert.throws(() => cameraOf({ scene, camera: {} }), /no renderer camera/); }); }); + +test('setFov holds the vertical angle across resizes, and waits for a first one', () => { + const { scene, camera } = headlessCamera(); + scene.postUpdate = new Cesium.Event(); + scene.requestRender = () => {}; + camera.frustum.aspectRatio = NaN; + const { port } = createCesiumCamera({ scene, camera, report: () => {} }); + port.setFov({ vertical: 0.5 }); + for (const aspect of [16 / 9, 0.6, 4 / 3]) { + camera.frustum.aspectRatio = aspect; + scene.postUpdate.raiseEvent(scene); + assert.ok(Math.abs(camera.frustum.fovy - 0.5) < 1e-12, `aspect ${aspect}`); + } + camera.switchToOrthographicFrustum(); + assert.doesNotThrow(() => port.setFov({ vertical: 0.4 })); + assert.throws(() => port.setFov({ vertical: Math.PI }), RangeError); + assert.throws(() => port.setFov({ vertical: NaN }), RangeError); +}); diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 184343c7a..15bf22588 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -27,6 +27,8 @@ export const CESIUM_CHECKS = new Set([ 'camera.trace.flyTo with a duration of 0 completes inside the call', 'camera.trace.flyTo where the camera already is completes inside the call', 'camera.viewRectangle', + 'camera.fov', + 'camera.trace.view and projection matrices', 'draw.drillPick', 'draw.ids', 'draw.models',