From e4b39331665c21722078e6bc3f859e14258c33dd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 09:10:30 -0800 Subject: [PATCH] fix(camera): read zoom input at the ceiling when the scene is idle The guard reads its input in preRender, which Cesium only fires on frames that draw. In request-render mode, wheel input at the ceiling doesn't move the camera, so nothing drew and the input was applied later by an unrelated frame. The guard now requests a frame through the render governor whenever it has unread input. --- src/app/zoomOutGuard.js | 14 ++++++++++++-- src/app/zoomOutGuard.test.mjs | 21 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/app/zoomOutGuard.js b/src/app/zoomOutGuard.js index 5e8829b5a..155f66566 100644 --- a/src/app/zoomOutGuard.js +++ b/src/app/zoomOutGuard.js @@ -194,8 +194,18 @@ export function installZoomOutGuard( } }; const removePreRender = scene.preRender.addEventListener(guard); - // Fires every frame, after Cesium has applied this frame's input. - const removePostUpdate = scene.postUpdate.addEventListener(followCeiling); + // Fires every frame, after Cesium has applied this frame's input, while + // preRender fires only on frames that draw. Input that didn't move the camera + // (such as wheel at the ceiling) would otherwise wait for an unrelated frame. + const removePostUpdate = scene.postUpdate.addEventListener(() => { + followCeiling(); + if ( + INPUT_BINDINGS.some(({ type, modifier }) => + aggregator.isMoving(type, modifier), + ) + ) + requestRender('zoom-out-guard'); + }); const removeCameraHandoff = subscribeCameraHandoff?.(() => { cancel(); // Discard input queued before an explicit navigation action took ownership. diff --git a/src/app/zoomOutGuard.test.mjs b/src/app/zoomOutGuard.test.mjs index 408fc830f..5821a5c87 100644 --- a/src/app/zoomOutGuard.test.mjs +++ b/src/app/zoomOutGuard.test.mjs @@ -130,9 +130,13 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) { requestRender: (reason) => renders.push(reason), }, ); + // A frame Cesium skips drawing in request-render mode: no preRender. + const idleFrame = () => { + for (const handler of postUpdate) handler(); + }; const frame = (height = camera.height) => { camera.height = height; - for (const handler of postUpdate) handler(); + idleFrame(); for (const handler of preRender) handler(); }; const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => { @@ -179,6 +183,7 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) { renders, dispose, frame, + idleFrame, movement, }; } @@ -454,6 +459,20 @@ test('outward input at the ceiling reframes a deliberately tilted camera', () => f.dispose(); }); +test('input that leaves the camera still requests a frame to read it', () => { + const f = guardFixture(); + f.frame(MAXIMUM_ZOOM_DISTANCE_M); + f.idleFrame(); + assert.deepEqual(f.renders, [], 'an idle scene stays idle'); + // At the ceiling Cesium doesn't move the camera, so nothing would draw. + f.movement(WHEEL, -10); + f.idleFrame(); + assert.deepEqual(f.renders, ['zoom-out-guard']); + f.frame(); + assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25), 'read that frame'); + f.dispose(); +}); + test('equivalent heading and roll wraps do not cancel stationary settling', () => { const f = guardFixture(); f.frame(500_000);