fix: respect camera ownership and finish outward zoom framing

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

No files matched your search

+5 -3
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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",
+333
View File
@@ -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();
}
+7
View File
@@ -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.
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
});