mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +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
|
- Keep the globe on screen when zooming out from a tilted view. User zoom is
|
||||||
stops at 40,000 km, and above 300 km a zoom-out pitches the view down so the
|
capped at 40,000 km, and above 300 km outward zoom pitches the view down so
|
||||||
Earth stays framed instead of sliding off into space (asturksever, #1522).
|
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.
|
its distance-aware zoom, collision handling and minimum/maximum distance limits.
|
||||||
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
|
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
|
||||||
|
|
||||||
User zoom stops at 40,000 km above the surface, a whole-Earth view; camera
|
User zoom is capped at 40,000 km (or a stricter host limit). Explicit camera
|
||||||
flights, share links and story views are not limited. Above 300 km, a
|
flight, share-link and story destinations can go farther; Cesium can still use
|
||||||
wheel, pinch or right-drag zoom-out from a shallow, tilted view pitches the
|
the limit when it computes an automatic bounding-sphere framing distance.
|
||||||
camera down as it climbs, keeping the view at least 8° below the Earth's limb,
|
Above 300 km, outward movement from the controller's configured zoom bindings
|
||||||
so the globe stays on screen instead of sliding off into space. Oblique views
|
(wheel, pinch distance and unmodified right-drag by default) pitches the camera
|
||||||
below 300 km, zooming in, tilting by drag, tracked entities, cockpit tracking
|
down towards 8° below the Earth's limb. Large steps reach the limb immediately;
|
||||||
and camera flights are left alone.
|
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
|
Analyst region queries resolve unique Natural Earth names offline without
|
||||||
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
|
||||||
|
|||||||
@@ -57,8 +57,8 @@
|
|||||||
"src/app/mapLifecycle.test.mjs",
|
"src/app/mapLifecycle.test.mjs",
|
||||||
"src/app/startupCamera.test.mjs",
|
"src/app/startupCamera.test.mjs",
|
||||||
"src/app/viewer.js",
|
"src/app/viewer.js",
|
||||||
"src/app/zoomOutGuard.js",
|
|
||||||
"src/app/zoomOutGuard.test.mjs",
|
"src/app/zoomOutGuard.test.mjs",
|
||||||
|
"scripts/qa-zoom-out-guard.mjs",
|
||||||
"src/app/atmosphereCompat.test.mjs",
|
"src/app/atmosphereCompat.test.mjs",
|
||||||
"src/standalone/application.js",
|
"src/standalone/application.js",
|
||||||
"src/standalone/controls.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 { catalogControlServices } from './catalog.js';
|
||||||
import { StyleManager } from '../ui/composition.js';
|
import { StyleManager } from '../ui/composition.js';
|
||||||
import { flyToAustin } from '../camera.js';
|
import { flyToAustin } from '../camera.js';
|
||||||
|
import { installZoomOutGuard } from './zoomOutGuard.js';
|
||||||
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
||||||
import { t } from '../i18n/index.js';
|
import { t } from '../i18n/index.js';
|
||||||
|
|
||||||
@@ -41,6 +42,12 @@ export function createApplicationControls({
|
|||||||
defer(() => styleManager.orbitController.stop());
|
defer(() => styleManager.orbitController.stop());
|
||||||
defer(() => styleManager.hud.destroy());
|
defer(() => styleManager.hud.destroy());
|
||||||
defer(() => styleManager.dispose());
|
defer(() => styleManager.dispose());
|
||||||
|
defer(
|
||||||
|
installZoomOutGuard(viewer, {
|
||||||
|
subscribeCameraHandoff: (listener) =>
|
||||||
|
styleManager.subscribeCameraHandoff?.(listener),
|
||||||
|
}),
|
||||||
|
);
|
||||||
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
||||||
// clouds use a separate, capped low-resolution GPU pass that never attaches
|
// 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.
|
// Cesium fog or post-process stages and is fully stopped in map mode.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
createApplicationViewer,
|
createApplicationViewer,
|
||||||
installTrackpadPinchZoom,
|
installTrackpadPinchZoom,
|
||||||
} from '../app/viewer.js';
|
} from '../app/viewer.js';
|
||||||
import { installZoomOutGuard } from './zoomOutGuard.js';
|
|
||||||
import { registerDataCredits } from '../data/dataCredits.js';
|
import { registerDataCredits } from '../data/dataCredits.js';
|
||||||
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
|
||||||
import { MapStackController } from '../mapStackController.js';
|
import { MapStackController } from '../mapStackController.js';
|
||||||
@@ -59,7 +58,6 @@ export async function createApplicationScene({
|
|||||||
if (!viewer.isDestroyed()) viewer.destroy();
|
if (!viewer.isDestroyed()) viewer.destroy();
|
||||||
});
|
});
|
||||||
defer(installTrackpadPinchZoom(viewer));
|
defer(installTrackpadPinchZoom(viewer));
|
||||||
defer(installZoomOutGuard(viewer));
|
|
||||||
registerDataCredits(viewer, credits);
|
registerDataCredits(viewer, credits);
|
||||||
configureCreditKeyboardAccess(document);
|
configureCreditKeyboardAccess(document);
|
||||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||||
|
|||||||
+143
-55
@@ -1,18 +1,24 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { governorRequestRender } from '../renderGovernor.js';
|
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;
|
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
|
||||||
/** Below this, oblique browsing is left alone. */
|
/** Below this, oblique browsing is left alone. */
|
||||||
export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
|
export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
|
||||||
/** Look this far below the Earth's limb, so the globe stays on screen. */
|
/** Look this far below the Earth's limb, so the globe stays on screen. */
|
||||||
export const LIMB_MARGIN_RAD = Cesium.Math.toRadians(8);
|
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_EASE = 0.35;
|
||||||
const PITCH_SNAP_RAD = Cesium.Math.toRadians(0.1);
|
const PITCH_SNAP_RAD = Cesium.Math.toRadians(0.1);
|
||||||
|
const HEIGHT_EPSILON_M = 0.0001;
|
||||||
const EARTH_RADIUS_M = 6_371_000;
|
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
|
* 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);
|
return -Math.min(limb + LIMB_MARGIN_RAD, Cesium.Math.PI_OVER_TWO);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUserZoomInput(event) {
|
function matchesZoomBinding(bindings, type, modifier) {
|
||||||
if (event.type === 'wheel') return true;
|
if (Array.isArray(bindings))
|
||||||
if (event.type === 'touchmove') return (event.touches?.length ?? 0) >= 2;
|
return bindings.some((binding) =>
|
||||||
// Cesium's default zoom bindings also include a right-button drag.
|
matchesZoomBinding(binding, type, modifier),
|
||||||
return event.type === 'pointermove' && (event.buttons & 2) === 2;
|
);
|
||||||
|
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.
|
* Keep outward user zooms bounded and framed, without taking camera ownership
|
||||||
*
|
* from tilts, tracked entities or flights. Returns an idempotent disposer.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function installZoomOutGuard(
|
export function installZoomOutGuard(
|
||||||
viewer,
|
viewer,
|
||||||
{ now = () => performance.now(), requestRender = governorRequestRender } = {},
|
{
|
||||||
|
requestRender = governorRequestRender,
|
||||||
|
subscribeCameraHandoff,
|
||||||
|
createAggregator = (canvas) => new Cesium.CameraEventAggregator(canvas),
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const scene = viewer?.scene;
|
const scene = viewer?.scene;
|
||||||
const controller = scene?.screenSpaceCameraController;
|
const controller = scene?.screenSpaceCameraController;
|
||||||
const camera = viewer?.camera;
|
const camera = viewer?.camera;
|
||||||
const container = viewer?.container;
|
const canvas = viewer?.canvas;
|
||||||
if (!controller || !camera || !container || !scene.preRender)
|
if (!controller || !camera || !canvas || !scene.preRender)
|
||||||
throw new TypeError('A complete Cesium viewer is required');
|
throw new TypeError('A complete Cesium viewer is required');
|
||||||
|
|
||||||
|
const aggregator = createAggregator(canvas);
|
||||||
const originalMaximumZoomDistance = controller.maximumZoomDistance;
|
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;
|
let lastHeight = camera.positionCartographic?.height;
|
||||||
const markInput = (event) => {
|
let lastPosition = Cesium.Cartesian3.clone(camera.position);
|
||||||
if (isUserZoomInput(event)) lastUserZoomAt = now();
|
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 guard = () => {
|
||||||
|
const input = readInput(aggregator, controller.zoomEventTypes);
|
||||||
const height = camera.positionCartographic?.height;
|
const height = camera.positionCartographic?.height;
|
||||||
const previousHeight = lastHeight;
|
const climbing = height > lastHeight + HEIGHT_EPSILON_M;
|
||||||
lastHeight = height;
|
const descending = height < lastHeight - HEIGHT_EPSILON_M;
|
||||||
if (!(height > previousHeight)) return; // Zoom-out only.
|
try {
|
||||||
if (now() - lastUserZoomAt > USER_ZOOM_WINDOW_MS) return;
|
if (
|
||||||
if (controller.enableInputs === false) return;
|
scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||||
if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY))
|
controller.enableInputs === false ||
|
||||||
return;
|
controller.enableZoom === false ||
|
||||||
// Cesium keeps no public flag for a running flyTo; never fight one.
|
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY) ||
|
||||||
if (camera._currentFlight) return;
|
// Cesium has no public running-flight flag.
|
||||||
const maxPitch = maxPitchForHeight(height);
|
camera._currentFlight ||
|
||||||
if (maxPitch === null || !(camera.pitch > maxPitch)) return;
|
input.cancel ||
|
||||||
const error = maxPitch - camera.pitch;
|
(descending && !settling)
|
||||||
const pitch =
|
) {
|
||||||
Math.abs(error) <= PITCH_SNAP_RAD
|
cancel();
|
||||||
? maxPitch
|
return;
|
||||||
: camera.pitch + error * PITCH_EASE;
|
}
|
||||||
camera.setView({
|
if (input.outward) {
|
||||||
orientation: { heading: camera.heading, pitch, roll: camera.roll },
|
zoomingOut = true;
|
||||||
});
|
// A genuine outward gesture can be blocked by the controller's ceiling.
|
||||||
lastHeight = camera.positionCartographic?.height;
|
settling = true;
|
||||||
requestRender('zoom-out-guard');
|
}
|
||||||
|
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 removePreRender = scene.preRender.addEventListener(guard);
|
||||||
|
const removeCameraHandoff = subscribeCameraHandoff?.(() => {
|
||||||
|
cancel();
|
||||||
|
// Discard input queued before an explicit navigation action took ownership.
|
||||||
|
aggregator.reset();
|
||||||
|
});
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
return () => {
|
return () => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
active = false;
|
active = false;
|
||||||
|
removeCameraHandoff?.();
|
||||||
removePreRender?.();
|
removePreRender?.();
|
||||||
for (const type of inputTypes)
|
aggregator.destroy();
|
||||||
container.removeEventListener(type, markInput, true);
|
if (controller.maximumZoomDistance === maximumZoomDistance)
|
||||||
if (controller.maximumZoomDistance === MAXIMUM_ZOOM_DISTANCE_M)
|
|
||||||
controller.maximumZoomDistance = originalMaximumZoomDistance;
|
controller.maximumZoomDistance = originalMaximumZoomDistance;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+446
-84
@@ -5,10 +5,12 @@ import {
|
|||||||
installZoomOutGuard,
|
installZoomOutGuard,
|
||||||
maxPitchForHeight,
|
maxPitchForHeight,
|
||||||
MAXIMUM_ZOOM_DISTANCE_M,
|
MAXIMUM_ZOOM_DISTANCE_M,
|
||||||
USER_ZOOM_WINDOW_MS,
|
|
||||||
} from './zoomOutGuard.js';
|
} from './zoomOutGuard.js';
|
||||||
|
|
||||||
const deg = Cesium.Math.toDegrees;
|
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', () => {
|
test('maxPitchForHeight leaves low-altitude tilt alone', () => {
|
||||||
assert.equal(maxPitchForHeight(0), null);
|
assert.equal(maxPitchForHeight(0), null);
|
||||||
@@ -18,7 +20,6 @@ test('maxPitchForHeight leaves low-altitude tilt alone', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('maxPitchForHeight looks 8 degrees below the limb', () => {
|
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.ok(Math.abs(deg(maxPitchForHeight(1_000_000)) + 38.19) < 0.01);
|
||||||
assert.equal(deg(maxPitchForHeight(1e12)), -90);
|
assert.equal(deg(maxPitchForHeight(1e12)), -90);
|
||||||
});
|
});
|
||||||
@@ -33,31 +34,51 @@ test('maxPitchForHeight steepens monotonically with height', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function guardFixture({ maximumZoomDistance = Infinity } = {}) {
|
function guardFixture({ maximumZoomDistance = Infinity } = {}) {
|
||||||
let clock = 0;
|
|
||||||
const listeners = new Map();
|
|
||||||
const preRender = new Set();
|
const preRender = new Set();
|
||||||
const container = {
|
const handoffs = new Set();
|
||||||
addEventListener(type, handler, options) {
|
const container = new EventTarget();
|
||||||
assert.equal(options.capture, true);
|
const canvas = new EventTarget();
|
||||||
listeners.set(type, handler);
|
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) {
|
destroy() {
|
||||||
assert.equal(capture, true);
|
this.destroys++;
|
||||||
if (listeners.get(type) === handler) listeners.delete(type);
|
movements.clear();
|
||||||
},
|
|
||||||
emit(event) {
|
|
||||||
listeners.get(event.type)?.(event);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const camera = {
|
const camera = {
|
||||||
height: 170_000,
|
height: 170_000,
|
||||||
|
longitude: 0,
|
||||||
|
latitude: 0,
|
||||||
heading: 0.4,
|
heading: 0.4,
|
||||||
pitch: Cesium.Math.toRadians(-25),
|
pitch: Cesium.Math.toRadians(-25),
|
||||||
roll: 0,
|
roll: 0,
|
||||||
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||||
views: [],
|
views: [],
|
||||||
get positionCartographic() {
|
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 }) {
|
setView({ orientation }) {
|
||||||
this.views.push(orientation);
|
this.views.push(orientation);
|
||||||
@@ -66,51 +87,103 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) {
|
|||||||
this.roll = orientation.roll;
|
this.roll = orientation.roll;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const controller = { maximumZoomDistance, enableInputs: true };
|
const controller = {
|
||||||
const viewer = {
|
maximumZoomDistance,
|
||||||
camera,
|
enableInputs: true,
|
||||||
container,
|
enableZoom: true,
|
||||||
scene: {
|
zoomEventTypes: [
|
||||||
screenSpaceCameraController: controller,
|
RIGHT_DRAG,
|
||||||
preRender: {
|
WHEEL,
|
||||||
addEventListener(handler) {
|
PINCH,
|
||||||
preRender.add(handler);
|
{ eventType: WHEEL, modifier: CTRL },
|
||||||
return () => preRender.delete(handler);
|
],
|
||||||
},
|
};
|
||||||
|
const scene = {
|
||||||
|
mode: Cesium.SceneMode.SCENE3D,
|
||||||
|
screenSpaceCameraController: controller,
|
||||||
|
preRender: {
|
||||||
|
addEventListener(handler) {
|
||||||
|
preRender.add(handler);
|
||||||
|
return () => preRender.delete(handler);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const renders = [];
|
const renders = [];
|
||||||
const dispose = installZoomOutGuard(viewer, {
|
const dispose = installZoomOutGuard(
|
||||||
now: () => clock,
|
{ camera, container, canvas, scene },
|
||||||
requestRender: (reason) => renders.push(reason),
|
{
|
||||||
});
|
subscribeCameraHandoff(listener) {
|
||||||
const frame = (height) => {
|
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;
|
camera.height = height;
|
||||||
for (const handler of preRender) handler();
|
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 {
|
return {
|
||||||
camera,
|
camera,
|
||||||
controller,
|
controller,
|
||||||
container,
|
scene,
|
||||||
listeners,
|
aggregator,
|
||||||
preRender,
|
preRender,
|
||||||
|
handoffs,
|
||||||
|
handoff: () => {
|
||||||
|
for (const listener of handoffs) listener();
|
||||||
|
},
|
||||||
renders,
|
renders,
|
||||||
dispose,
|
dispose,
|
||||||
frame,
|
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();
|
const f = guardFixture();
|
||||||
f.frame(170_000);
|
f.frame();
|
||||||
f.container.emit({ type: 'wheel' });
|
f.movement(WHEEL, -10);
|
||||||
f.frame(1_000_000);
|
f.frame(1_000_000);
|
||||||
const target = maxPitchForHeight(1_000_000);
|
const target = maxPitchForHeight(1_000_000);
|
||||||
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25));
|
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25));
|
||||||
assert.ok(f.camera.pitch > target, 'eases rather than snapping');
|
assert.ok(f.camera.pitch > target, 'eases rather than snapping');
|
||||||
assert.equal(f.camera.heading, 0.4);
|
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);
|
for (let i = 1; i <= 40; i++) f.frame(1_000_000 + i);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-3,
|
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);
|
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();
|
const f = guardFixture();
|
||||||
f.frame(500_000);
|
f.frame(500_000);
|
||||||
f.container.emit({ type: 'touchmove', touches: [1] });
|
f.movement(WHEEL, -100);
|
||||||
f.container.emit({ type: 'pointermove', buttons: 1 });
|
f.frame(20_000_000);
|
||||||
f.frame(600_000);
|
const limbPitch = -Math.acos(6_371_000 / (6_371_000 + 20_000_000));
|
||||||
assert.equal(f.camera.views.length, 0);
|
assert.ok(f.camera.pitch <= limbPitch);
|
||||||
f.container.emit({ type: 'touchmove', touches: [1, 2] });
|
assert.ok(f.camera.pitch > maxPitchForHeight(20_000_000));
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
const f = guardFixture();
|
||||||
f.frame(170_000);
|
f.frame(170_000);
|
||||||
f.frame(2_000_000); // programmatic flight, no input
|
f.frame(2_000_000);
|
||||||
f.container.emit({ type: 'wheel' });
|
|
||||||
f.advance(USER_ZOOM_WINDOW_MS + 1);
|
|
||||||
f.frame(3_000_000);
|
f.frame(3_000_000);
|
||||||
assert.equal(f.camera.views.length, 0);
|
assert.equal(f.camera.views.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('guard only acts while zooming out', () => {
|
test('inward zoom does not activate the guard even when altitude rises', () => {
|
||||||
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', () => {
|
|
||||||
const f = guardFixture();
|
const f = guardFixture();
|
||||||
f.frame(500_000);
|
f.frame(500_000);
|
||||||
f.container.emit({ type: 'wheel' });
|
f.movement(WHEEL, 10);
|
||||||
|
|
||||||
f.controller.enableInputs = false;
|
|
||||||
f.frame(600_000);
|
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);
|
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', () => {
|
test('outward input with falling height does not alter pitch', () => {
|
||||||
const f = guardFixture({ maximumZoomDistance: 123 });
|
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.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
|
||||||
assert.equal(f.listeners.size, 3);
|
|
||||||
assert.equal(f.preRender.size, 1);
|
assert.equal(f.preRender.size, 1);
|
||||||
|
f.frame();
|
||||||
|
assert.ok(f.aggregator.resets > 0);
|
||||||
f.dispose();
|
f.dispose();
|
||||||
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.controller.maximumZoomDistance, 123);
|
||||||
assert.equal(f.listeners.size, 0);
|
|
||||||
assert.equal(f.preRender.size, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('disposer keeps a ceiling someone else changed later', () => {
|
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', () => {
|
test('installer rejects an incomplete viewer', () => {
|
||||||
assert.throws(() => installZoomOutGuard({}), TypeError);
|
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