mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge branch 'main' into feat/voice-geometry
This commit is contained in:
commit
553f0c06ee
9 files changed
+1443
No files matched your search
@@ -0,0 +1,5 @@
|
||||
- Keep the globe on screen when zooming out from a tilted view. User zoom is
|
||||
capped at 40,000 km, and above 300 km outward zoom pitches the view down so
|
||||
Earth stays framed. Correction finishes when zoom stops, respects tilt
|
||||
gestures and stricter host limits, and yields to other camera actions
|
||||
(asturksever, #1522).
|
||||
@@ -44,6 +44,27 @@ pinching traverses globe and street scales with fewer gestures. Cesium retains
|
||||
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; 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;
|
||||
the remaining margin eases into place, including after zooming stops or reaches
|
||||
the ceiling. Continuous outward inertia remains guarded without a time cutoff.
|
||||
Two-finger tilts, modifier-specific tilts and other camera gestures cancel the
|
||||
correction. A two-finger gesture counts as a pinch zoom only while the finger
|
||||
spacing changes more than the fingers move up or down, so spacing drift during
|
||||
a tilt is ignored. Explicit navigation handoffs, including toolbar tilt,
|
||||
cancel it before animation begins. Zooming in, disabled zoom/input, non-3D modes, tracked
|
||||
transforms and flights also cancel it; external pose changes cancel stationary
|
||||
settling. Oblique views below 300 km are left alone.
|
||||
|
||||
`QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises
|
||||
the real browser controller at desktop and phone widths with keyless fixtures.
|
||||
|
||||
Analyst region queries resolve unique Natural Earth names offline without
|
||||
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||
Duplicate names require a geocoded containment anchor; when several entries for
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
"src/app/startupCamera.test.mjs",
|
||||
"src/app/viewer.js",
|
||||
"src/app/zoomOutGuard.test.mjs",
|
||||
"src/app/controls.test.mjs",
|
||||
"scripts/qa-zoom-out-guard.mjs",
|
||||
"src/app/atmosphereCompat.test.mjs",
|
||||
"src/standalone/application.js",
|
||||
"src/standalone/controls.js",
|
||||
|
||||
@@ -2280,6 +2280,7 @@
|
||||
"src/app/layers/traffic.js",
|
||||
"src/app/operations.js",
|
||||
"src/app/scene.js",
|
||||
"src/app/zoomOutGuard.js",
|
||||
"src/sources/sourceSlot.js",
|
||||
"src/app/sources.js",
|
||||
"src/app/startupChrome.js",
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env node
|
||||
/** Real-app zoom and tilt regression. Start a dev server, then run this file. */
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: [
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
]),
|
||||
],
|
||||
protocolTimeout: 120_000,
|
||||
});
|
||||
const errors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewport({ width: 1200, height: 800 });
|
||||
await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.viewer &&
|
||||
window.__godsEyeView.viewer.scene.screenSpaceCameraController
|
||||
.enableInputs &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
const renderer = await page.evaluate(async () => {
|
||||
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const { maxPitchForHeight } = await import('/src/app/zoomOutGuard.js');
|
||||
const { readCameraTargetFrame } =
|
||||
await import('/src/ui/cameraOrientationControls.js');
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.trackedEntity = undefined;
|
||||
// Explicit real renders make each gesture independent of host frame rate.
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
controller.inertiaZoom = 0;
|
||||
controller.inertiaSpin = 0;
|
||||
const original = viewer.camera.setView.bind(viewer.camera);
|
||||
const qa = {
|
||||
viewer,
|
||||
guardWrites: 0,
|
||||
targetPitch: () => readCameraTargetFrame(viewer)?.pitch,
|
||||
history: [],
|
||||
frame() {
|
||||
viewer.scene.requestRender();
|
||||
viewer.render();
|
||||
qa.history.push({
|
||||
height: viewer.camera._positionCartographic.height,
|
||||
guardWrites: qa.guardWrites,
|
||||
position: { ...viewer.camera.position },
|
||||
});
|
||||
if (qa.history.length > 80) qa.history.shift();
|
||||
},
|
||||
pose() {
|
||||
return {
|
||||
height: viewer.camera.positionCartographic.height,
|
||||
pitch: viewer.camera.pitch,
|
||||
maxPitch: maxPitchForHeight(
|
||||
viewer.camera.positionCartographic.height,
|
||||
),
|
||||
guardWrites: qa.guardWrites,
|
||||
};
|
||||
},
|
||||
reset(height = 1_000_000, pitch = -35) {
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.lookAtTransform(C.Matrix4.IDENTITY);
|
||||
original({
|
||||
destination: C.Cartesian3.fromDegrees(0, 0, height),
|
||||
orientation: { heading: 0, pitch: C.Math.toRadians(pitch), roll: 0 },
|
||||
});
|
||||
qa.frame();
|
||||
qa.guardWrites = 0;
|
||||
},
|
||||
};
|
||||
viewer.camera.setView = (options) => {
|
||||
// Cesium itself also calls setView while processing touch movement.
|
||||
if (new Error().stack.includes('/src/app/zoomOutGuard.js'))
|
||||
qa.guardWrites++;
|
||||
return original(options);
|
||||
};
|
||||
window.__zoomGuardQa = qa;
|
||||
const gl = viewer.scene.context._gl;
|
||||
const debug = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER);
|
||||
});
|
||||
console.log(`[INFO] WebGL renderer: ${renderer}`);
|
||||
const frame = (count = 1) =>
|
||||
page.evaluate(async (n) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
window.__zoomGuardQa.frame();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
return window.__zoomGuardQa.pose();
|
||||
}, count);
|
||||
const reset = (height, pitch) =>
|
||||
page.evaluate((h, p) => window.__zoomGuardQa.reset(h, p), height, pitch);
|
||||
const session = await page.createCDPSession();
|
||||
for (const viewport of [
|
||||
{ width: 1200, height: 800 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
await page.setViewport(viewport);
|
||||
await page.evaluate(() => window.__zoomGuardQa.viewer.resize());
|
||||
const x = Math.round(viewport.width / 2);
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await reset(1_000_000, -25);
|
||||
await page.mouse.move(x, y);
|
||||
await page.evaluate(
|
||||
({ x, y }) => {
|
||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: 2400,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ x, y },
|
||||
);
|
||||
const first = await frame();
|
||||
console.log(`[INFO] ${viewport.width}px wheel delivered`);
|
||||
const settled = await frame(40);
|
||||
assert.ok(first.height > 1_000_000, 'wheel must move the real camera');
|
||||
assert.ok(settled.guardWrites > 0, 'zoom guard must run');
|
||||
assert.ok(
|
||||
settled.pitch <= settled.maxPitch + 1e-6,
|
||||
`stationary pitch must finish easing: ${JSON.stringify(settled)}`,
|
||||
);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.evaluate(
|
||||
({ x, y }) => {
|
||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: 2400,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ x, y },
|
||||
);
|
||||
await frame();
|
||||
}
|
||||
const ceiling = await frame(40);
|
||||
assert.ok(ceiling.height <= 40_000_001, 'user zoom respects the ceiling');
|
||||
assert.ok(
|
||||
ceiling.height > 39_000_000,
|
||||
'exercise the ceiling, not a low orbit',
|
||||
);
|
||||
assert.ok(
|
||||
ceiling.pitch <= ceiling.maxPitch + 1e-6,
|
||||
`ceiling must settle: ${JSON.stringify(await page.evaluate(() => window.__zoomGuardQa.history))}`,
|
||||
);
|
||||
console.log(
|
||||
`[PASS] ${viewport.width}px wheel, stationary easing and ceiling`,
|
||||
);
|
||||
if (process.env.QA_SCREENSHOT_DIR) {
|
||||
fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(
|
||||
process.env.QA_SCREENSHOT_DIR,
|
||||
`zoom-ceiling-${viewport.width}.png`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
await reset();
|
||||
await page.mouse.move(x, y);
|
||||
await page.keyboard.down('Control');
|
||||
await page.mouse.down({ button: 'right' });
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
await page.mouse.move(x, y + i * 3);
|
||||
await frame();
|
||||
}
|
||||
await page.mouse.up({ button: 'right' });
|
||||
await page.keyboard.up('Control');
|
||||
const tilted = await frame(3);
|
||||
assert.equal(
|
||||
tilted.guardWrites,
|
||||
0,
|
||||
'Ctrl+right tilt must not trigger guard',
|
||||
);
|
||||
assert.ok(Math.abs(tilted.height - 1_000_000) > 1, 'tilt must move camera');
|
||||
|
||||
await reset();
|
||||
const touches = (dy) => [
|
||||
{ x: x - 60, y: y + dy, id: 1 },
|
||||
{ x: x + 60, y: y + dy, id: 2 },
|
||||
];
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: touches(0),
|
||||
});
|
||||
await frame();
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: touches(i * 3),
|
||||
});
|
||||
await frame();
|
||||
}
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
const translated = await frame(3);
|
||||
assert.equal(
|
||||
translated.guardWrites,
|
||||
0,
|
||||
'parallel two-finger tilt must not trigger guard',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(translated.height - 1_000_000) > 1,
|
||||
'touch tilt must move camera',
|
||||
);
|
||||
console.log(
|
||||
`[PASS] ${viewport.width}px Ctrl+right and parallel touch tilt`,
|
||||
);
|
||||
await reset(1_000_000, -35);
|
||||
await page.evaluate(
|
||||
({ x, y }) => {
|
||||
window.__zoomGuardQa.viewer.canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: 120,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ x, y },
|
||||
);
|
||||
const beforeTilt = await frame();
|
||||
assert.ok(
|
||||
beforeTilt.guardWrites > 0,
|
||||
'wheel correction must be pending before UI tilt',
|
||||
);
|
||||
const targetPitchBeforeClick = await page.evaluate(() =>
|
||||
window.__zoomGuardQa.targetPitch(),
|
||||
);
|
||||
assert.ok(
|
||||
Number.isFinite(targetPitchBeforeClick),
|
||||
'UI tilt fixture must have a ground target under the canvas center',
|
||||
);
|
||||
// Programmatic fixture resets precede moveEnd, so the button's published
|
||||
// aria state may still describe the previous view. The action toggles the
|
||||
// current target frame: angles shallower than -60 degrees are tilted.
|
||||
const wasTilted = targetPitchBeforeClick > -Math.PI / 3;
|
||||
await page.click('#tilt-map-view');
|
||||
const uiTilt = await page.evaluate(
|
||||
async (desiredDegrees) => {
|
||||
const qa = window.__zoomGuardQa;
|
||||
const desired = (desiredDegrees * Math.PI) / 180;
|
||||
const started = performance.now();
|
||||
const deadline = started + 3000;
|
||||
while (performance.now() < deadline) {
|
||||
qa.frame();
|
||||
// The 650 ms animator can be numerically near its destination
|
||||
// before it releases preUpdate. Finish its lifetime before resetting.
|
||||
if (
|
||||
performance.now() - started >= 700 &&
|
||||
Math.abs(qa.targetPitch() - desired) < 1e-5
|
||||
)
|
||||
return { settled: true, guardWrites: qa.guardWrites };
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
return {
|
||||
settled: false,
|
||||
guardWrites: qa.guardWrites,
|
||||
pitch: qa.targetPitch(),
|
||||
};
|
||||
},
|
||||
wasTilted ? -89 : -35,
|
||||
);
|
||||
assert.ok(
|
||||
uiTilt.settled,
|
||||
`UI tilt must reach its destination: ${JSON.stringify(uiTilt)}`,
|
||||
);
|
||||
assert.equal(
|
||||
uiTilt.guardWrites,
|
||||
beforeTilt.guardWrites,
|
||||
'UI navigation handoff must cancel pending zoom correction',
|
||||
);
|
||||
console.log(
|
||||
`[PASS] ${viewport.width}px UI tilt takes over a pending wheel correction`,
|
||||
);
|
||||
|
||||
await reset(1_000_000, -25);
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints: touches(0),
|
||||
});
|
||||
await frame();
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [
|
||||
{ x: x - 35, y, id: 1 },
|
||||
{ x: x + 35, y, id: 2 },
|
||||
],
|
||||
});
|
||||
await frame();
|
||||
await session.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints: [],
|
||||
});
|
||||
const pinched = await frame(40);
|
||||
assert.ok(pinched.height > 1_000_000, 'pinch-out must move camera outward');
|
||||
assert.ok(pinched.guardWrites > 0, 'pinch-out must trigger the guard');
|
||||
assert.ok(pinched.pitch <= pinched.maxPitch + 1e-6);
|
||||
console.log(`[PASS] ${viewport.width}px pinch-out settles below the limb`);
|
||||
}
|
||||
assert.deepEqual(errors, [], 'no JavaScript page errors');
|
||||
console.log('[PASS] no JavaScript page errors');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Vendored
+7
@@ -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.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import test, { after, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import '../testSupport/stubMgrs.mjs';
|
||||
import { installVueDom } from '../testSupport/vueDom.mjs';
|
||||
import { MAXIMUM_ZOOM_DISTANCE_M } from './zoomOutGuard.js';
|
||||
|
||||
const { createApplicationControls } = await import('./controls.js');
|
||||
|
||||
let uninstallDom;
|
||||
before(() => {
|
||||
uninstallDom = installVueDom();
|
||||
});
|
||||
after(() => uninstallDom());
|
||||
|
||||
test('application controls install the zoom-out guard and remove it on teardown', () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const controller = { maximumZoomDistance: Infinity };
|
||||
const scene = {
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
screenSpaceCameraController: controller,
|
||||
preRender: new Cesium.Event(),
|
||||
postUpdate: new Cesium.Event(),
|
||||
};
|
||||
const camera = {
|
||||
position: Cesium.Cartesian3.fromDegrees(0, 0, 1_000_000),
|
||||
positionCartographic: { height: 1_000_000 },
|
||||
};
|
||||
const viewer = { scene, camera, canvas };
|
||||
const handoffs = new Set();
|
||||
class Controls {
|
||||
hasShareState = true;
|
||||
orbitController = { stop() {} };
|
||||
hud = { destroy() {} };
|
||||
dispose() {}
|
||||
subscribeCameraHandoff(listener) {
|
||||
handoffs.add(listener);
|
||||
return () => handoffs.delete(listener);
|
||||
}
|
||||
}
|
||||
const cleanups = [];
|
||||
createApplicationControls({
|
||||
scene: {
|
||||
viewer,
|
||||
mapStackController: {},
|
||||
operations: {
|
||||
surface: { controlServices: {} },
|
||||
searchAndFlyTo() {},
|
||||
requests: { regional: {}, weather: {} },
|
||||
},
|
||||
},
|
||||
loaderStatus: {},
|
||||
Controls,
|
||||
catalog: { get: (id) => ({ id }) },
|
||||
defer: (cleanup) => cleanups.push(cleanup),
|
||||
});
|
||||
|
||||
assert.equal(controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||
assert.equal(scene.preRender.numberOfListeners, 1);
|
||||
assert.equal(scene.postUpdate.numberOfListeners, 1);
|
||||
assert.equal(handoffs.size, 1);
|
||||
|
||||
for (const cleanup of cleanups.reverse()) cleanup();
|
||||
assert.equal(controller.maximumZoomDistance, Infinity);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(scene.postUpdate.numberOfListeners, 0);
|
||||
assert.equal(handoffs.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
|
||||
/** 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);
|
||||
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
|
||||
* framed from height `h`, or null where tilted views are left alone.
|
||||
* The limb sits acos(R / (R + h)) below the local horizontal.
|
||||
*/
|
||||
export function maxPitchForHeight(h) {
|
||||
if (!(h >= PITCH_CLAMP_MIN_HEIGHT_M)) return null;
|
||||
const limb = Math.acos(EARTH_RADIUS_M / (EARTH_RADIUS_M + h));
|
||||
return -Math.min(limb + LIMB_MARGIN_RAD, Cesium.Math.PI_OVER_TWO);
|
||||
}
|
||||
|
||||
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 pinch = type === Cesium.CameraEventType.PINCH;
|
||||
const distance = pinch ? movement.distance : movement;
|
||||
const delta = distance.endPosition.y - distance.startPosition.y;
|
||||
// Cesium tilts by the fingers' midpoint while zooming by their spacing,
|
||||
// both in the same quarter-pixel units. Fingers rarely keep a perfectly
|
||||
// even spacing, so the larger of the two movements decides the gesture.
|
||||
const tilt = pinch
|
||||
? movement.angleAndHeight.endPosition.y -
|
||||
movement.angleAndHeight.startPosition.y
|
||||
: 0;
|
||||
if (
|
||||
matchesZoomBinding(bindings, type, modifier) &&
|
||||
Math.abs(delta) > Math.abs(tilt)
|
||||
) {
|
||||
outward ||= delta < 0;
|
||||
cancel ||= delta > 0;
|
||||
} else {
|
||||
// Includes modifier-specific tilts and parallel two-finger movement.
|
||||
cancel = true;
|
||||
}
|
||||
}
|
||||
aggregator.reset();
|
||||
return { outward, cancel };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
{
|
||||
requestRender = governorRequestRender,
|
||||
subscribeCameraHandoff,
|
||||
createAggregator = (canvas) => new Cesium.CameraEventAggregator(canvas),
|
||||
} = {},
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const controller = scene?.screenSpaceCameraController;
|
||||
const camera = viewer?.camera;
|
||||
const canvas = viewer?.canvas;
|
||||
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 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;
|
||||
// While tracking, Cesium measures zoom from the target, not the Earth, so
|
||||
// the camera's altitude would lift the ceiling with every outward step.
|
||||
const untracked =
|
||||
scene.mode === Cesium.SceneMode.SCENE3D &&
|
||||
Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
|
||||
const height = untracked ? camera.positionCartographic?.height : undefined;
|
||||
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);
|
||||
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 guard = () => {
|
||||
const input = readInput(aggregator, controller.zoomEventTypes);
|
||||
const height = camera.positionCartographic?.height;
|
||||
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);
|
||||
// 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.
|
||||
aggregator.reset();
|
||||
});
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
removeCameraHandoff?.();
|
||||
removePreRender?.();
|
||||
removePostUpdate?.();
|
||||
aggregator.destroy();
|
||||
if (controller.maximumZoomDistance === maximumZoomDistance)
|
||||
controller.maximumZoomDistance = originalMaximumZoomDistance;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
installZoomOutGuard,
|
||||
maxPitchForHeight,
|
||||
MAXIMUM_ZOOM_DISTANCE_M,
|
||||
} 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);
|
||||
assert.equal(maxPitchForHeight(299_999), null);
|
||||
assert.equal(maxPitchForHeight(NaN), null);
|
||||
assert.equal(maxPitchForHeight(undefined), null);
|
||||
});
|
||||
|
||||
test('maxPitchForHeight looks 8 degrees below the limb', () => {
|
||||
assert.ok(Math.abs(deg(maxPitchForHeight(1_000_000)) + 38.19) < 0.01);
|
||||
assert.equal(deg(maxPitchForHeight(1e12)), -90);
|
||||
});
|
||||
|
||||
test('maxPitchForHeight steepens monotonically with height', () => {
|
||||
let previous = 0;
|
||||
for (let h = 300_000; h <= 100_000_000; h *= 1.5) {
|
||||
const pitch = maxPitchForHeight(h);
|
||||
assert.ok(pitch <= previous, `pitch at ${h} m`);
|
||||
previous = pitch;
|
||||
}
|
||||
});
|
||||
|
||||
function guardFixture({ maximumZoomDistance = Infinity } = {}) {
|
||||
const preRender = new Set();
|
||||
const postUpdate = new Set();
|
||||
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++;
|
||||
},
|
||||
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,
|
||||
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);
|
||||
this.heading = orientation.heading;
|
||||
this.pitch = orientation.pitch;
|
||||
this.roll = orientation.roll;
|
||||
},
|
||||
};
|
||||
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);
|
||||
},
|
||||
},
|
||||
postUpdate: {
|
||||
addEventListener(handler) {
|
||||
postUpdate.add(handler);
|
||||
return () => postUpdate.delete(handler);
|
||||
},
|
||||
},
|
||||
};
|
||||
const renders = [];
|
||||
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),
|
||||
},
|
||||
);
|
||||
// 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;
|
||||
idleFrame();
|
||||
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,
|
||||
scene,
|
||||
aggregator,
|
||||
preRender,
|
||||
postUpdate,
|
||||
handoffs,
|
||||
handoff: () => {
|
||||
for (const listener of handoffs) listener();
|
||||
},
|
||||
renders,
|
||||
dispose,
|
||||
frame,
|
||||
idleFrame,
|
||||
movement,
|
||||
};
|
||||
}
|
||||
|
||||
test('a tilted wheel zoom-out pitches down and preserves heading and roll', () => {
|
||||
const f = guardFixture();
|
||||
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,
|
||||
);
|
||||
assert.ok(f.renders.length > 0);
|
||||
});
|
||||
|
||||
test('a large outward step immediately keeps the Earth limb in view', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
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('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('two-finger tilt with finger-spacing drift is left to Cesium', () => {
|
||||
for (const [spacing, tilt] of [
|
||||
[-0.01, 3],
|
||||
[-2, 3],
|
||||
[-3, -3],
|
||||
]) {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(PINCH, spacing, { tiltDeltaY: tilt });
|
||||
// Real Cesium's two-finger tilt climbs and picks its own pitch.
|
||||
f.camera.pitch = Cesium.Math.toRadians(-26.07746);
|
||||
f.frame(529_406);
|
||||
assert.equal(f.camera.views.length, 0, `${spacing}, ${tilt}`);
|
||||
f.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('a pinch that closes the fingers more than it moves them still zooms out', () => {
|
||||
// One finger still, the other sliding towards it: the midpoint moves half as far.
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(PINCH, -10, { tiltDeltaY: -5 });
|
||||
f.frame(700_000);
|
||||
assert.equal(f.camera.views.length, 1);
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
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);
|
||||
f.frame(3_000_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
});
|
||||
|
||||
test('inward zoom does not activate the guard even when altitude rises', () => {
|
||||
const f = guardFixture();
|
||||
f.frame(500_000);
|
||||
f.movement(WHEEL, 10);
|
||||
f.frame(600_000);
|
||||
assert.equal(f.camera.views.length, 0);
|
||||
});
|
||||
|
||||
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.preRender.size, 1);
|
||||
assert.equal(f.postUpdate.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.postUpdate.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);
|
||||
});
|
||||
|
||||
test('disposer keeps a ceiling someone else changed later', () => {
|
||||
const f = guardFixture();
|
||||
f.controller.maximumZoomDistance = 5;
|
||||
f.dispose();
|
||||
assert.equal(f.controller.maximumZoomDistance, 5);
|
||||
});
|
||||
|
||||
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('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);
|
||||
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(),
|
||||
postUpdate: 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();
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
test('a tracked camera cannot climb past the ceiling from its target', () => {
|
||||
const s = realControllerScene(1_000_000);
|
||||
try {
|
||||
s.frame();
|
||||
// In look-at mode Cesium measures zoom from the target, not the Earth.
|
||||
s.camera.lookAt(
|
||||
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
|
||||
new Cesium.HeadingPitchRange(0, -0.5, 5_000_000),
|
||||
);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
s.wheel(100);
|
||||
s.frame();
|
||||
}
|
||||
const range = Cesium.Cartesian3.magnitude(s.camera.position);
|
||||
assert.ok(range <= MAXIMUM_ZOOM_DISTANCE_M + 1_000, `${range} m`);
|
||||
assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||
} finally {
|
||||
s.dispose();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user