From 08ff7f3e9dc4a2f5eb291c869e80cf35959b84f7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 09:09:55 -0800 Subject: [PATCH] fix(camera): let a camera above the zoom ceiling zoom in without a jump Cesium clamps any zoom that starts above maximumZoomDistance to that limit, in either direction, so a share link or flight that left the camera above 40,000 km jumped to the ceiling on the first wheel tick. While the camera is above the ceiling, the guard now uses its current height as the limit and lowers it as the camera descends. --- docs/CURRENT-STATE.md | 6 +- src/app/zoomOutGuard.js | 26 ++++++- src/app/zoomOutGuard.test.mjs | 135 ++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a8910d904..124309141 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -45,8 +45,10 @@ its distance-aware zoom, collision handling and minimum/maximum distance limits. Fine-pointer desktop mouse and trackpad sensitivity is unchanged. User zoom is capped at 40,000 km (or a stricter host limit). Explicit camera -flight, share-link and story destinations can go farther; Cesium can still use -the limit when it computes an automatic bounding-sphere framing distance. +flight, share-link and story destinations can go farther; from there, user zoom +can't climb any higher and zooms in gradually from the current height, with no +jump to the cap. Cesium can still use the limit when it computes an automatic +bounding-sphere framing distance. Above 300 km, outward movement from the controller's configured zoom bindings (wheel, pinch distance and unmodified right-drag by default) pitches the camera down towards 8° below the Earth's limb. Large steps reach the limb immediately; diff --git a/src/app/zoomOutGuard.js b/src/app/zoomOutGuard.js index ccf6ca78b..5e8829b5a 100644 --- a/src/app/zoomOutGuard.js +++ b/src/app/zoomOutGuard.js @@ -78,16 +78,35 @@ export function installZoomOutGuard( const controller = scene?.screenSpaceCameraController; const camera = viewer?.camera; const canvas = viewer?.canvas; - if (!controller || !camera || !canvas || !scene.preRender) + if ( + !controller || + !camera || + !canvas || + !scene.preRender || + !scene.postUpdate + ) throw new TypeError('A complete Cesium viewer is required'); const aggregator = createAggregator(canvas); const originalMaximumZoomDistance = controller.maximumZoomDistance; - const maximumZoomDistance = Math.min( + const ownsCeiling = originalMaximumZoomDistance > MAXIMUM_ZOOM_DISTANCE_M; + let maximumZoomDistance = Math.min( originalMaximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M, ); controller.maximumZoomDistance = maximumZoomDistance; + // Cesium clamps any zoom that starts above the limit to the limit, in either + // direction. A camera placed higher by a flight or share link keeps its own + // height as the limit, so it zooms in gradually and can't climb further. + const followCeiling = () => { + if (!ownsCeiling || controller.maximumZoomDistance !== maximumZoomDistance) + return; + const height = camera.positionCartographic?.height; + maximumZoomDistance = + height > MAXIMUM_ZOOM_DISTANCE_M ? height : MAXIMUM_ZOOM_DISTANCE_M; + controller.maximumZoomDistance = maximumZoomDistance; + }; + followCeiling(); let lastHeight = camera.positionCartographic?.height; let lastPosition = Cesium.Cartesian3.clone(camera.position); @@ -175,6 +194,8 @@ 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); const removeCameraHandoff = subscribeCameraHandoff?.(() => { cancel(); // Discard input queued before an explicit navigation action took ownership. @@ -187,6 +208,7 @@ export function installZoomOutGuard( active = false; removeCameraHandoff?.(); removePreRender?.(); + removePostUpdate?.(); aggregator.destroy(); if (controller.maximumZoomDistance === maximumZoomDistance) controller.maximumZoomDistance = originalMaximumZoomDistance; diff --git a/src/app/zoomOutGuard.test.mjs b/src/app/zoomOutGuard.test.mjs index 400113b79..408fc830f 100644 --- a/src/app/zoomOutGuard.test.mjs +++ b/src/app/zoomOutGuard.test.mjs @@ -35,6 +35,7 @@ test('maxPitchForHeight steepens monotonically with height', () => { function guardFixture({ maximumZoomDistance = Infinity } = {}) { const preRender = new Set(); + const postUpdate = new Set(); const handoffs = new Set(); const container = new EventTarget(); const canvas = new EventTarget(); @@ -107,6 +108,12 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) { return () => preRender.delete(handler); }, }, + postUpdate: { + addEventListener(handler) { + postUpdate.add(handler); + return () => postUpdate.delete(handler); + }, + }, }; const renders = []; const dispose = installZoomOutGuard( @@ -125,6 +132,7 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) { ); const frame = (height = camera.height) => { camera.height = height; + for (const handler of postUpdate) handler(); for (const handler of preRender) handler(); }; const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => { @@ -163,6 +171,7 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) { scene, aggregator, preRender, + postUpdate, handoffs, handoff: () => { for (const listener of handoffs) listener(); @@ -402,6 +411,7 @@ test('installer sets the ceiling and disposer releases aggregator and frame list const f = guardFixture(); assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M); assert.equal(f.preRender.size, 1); + assert.equal(f.postUpdate.size, 1); f.frame(); assert.ok(f.aggregator.resets > 0); f.dispose(); @@ -409,6 +419,7 @@ test('installer sets the ceiling and disposer releases aggregator and frame list assert.equal(f.controller.maximumZoomDistance, Infinity); assert.equal(f.aggregator.destroys, 1); assert.equal(f.preRender.size, 0); + assert.equal(f.postUpdate.size, 0); assert.equal(f.handoffs.size, 0); }); @@ -466,6 +477,7 @@ test('a real Cesium camera settles across the north heading wrap at Istanbul', ( mode: Cesium.SceneMode.SCENE3D, pixelRatio: 1, preRender: new Cesium.Event(), + postUpdate: new Cesium.Event(), screenSpaceCameraController: { maximumZoomDistance: Infinity, enableInputs: true, @@ -560,3 +572,126 @@ test('explicit navigation cancels outward inertia before a climbing UI tilt', () assert.ok(f.camera.views.length > writes); f.dispose(); }); + +// A real Cesium camera and controller, fed by DOM wheel events on the canvas. +function realControllerScene(startHeight) { + const canvas = Object.assign(new EventTarget(), { + clientWidth: 1200, + clientHeight: 800, + width: 1200, + height: 800, + style: {}, + // Keep Cesium's input handler off the missing `document`. + disableRootEvents: true, + onwheel: null, + getBoundingClientRect: () => ({ + left: 0, + top: 0, + width: 1200, + height: 800, + }), + }); + const scene = { + canvas, + drawingBufferWidth: 1200, + drawingBufferHeight: 800, + mapProjection: new Cesium.GeographicProjection(), + ellipsoid: Cesium.Ellipsoid.WGS84, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + verticalExaggeration: 1, + verticalExaggerationRelativeHeight: 0, + preRender: new Cesium.Event(), + postUpdate: new Cesium.Event(), + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + const controller = new Cesium.ScreenSpaceCameraController(scene); + controller.inertiaZoom = 0; + scene.screenSpaceCameraController = controller; + const setHeight = (h) => + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, h), + orientation: { heading: 0, pitch: -Cesium.Math.PI_OVER_TWO, roll: 0 }, + }); + setHeight(startHeight); + const dispose = installZoomOutGuard( + { scene, camera, canvas }, + { requestRender() {} }, + ); + // Scene.render order: the controller applies input, then postUpdate, then + // preRender on frames that draw. + const frame = () => { + controller.update(); + scene.postUpdate.raiseEvent(scene); + scene.preRender.raiseEvent(scene); + }; + const wheel = (deltaY) => + canvas.dispatchEvent( + Object.assign(new Event('wheel'), { + deltaY, + deltaMode: 0, + clientX: 600, + clientY: 400, + }), + ); + const height = () => camera.positionCartographic.height; + return { + scene, + camera, + controller, + setHeight, + frame, + wheel, + height, + dispose, + }; +} + +test('a camera placed above the ceiling cannot zoom out and zooms in without a jump', () => { + const s = realControllerScene(60_000_000); + try { + s.frame(); + s.wheel(1); + s.frame(); + assert.ok(Math.abs(s.height() - 60_000_000) < 1, `${s.height()} m`); + s.wheel(-1); + s.frame(); + const firstStep = s.height(); + assert.ok(firstStep < 60_000_000, 'zooms in'); + assert.ok(firstStep > 50_000_000, `${firstStep} m is gradual`); + // The camera can no longer climb back above where it has descended to. + s.wheel(1); + s.frame(); + assert.ok(Math.abs(s.height() - firstStep) < 1, `${s.height()} m`); + for (let i = 0; i < 40; i++) { + s.wheel(-100); + s.frame(); + } + assert.ok(s.height() < MAXIMUM_ZOOM_DISTANCE_M); + assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M); + for (let i = 0; i < 80; i++) { + s.wheel(100); + s.frame(); + } + assert.ok(Math.abs(s.height() - MAXIMUM_ZOOM_DISTANCE_M) < 1_000); + } finally { + s.dispose(); + } + assert.equal(s.controller.maximumZoomDistance, Infinity); +}); + +test('a share-link view restored above the ceiling is honoured by later frames', () => { + const s = realControllerScene(1_000_000); + try { + s.frame(); + s.setHeight(97_679_259); + s.frame(); + s.wheel(100); + s.frame(); + assert.ok(Math.abs(s.height() - 97_679_259) < 1, `${s.height()} m`); + } finally { + s.dispose(); + } +});