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.
This commit is contained in:
Sameh Khamis committed 2026-11-03 09:09:55 -08:00
1 parent d5a568e55f
commit 08ff7f3e9d
3 files changed
+163 -4

No files matched your search

+4 -2
View File
@@ -45,8 +45,10 @@ its distance-aware zoom, collision handling and minimum/maximum distance limits.
Fine-pointer desktop mouse and trackpad sensitivity is unchanged. Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
User zoom is capped at 40,000 km (or a stricter host limit). Explicit camera 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 flight, share-link and story destinations can go farther; from there, user zoom
the limit when it computes an automatic bounding-sphere framing distance. 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 Above 300 km, outward movement from the controller's configured zoom bindings
(wheel, pinch distance and unmodified right-drag by default) pitches the camera (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; down towards 8° below the Earth's limb. Large steps reach the limb immediately;
+24 -2
View File
@@ -78,16 +78,35 @@ export function installZoomOutGuard(
const controller = scene?.screenSpaceCameraController; const controller = scene?.screenSpaceCameraController;
const camera = viewer?.camera; const camera = viewer?.camera;
const canvas = viewer?.canvas; 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'); throw new TypeError('A complete Cesium viewer is required');
const aggregator = createAggregator(canvas); const aggregator = createAggregator(canvas);
const originalMaximumZoomDistance = controller.maximumZoomDistance; const originalMaximumZoomDistance = controller.maximumZoomDistance;
const maximumZoomDistance = Math.min( const ownsCeiling = originalMaximumZoomDistance > MAXIMUM_ZOOM_DISTANCE_M;
let maximumZoomDistance = Math.min(
originalMaximumZoomDistance, originalMaximumZoomDistance,
MAXIMUM_ZOOM_DISTANCE_M, MAXIMUM_ZOOM_DISTANCE_M,
); );
controller.maximumZoomDistance = maximumZoomDistance; 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 lastHeight = camera.positionCartographic?.height;
let lastPosition = Cesium.Cartesian3.clone(camera.position); let lastPosition = Cesium.Cartesian3.clone(camera.position);
@@ -175,6 +194,8 @@ export function installZoomOutGuard(
} }
}; };
const removePreRender = scene.preRender.addEventListener(guard); 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?.(() => { const removeCameraHandoff = subscribeCameraHandoff?.(() => {
cancel(); cancel();
// Discard input queued before an explicit navigation action took ownership. // Discard input queued before an explicit navigation action took ownership.
@@ -187,6 +208,7 @@ export function installZoomOutGuard(
active = false; active = false;
removeCameraHandoff?.(); removeCameraHandoff?.();
removePreRender?.(); removePreRender?.();
removePostUpdate?.();
aggregator.destroy(); aggregator.destroy();
if (controller.maximumZoomDistance === maximumZoomDistance) if (controller.maximumZoomDistance === maximumZoomDistance)
controller.maximumZoomDistance = originalMaximumZoomDistance; controller.maximumZoomDistance = originalMaximumZoomDistance;
+135
View File
@@ -35,6 +35,7 @@ test('maxPitchForHeight steepens monotonically with height', () => {
function guardFixture({ maximumZoomDistance = Infinity } = {}) { function guardFixture({ maximumZoomDistance = Infinity } = {}) {
const preRender = new Set(); const preRender = new Set();
const postUpdate = new Set();
const handoffs = new Set(); const handoffs = new Set();
const container = new EventTarget(); const container = new EventTarget();
const canvas = new EventTarget(); const canvas = new EventTarget();
@@ -107,6 +108,12 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) {
return () => preRender.delete(handler); return () => preRender.delete(handler);
}, },
}, },
postUpdate: {
addEventListener(handler) {
postUpdate.add(handler);
return () => postUpdate.delete(handler);
},
},
}; };
const renders = []; const renders = [];
const dispose = installZoomOutGuard( const dispose = installZoomOutGuard(
@@ -125,6 +132,7 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) {
); );
const frame = (height = camera.height) => { const frame = (height = camera.height) => {
camera.height = height; camera.height = height;
for (const handler of postUpdate) handler();
for (const handler of preRender) handler(); for (const handler of preRender) handler();
}; };
const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => { const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => {
@@ -163,6 +171,7 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) {
scene, scene,
aggregator, aggregator,
preRender, preRender,
postUpdate,
handoffs, handoffs,
handoff: () => { handoff: () => {
for (const listener of handoffs) listener(); 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(); const f = guardFixture();
assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M); assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
assert.equal(f.preRender.size, 1); assert.equal(f.preRender.size, 1);
assert.equal(f.postUpdate.size, 1);
f.frame(); f.frame();
assert.ok(f.aggregator.resets > 0); assert.ok(f.aggregator.resets > 0);
f.dispose(); 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.controller.maximumZoomDistance, Infinity);
assert.equal(f.aggregator.destroys, 1); assert.equal(f.aggregator.destroys, 1);
assert.equal(f.preRender.size, 0); assert.equal(f.preRender.size, 0);
assert.equal(f.postUpdate.size, 0);
assert.equal(f.handoffs.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, mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1, pixelRatio: 1,
preRender: new Cesium.Event(), preRender: new Cesium.Event(),
postUpdate: new Cesium.Event(),
screenSpaceCameraController: { screenSpaceCameraController: {
maximumZoomDistance: Infinity, maximumZoomDistance: Infinity,
enableInputs: true, enableInputs: true,
@@ -560,3 +572,126 @@ test('explicit navigation cancels outward inertia before a climbing UI tilt', ()
assert.ok(f.camera.views.length > writes); assert.ok(f.camera.views.length > writes);
f.dispose(); 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();
}
});