mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix: respect camera ownership and finish outward zoom framing
This commit is contained in:
1 parent
895ef536ca
commit
a236504347
8 files changed
+951
-152
No files matched your search
@@ -1,3 +1,5 @@
|
||||
- Keep the globe on screen when zooming out from a tilted view. User zoom now
|
||||
stops at 40,000 km, and above 300 km a zoom-out pitches the view down so the
|
||||
Earth stays framed instead of sliding off into space (asturksever, #1522).
|
||||
- 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).
|
||||
+16
-7
@@ -44,13 +44,22 @@ 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 stops at 40,000 km above the surface, a whole-Earth view; camera
|
||||
flights, share links and story views are not limited. Above 300 km, a
|
||||
wheel, pinch or right-drag zoom-out from a shallow, tilted view pitches the
|
||||
camera down as it climbs, keeping the view at least 8° below the Earth's limb,
|
||||
so the globe stays on screen instead of sliding off into space. Oblique views
|
||||
below 300 km, zooming in, tilting by drag, tracked entities, cockpit tracking
|
||||
and camera flights are left alone.
|
||||
User zoom is capped at 40,000 km (or a stricter host limit). Explicit camera
|
||||
flight, share-link and story destinations can go farther; Cesium can still use
|
||||
the limit when it computes an automatic bounding-sphere framing distance.
|
||||
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.
|
||||
Parallel two-finger tilts, modifier-specific tilts and other camera gestures
|
||||
cancel the correction. 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.
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
"src/app/startupCamera.test.mjs",
|
||||
"src/app/viewer.js",
|
||||
"src/app/zoomOutGuard.js",
|
||||
"src/app/zoomOutGuard.test.mjs",
|
||||
"scripts/qa-zoom-out-guard.mjs",
|
||||
"src/app/atmosphereCompat.test.mjs",
|
||||
"src/standalone/application.js",
|
||||
"src/standalone/controls.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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user