From 764b40950fa5119c5912c2cdb1b9ada21359f6f4 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 16:32:07 -0800 Subject: [PATCH] Set the field of view and read the matrices through the camera port setFov holds the vertical angle it is given across resizes (Cesium keeps the wider one), waits for a frustum with a field of view, and rejects angles outside (0, pi). matrices returns Cesium's view and projection matrices, ECEF to eye in 3D, the only mode Core runs. The conformance run checks both on Cesium. --- src/render/cesium/appRenderer.test.mjs | 2 -- src/render/cesium/camera.js | 40 +++++++++++++++++++++++++- src/render/cesium/cameraPort.test.mjs | 20 ++++++++++++- src/render/cesium/harness.js | 2 ++ 4 files changed, 60 insertions(+), 4 deletions(-) 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',