fix: respect camera ownership and finish outward zoom framing

This commit is contained in:
Said Turksever committed 2026-11-03 14:32:30 +00:00
1 parent 895ef536ca
commit a236504347
8 files changed
+951 -152

No files matched your search

+7
View File
@@ -1,6 +1,7 @@
import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { installZoomOutGuard } from './zoomOutGuard.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
import { t } from '../i18n/index.js';
@@ -41,6 +42,12 @@ export function createApplicationControls({
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
defer(() => styleManager.dispose());
defer(
installZoomOutGuard(viewer, {
subscribeCameraHandoff: (listener) =>
styleManager.subscribeCameraHandoff?.(listener),
}),
);
// The previous multi-canvas weather compositor remains disabled. Cockpit
// clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode.
-2
View File
@@ -4,7 +4,6 @@ import {
createApplicationViewer,
installTrackpadPinchZoom,
} from '../app/viewer.js';
import { installZoomOutGuard } from './zoomOutGuard.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
@@ -59,7 +58,6 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
defer(installZoomOutGuard(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
+143 -55
View File
@@ -1,18 +1,24 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js';
/** Whole-Earth view. Programmatic flights ignore it; only user input stops here. */
/** Whole-Earth input limit; explicit camera destinations can go farther. */
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
/** Below this, oblique browsing is left alone. */
export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
/** Look this far below the Earth's limb, so the globe stays on screen. */
export const LIMB_MARGIN_RAD = Cesium.Math.toRadians(8);
/** How long after the last zoom input the camera still counts as user-zoomed. */
export const USER_ZOOM_WINDOW_MS = 500;
/** Fraction of the remaining pitch error closed per frame. */
const PITCH_EASE = 0.35;
const PITCH_SNAP_RAD = Cesium.Math.toRadians(0.1);
const HEIGHT_EPSILON_M = 0.0001;
const EARTH_RADIUS_M = 6_371_000;
const INPUT_BINDINGS = Object.values(Cesium.CameraEventType).flatMap((type) =>
[undefined, ...Object.values(Cesium.KeyboardEventModifier)].map(
(modifier) => ({
type,
modifier,
}),
),
);
/**
* The shallowest pitch (radians, negative is down) that still keeps the Earth
@@ -25,82 +31,164 @@ export function maxPitchForHeight(h) {
return -Math.min(limb + LIMB_MARGIN_RAD, Cesium.Math.PI_OVER_TWO);
}
function isUserZoomInput(event) {
if (event.type === 'wheel') return true;
if (event.type === 'touchmove') return (event.touches?.length ?? 0) >= 2;
// Cesium's default zoom bindings also include a right-button drag.
return event.type === 'pointermove' && (event.buttons & 2) === 2;
function matchesZoomBinding(bindings, type, modifier) {
if (Array.isArray(bindings))
return bindings.some((binding) =>
matchesZoomBinding(binding, type, modifier),
);
return typeof bindings === 'number'
? bindings === type && modifier === undefined
: bindings?.eventType === type && bindings?.modifier === modifier;
}
function readInput(aggregator, bindings) {
let outward = false;
let cancel = false;
for (const { type, modifier } of INPUT_BINDINGS) {
if (!aggregator.isMoving(type, modifier)) continue;
const movement = aggregator.getMovement(type, modifier);
const distance =
type === Cesium.CameraEventType.PINCH ? movement.distance : movement;
const delta = distance.endPosition.y - distance.startPosition.y;
if (matchesZoomBinding(bindings, type, modifier) && delta !== 0) {
outward ||= delta < 0;
cancel ||= delta > 0;
} else {
// Includes modifier-specific tilts and parallel two-finger movement.
cancel = true;
}
}
aggregator.reset();
return { outward, cancel };
}
/**
* Stop a tilted wheel/pinch zoom-out from flying the camera off into space.
*
* Cesium zooms a shallow view out by backing along the cursor ray with the
* pitch held fixed, and with no ceiling. The limb drops below a fixed pitch
* by ~700 km, then the steps grow with height and never stop. This caps the
* zoom distance and, only while the user is zooming out, pitches the view
* down to keep the globe framed. Returns its disposer.
* Keep outward user zooms bounded and framed, without taking camera ownership
* from tilts, tracked entities or flights. Returns an idempotent disposer.
*/
export function installZoomOutGuard(
viewer,
{ now = () => performance.now(), requestRender = governorRequestRender } = {},
{
requestRender = governorRequestRender,
subscribeCameraHandoff,
createAggregator = (canvas) => new Cesium.CameraEventAggregator(canvas),
} = {},
) {
const scene = viewer?.scene;
const controller = scene?.screenSpaceCameraController;
const camera = viewer?.camera;
const container = viewer?.container;
if (!controller || !camera || !container || !scene.preRender)
const canvas = viewer?.canvas;
if (!controller || !camera || !canvas || !scene.preRender)
throw new TypeError('A complete Cesium viewer is required');
const aggregator = createAggregator(canvas);
const originalMaximumZoomDistance = controller.maximumZoomDistance;
controller.maximumZoomDistance = MAXIMUM_ZOOM_DISTANCE_M;
const maximumZoomDistance = Math.min(
originalMaximumZoomDistance,
MAXIMUM_ZOOM_DISTANCE_M,
);
controller.maximumZoomDistance = maximumZoomDistance;
let lastUserZoomAt = -Infinity;
const markInput = (event) => {
if (isUserZoomInput(event)) lastUserZoomAt = now();
let lastHeight = camera.positionCartographic?.height;
let lastPosition = Cesium.Cartesian3.clone(camera.position);
let lastHeading = camera.heading;
let lastPitch = camera.pitch;
let lastRoll = camera.roll;
let zoomingOut = false;
let settling = false;
const cancel = () => {
zoomingOut = false;
settling = false;
};
const inputTypes = ['wheel', 'touchmove', 'pointermove'];
for (const type of inputTypes)
container.addEventListener(type, markInput, {
capture: true,
passive: true,
});
let lastHeight = NaN;
const guard = () => {
const input = readInput(aggregator, controller.zoomEventTypes);
const height = camera.positionCartographic?.height;
const previousHeight = lastHeight;
lastHeight = height;
if (!(height > previousHeight)) return; // Zoom-out only.
if (now() - lastUserZoomAt > USER_ZOOM_WINDOW_MS) return;
if (controller.enableInputs === false) return;
if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY))
return;
// Cesium keeps no public flag for a running flyTo; never fight one.
if (camera._currentFlight) return;
const maxPitch = maxPitchForHeight(height);
if (maxPitch === null || !(camera.pitch > maxPitch)) return;
const error = maxPitch - camera.pitch;
const pitch =
Math.abs(error) <= PITCH_SNAP_RAD
? maxPitch
: camera.pitch + error * PITCH_EASE;
camera.setView({
orientation: { heading: camera.heading, pitch, roll: camera.roll },
});
lastHeight = camera.positionCartographic?.height;
requestRender('zoom-out-guard');
const climbing = height > lastHeight + HEIGHT_EPSILON_M;
const descending = height < lastHeight - HEIGHT_EPSILON_M;
try {
if (
scene.mode !== Cesium.SceneMode.SCENE3D ||
controller.enableInputs === false ||
controller.enableZoom === false ||
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY) ||
// Cesium has no public running-flight flag.
camera._currentFlight ||
input.cancel ||
(descending && !settling)
) {
cancel();
return;
}
if (input.outward) {
zoomingOut = true;
// A genuine outward gesture can be blocked by the controller's ceiling.
settling = true;
}
if (climbing) {
if (!zoomingOut) return;
settling = true;
} else {
zoomingOut = false;
// Outward inertia can lower altitude when Cesium clamps slant range at
// the ceiling. Finish that correction; fresh inward input cancels above.
// A stationary correction owns only the pose it last produced.
if (
!input.outward &&
((!descending &&
!Cesium.Cartesian3.equals(camera.position, lastPosition)) ||
Math.abs(Cesium.Math.negativePiToPi(camera.heading - lastHeading)) >
1e-10 ||
Math.abs(camera.pitch - lastPitch) > 1e-10 ||
Math.abs(Cesium.Math.negativePiToPi(camera.roll - lastRoll)) >
1e-10)
)
settling = false;
if (!settling) return;
}
const maxPitch = maxPitchForHeight(height);
if (maxPitch === null || !(camera.pitch > maxPitch)) {
settling = false;
} else {
const error = maxPitch - camera.pitch;
const easedPitch =
Math.abs(error) <= PITCH_SNAP_RAD
? maxPitch
: camera.pitch + error * PITCH_EASE;
// A large wheel step must not leave the view above the limb while easing.
const limbPitch = -Math.acos(
EARTH_RADIUS_M / (EARTH_RADIUS_M + height),
);
const pitch = Math.min(easedPitch, limbPitch);
camera.setView({
orientation: { heading: camera.heading, pitch, roll: camera.roll },
});
settling = pitch > maxPitch;
}
// Observe the end of inertia too; no arbitrary timeout cuts off a long zoom.
if (zoomingOut || settling) requestRender('zoom-out-guard');
} finally {
lastHeight = camera.positionCartographic?.height;
lastPosition = Cesium.Cartesian3.clone(camera.position, lastPosition);
lastHeading = camera.heading;
lastPitch = camera.pitch;
lastRoll = camera.roll;
}
};
const removePreRender = scene.preRender.addEventListener(guard);
const removeCameraHandoff = subscribeCameraHandoff?.(() => {
cancel();
// Discard input queued before an explicit navigation action took ownership.
aggregator.reset();
});
let active = true;
return () => {
if (!active) return;
active = false;
removeCameraHandoff?.();
removePreRender?.();
for (const type of inputTypes)
container.removeEventListener(type, markInput, true);
if (controller.maximumZoomDistance === MAXIMUM_ZOOM_DISTANCE_M)
aggregator.destroy();
if (controller.maximumZoomDistance === maximumZoomDistance)
controller.maximumZoomDistance = originalMaximumZoomDistance;
};
}
+446 -84
View File
@@ -5,10 +5,12 @@ import {
installZoomOutGuard,
maxPitchForHeight,
MAXIMUM_ZOOM_DISTANCE_M,
USER_ZOOM_WINDOW_MS,
} from './zoomOutGuard.js';
const deg = Cesium.Math.toDegrees;
const { WHEEL, PINCH, RIGHT_DRAG, LEFT_DRAG, MIDDLE_DRAG } =
Cesium.CameraEventType;
const { CTRL, SHIFT, ALT } = Cesium.KeyboardEventModifier;
test('maxPitchForHeight leaves low-altitude tilt alone', () => {
assert.equal(maxPitchForHeight(0), null);
@@ -18,7 +20,6 @@ test('maxPitchForHeight leaves low-altitude tilt alone', () => {
});
test('maxPitchForHeight looks 8 degrees below the limb', () => {
// Limb at 1,000 km is 30.2 degrees below the horizontal.
assert.ok(Math.abs(deg(maxPitchForHeight(1_000_000)) + 38.19) < 0.01);
assert.equal(deg(maxPitchForHeight(1e12)), -90);
});
@@ -33,31 +34,51 @@ test('maxPitchForHeight steepens monotonically with height', () => {
});
function guardFixture({ maximumZoomDistance = Infinity } = {}) {
let clock = 0;
const listeners = new Map();
const preRender = new Set();
const container = {
addEventListener(type, handler, options) {
assert.equal(options.capture, true);
listeners.set(type, handler);
const handoffs = new Set();
const container = new EventTarget();
const canvas = new EventTarget();
const movements = new Map();
const key = (type, modifier) => `${type}:${modifier}`;
const aggregator = {
resets: 0,
destroys: 0,
isMoving: (type, modifier) => movements.has(key(type, modifier)),
getMovement: (type, modifier) => movements.get(key(type, modifier)),
reset() {
movements.clear();
this.resets++;
},
removeEventListener(type, handler, capture) {
assert.equal(capture, true);
if (listeners.get(type) === handler) listeners.delete(type);
},
emit(event) {
listeners.get(event.type)?.(event);
destroy() {
this.destroys++;
movements.clear();
},
};
const camera = {
height: 170_000,
longitude: 0,
latitude: 0,
heading: 0.4,
pitch: Cesium.Math.toRadians(-25),
roll: 0,
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
views: [],
get positionCartographic() {
return { height: this.height };
return {
height: this.height,
longitude: this.longitude,
latitude: this.latitude,
};
},
get position() {
return this.positionWC;
},
get positionWC() {
return Cesium.Cartesian3.fromRadians(
this.longitude,
this.latitude,
this.height,
);
},
setView({ orientation }) {
this.views.push(orientation);
@@ -66,51 +87,103 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) {
this.roll = orientation.roll;
},
};
const controller = { maximumZoomDistance, enableInputs: true };
const viewer = {
camera,
container,
scene: {
screenSpaceCameraController: controller,
preRender: {
addEventListener(handler) {
preRender.add(handler);
return () => preRender.delete(handler);
},
const controller = {
maximumZoomDistance,
enableInputs: true,
enableZoom: true,
zoomEventTypes: [
RIGHT_DRAG,
WHEEL,
PINCH,
{ eventType: WHEEL, modifier: CTRL },
],
};
const scene = {
mode: Cesium.SceneMode.SCENE3D,
screenSpaceCameraController: controller,
preRender: {
addEventListener(handler) {
preRender.add(handler);
return () => preRender.delete(handler);
},
},
};
const renders = [];
const dispose = installZoomOutGuard(viewer, {
now: () => clock,
requestRender: (reason) => renders.push(reason),
});
const frame = (height) => {
const dispose = installZoomOutGuard(
{ camera, container, canvas, scene },
{
subscribeCameraHandoff(listener) {
handoffs.add(listener);
return () => handoffs.delete(listener);
},
createAggregator(element) {
assert.equal(element, canvas);
return aggregator;
},
requestRender: (reason) => renders.push(reason),
},
);
const frame = (height = camera.height) => {
camera.height = height;
for (const handler of preRender) handler();
};
const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => {
const distance = {
startPosition: new Cesium.Cartesian2(0, 0),
endPosition: new Cesium.Cartesian2(0, deltaY),
};
movements.set(
key(type, modifier),
type === PINCH
? {
distance,
angleAndHeight: {
startPosition: new Cesium.Cartesian2(0, 0),
endPosition: new Cesium.Cartesian2(0, tiltDeltaY),
},
}
: distance,
);
// Also exercise the former raw DOM path when running against the PR head.
const event = new Event(
type === WHEEL ? 'wheel' : type === PINCH ? 'touchmove' : 'pointermove',
);
Object.assign(event, {
buttons: type === RIGHT_DRAG ? 2 : type === MIDDLE_DRAG ? 4 : 1,
ctrlKey: modifier === CTRL,
shiftKey: modifier === SHIFT,
altKey: modifier === ALT,
touches: type === PINCH ? [1, 2] : [],
});
container.dispatchEvent(event);
};
return {
camera,
controller,
container,
listeners,
scene,
aggregator,
preRender,
handoffs,
handoff: () => {
for (const listener of handoffs) listener();
},
renders,
dispose,
frame,
advance: (ms) => (clock += ms),
movement,
};
}
test('a tilted wheel zoom-out pitches down to keep the globe framed', () => {
test('a tilted wheel zoom-out pitches down and preserves heading and roll', () => {
const f = guardFixture();
f.frame(170_000);
f.container.emit({ type: 'wheel' });
f.frame();
f.movement(WHEEL, -10);
f.frame(1_000_000);
const target = maxPitchForHeight(1_000_000);
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25));
assert.ok(f.camera.pitch > target, 'eases rather than snapping');
assert.equal(f.camera.heading, 0.4);
assert.equal(f.camera.roll, 0);
for (let i = 1; i <= 40; i++) f.frame(1_000_000 + i);
assert.ok(
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-3,
@@ -118,74 +191,232 @@ test('a tilted wheel zoom-out pitches down to keep the globe framed', () => {
assert.ok(f.renders.length > 0);
});
test('two-finger touch and right-drag count as zoom input, one finger does not', () => {
test('a large outward step immediately keeps the Earth limb in view', () => {
const f = guardFixture();
f.frame(500_000);
f.container.emit({ type: 'touchmove', touches: [1] });
f.container.emit({ type: 'pointermove', buttons: 1 });
f.frame(600_000);
assert.equal(f.camera.views.length, 0);
f.container.emit({ type: 'touchmove', touches: [1, 2] });
f.frame(700_000);
assert.equal(f.camera.views.length, 1);
f.container.emit({ type: 'pointermove', buttons: 2 });
f.frame(800_000);
assert.equal(f.camera.views.length, 2);
f.movement(WHEEL, -100);
f.frame(20_000_000);
const limbPitch = -Math.acos(6_371_000 / (6_371_000 + 20_000_000));
assert.ok(f.camera.pitch <= limbPitch);
assert.ok(f.camera.pitch > maxPitchForHeight(20_000_000));
});
test('guard leaves the camera alone without recent user zoom input', () => {
test('pinch, right-drag and configured Ctrl+wheel can zoom out', () => {
for (const [type, modifier] of [[PINCH], [RIGHT_DRAG], [WHEEL, CTRL]]) {
const f = guardFixture();
f.frame(500_000);
f.movement(type, -10, { modifier });
f.frame(700_000);
assert.equal(f.camera.views.length, 1, `${type}:${modifier}`);
f.dispose();
}
});
test('Ctrl+right-drag tilt is left at the pose chosen by Cesium', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(RIGHT_DRAG, 3, { modifier: CTRL });
// Real Cesium's orbit tilt raises altitude with no zoom movement.
f.camera.pitch = Cesium.Math.toRadians(-26.07722459498313);
f.frame(529_392.3759876974);
assert.equal(f.camera.views.length, 0);
assert.equal(deg(f.camera.pitch), -26.07722459498313);
});
test('parallel two-finger tilt is not mistaken for pinch zoom', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(PINCH, 0, { tiltDeltaY: 3 });
f.frame(600_000);
assert.equal(f.camera.views.length, 0);
});
test('unconfigured wheel modifiers and one-finger drag do not enable the guard', () => {
for (const [type, modifier] of [[WHEEL, SHIFT], [WHEEL, ALT], [LEFT_DRAG]]) {
const f = guardFixture();
f.frame(500_000);
f.movement(type, -10, { modifier });
f.frame(600_000);
assert.equal(f.camera.views.length, 0, `${type}:${modifier}`);
f.dispose();
}
});
test('current controller zoom bindings are honored', () => {
const f = guardFixture();
f.frame(500_000);
f.controller.zoomEventTypes = { eventType: MIDDLE_DRAG, modifier: ALT };
f.movement(WHEEL, -10);
f.frame(600_000);
assert.equal(f.camera.views.length, 0);
f.movement(MIDDLE_DRAG, -10, { modifier: ALT });
f.frame(700_000);
assert.equal(f.camera.views.length, 1);
});
test('programmatic height changes alone never activate the guard', () => {
const f = guardFixture();
f.frame(170_000);
f.frame(2_000_000); // programmatic flight, no input
f.container.emit({ type: 'wheel' });
f.advance(USER_ZOOM_WINDOW_MS + 1);
f.frame(2_000_000);
f.frame(3_000_000);
assert.equal(f.camera.views.length, 0);
});
test('guard only acts while zooming out', () => {
const f = guardFixture();
f.frame(2_000_000);
f.container.emit({ type: 'wheel' });
f.frame(1_500_000);
f.frame(1_500_000);
assert.equal(f.camera.views.length, 0);
});
test('guard yields to disabled inputs, tracked transforms and flights', () => {
test('inward zoom does not activate the guard even when altitude rises', () => {
const f = guardFixture();
f.frame(500_000);
f.container.emit({ type: 'wheel' });
f.controller.enableInputs = false;
f.movement(WHEEL, 10);
f.frame(600_000);
f.controller.enableInputs = true;
f.camera.transform = Cesium.Matrix4.fromTranslation(
new Cesium.Cartesian3(1, 2, 3),
);
f.frame(700_000);
f.camera.transform = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY);
f.camera._currentFlight = {};
f.frame(800_000);
delete f.camera._currentFlight;
assert.equal(f.camera.views.length, 0);
f.frame(900_000);
assert.equal(f.camera.views.length, 1);
});
test('installer sets the zoom ceiling and the disposer restores it', () => {
const f = guardFixture({ maximumZoomDistance: 123 });
test('outward input with falling height does not alter pitch', () => {
const f = guardFixture();
f.frame(2_000_000);
f.movement(WHEEL, -10);
f.frame(1_500_000);
f.frame(1_500_000);
assert.equal(f.camera.views.length, 0);
});
test('pitch finishes settling when the zoom stops changing height', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(2_000_000);
const firstPitch = f.camera.pitch;
for (let i = 0; i < 40; i++) f.frame();
assert.ok(f.camera.pitch < firstPitch);
assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10);
const views = f.camera.views.length;
f.frame();
assert.equal(
f.camera.views.length,
views,
'settled pose does not keep rendering',
);
});
test('completed settling does not authorize a later programmatic climb', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
for (let i = 0; i < 40; i++) f.frame();
const views = f.camera.views.length;
f.frame(3_000_000);
assert.equal(f.camera.views.length, views);
});
test('a fresh inward zoom or unrelated gesture cancels pending correction', () => {
for (const [type, delta, options] of [
[WHEEL, 10, {}],
[RIGHT_DRAG, 3, { modifier: CTRL }],
[LEFT_DRAG, 3, {}],
[MIDDLE_DRAG, 3, {}],
[PINCH, 0, { tiltDeltaY: 3 }],
]) {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
const views = f.camera.views.length;
f.movement(type, delta, options);
f.frame(800_000);
f.frame(900_000);
assert.equal(f.camera.views.length, views, `${type}:${options.modifier}`);
f.dispose();
}
});
test('an external pose change at fixed height cancels pending correction', () => {
for (const field of ['heading', 'pitch', 'roll', 'longitude', 'latitude']) {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
const views = f.camera.views.length;
f.camera[field] += 0.1;
f.frame();
f.frame(900_000);
assert.equal(f.camera.views.length, views, field);
f.dispose();
}
});
test('disabled controls, flights, tracked transforms and non-3D scenes cancel intent', () => {
const cases = [
(f) => {
f.controller.enableInputs = false;
return () => {
f.controller.enableInputs = true;
};
},
(f) => {
f.controller.enableZoom = false;
return () => {
f.controller.enableZoom = true;
};
},
(f) => {
f.camera._currentFlight = {};
return () => {
delete f.camera._currentFlight;
};
},
(f) => {
f.camera.transform = Cesium.Matrix4.fromTranslation(
new Cesium.Cartesian3(1, 2, 3),
);
return () => {
f.camera.transform = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY);
};
},
(f) => {
f.scene.mode = Cesium.SceneMode.SCENE2D;
return () => {
f.scene.mode = Cesium.SceneMode.SCENE3D;
};
},
];
for (const block of cases) {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
const views = f.camera.views.length;
const restore = block(f);
f.movement(WHEEL, -10);
f.frame(800_000);
restore();
f.frame(900_000);
assert.equal(f.camera.views.length, views);
f.movement(WHEEL, -10);
f.frame(1_000_000);
assert.equal(f.camera.views.length, views + 1);
f.dispose();
}
});
test('installer sets the ceiling and disposer releases aggregator and frame listener', () => {
const f = guardFixture();
assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
assert.equal(f.listeners.size, 3);
assert.equal(f.preRender.size, 1);
f.frame();
assert.ok(f.aggregator.resets > 0);
f.dispose();
f.dispose();
assert.equal(f.controller.maximumZoomDistance, Infinity);
assert.equal(f.aggregator.destroys, 1);
assert.equal(f.preRender.size, 0);
assert.equal(f.handoffs.size, 0);
});
test('installer preserves a stricter host zoom ceiling', () => {
const f = guardFixture({ maximumZoomDistance: 123 });
assert.equal(f.controller.maximumZoomDistance, 123);
f.dispose();
assert.equal(f.controller.maximumZoomDistance, 123);
assert.equal(f.listeners.size, 0);
assert.equal(f.preRender.size, 0);
});
test('disposer keeps a ceiling someone else changed later', () => {
@@ -198,3 +429,134 @@ test('disposer keeps a ceiling someone else changed later', () => {
test('installer rejects an incomplete viewer', () => {
assert.throws(() => installZoomOutGuard({}), TypeError);
});
test('outward input at the ceiling reframes a deliberately tilted camera', () => {
const f = guardFixture();
f.frame(MAXIMUM_ZOOM_DISTANCE_M);
f.movement(WHEEL, -10);
f.frame();
for (let i = 0; i < 40; i++) f.frame();
assert.ok(
Math.abs(f.camera.pitch - maxPitchForHeight(MAXIMUM_ZOOM_DISTANCE_M)) <
1e-10,
);
f.dispose();
});
test('equivalent heading and roll wraps do not cancel stationary settling', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(2_000_000);
f.camera.heading += Cesium.Math.TWO_PI;
f.camera.roll += Cesium.Math.TWO_PI;
for (let i = 0; i < 40; i++) f.frame();
assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10);
f.dispose();
});
test('a real Cesium camera settles across the north heading wrap at Istanbul', () => {
const scene = {
canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 },
drawingBufferWidth: 1200,
drawingBufferHeight: 800,
mapProjection: new Cesium.GeographicProjection(),
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
preRender: new Cesium.Event(),
screenSpaceCameraController: {
maximumZoomDistance: Infinity,
enableInputs: true,
zoomEventTypes: [WHEEL],
},
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
const setHeight = (height) =>
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, height),
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-25), roll: 0 },
});
setHeight(1_000_000);
let outward = false;
const dispose = installZoomOutGuard(
{ scene, camera, canvas: scene.canvas },
{
createAggregator: () => ({
isMoving: (type, modifier) =>
outward && type === WHEEL && modifier === undefined,
getMovement: () => ({
startPosition: { y: 0 },
endPosition: { y: -10 },
}),
reset: () => {
outward = false;
},
destroy() {},
}),
requestRender() {},
},
);
try {
setHeight(MAXIMUM_ZOOM_DISTANCE_M);
outward = true;
for (let i = 0; i < 40; i++) scene.preRender.raiseEvent(scene);
assert.ok(
Math.abs(
camera.pitch - maxPitchForHeight(camera.positionCartographic.height),
) < 1e-8,
);
} finally {
dispose();
}
});
test('an outward inertia ceiling adjustment can descend while pitch settles', () => {
const f = guardFixture();
f.frame(39_000_000);
f.movement(WHEEL, -10);
f.frame(39_999_999);
const before = f.camera.pitch;
// Cesium limits slant range on an inertial pick, slightly below the altitude cap.
f.frame(39_997_258);
for (let i = 0; i < 40; i++) f.frame();
assert.ok(f.camera.pitch < before);
assert.ok(
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-10,
);
f.dispose();
});
test('a descending programmatic tilt cancels pending correction', () => {
const f = guardFixture();
f.frame(39_000_000);
f.movement(WHEEL, -10);
f.frame(39_999_999);
const count = f.camera.views.length;
f.camera.pitch += 0.1;
f.frame(39_997_258);
for (let i = 0; i < 40; i++) f.frame();
assert.equal(f.camera.views.length, count);
f.dispose();
});
test('explicit navigation cancels outward inertia before a climbing UI tilt', () => {
const f = guardFixture();
f.movement(WHEEL, -10);
f.frame(1_000_000);
f.movement(WHEEL, -10);
f.handoff();
const writes = f.camera.views.length;
f.camera.pitch = Cesium.Math.toRadians(-25);
f.frame(1_100_000);
f.frame(1_200_000);
assert.equal(f.camera.views.length, writes);
assert.equal(deg(f.camera.pitch), -25);
// A subsequent user zoom starts a new correction normally.
f.movement(WHEEL, -10);
f.frame(1_300_000);
assert.ok(f.camera.views.length > writes);
f.dispose();
});