diff --git a/changelog.d/1522-zoom-out-runaway.md b/changelog.d/1522-zoom-out-runaway.md new file mode 100644 index 000000000..1e9c61a78 --- /dev/null +++ b/changelog.d/1522-zoom-out-runaway.md @@ -0,0 +1,5 @@ +- Keep the globe on screen when zooming out from a tilted view. User zoom is + capped at 40,000 km, and above 300 km outward zoom pitches the view down so + Earth stays framed. Correction finishes when zoom stops, respects tilt + gestures and stricter host limits, and yields to other camera actions + (asturksever, #1522). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index da72c1041..256db0530 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -44,6 +44,27 @@ pinching traverses globe and street scales with fewer gestures. Cesium retains its distance-aware zoom, collision handling and minimum/maximum distance limits. Fine-pointer desktop mouse and trackpad sensitivity is unchanged. +User zoom is capped at 40,000 km (or a stricter host limit). Explicit camera +flight, share-link and story destinations can go farther; from there, user zoom +can't climb any higher and zooms in gradually from the current height, with no +jump to the cap. Cesium can still use the limit when it computes an automatic +bounding-sphere framing distance. +Above 300 km, outward movement from the controller's configured zoom bindings +(wheel, pinch distance and unmodified right-drag by default) pitches the camera +down towards 8° below the Earth's limb. Large steps reach the limb immediately; +the remaining margin eases into place, including after zooming stops or reaches +the ceiling. Continuous outward inertia remains guarded without a time cutoff. +Two-finger tilts, modifier-specific tilts and other camera gestures cancel the +correction. A two-finger gesture counts as a pinch zoom only while the finger +spacing changes more than the fingers move up or down, so spacing drift during +a tilt is ignored. Explicit navigation handoffs, including toolbar tilt, +cancel it before animation begins. Zooming in, disabled zoom/input, non-3D modes, tracked +transforms and flights also cancel it; external pose changes cancel stationary +settling. Oblique views below 300 km are left alone. + +`QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises +the real browser controller at desktop and phone widths with keyless fixtures. + Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. Duplicate names require a geocoded containment anchor; when several entries for diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 860fa10ce..afe2e153c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -57,6 +57,9 @@ "src/app/mapLifecycle.test.mjs", "src/app/startupCamera.test.mjs", "src/app/viewer.js", + "src/app/zoomOutGuard.test.mjs", + "src/app/controls.test.mjs", + "scripts/qa-zoom-out-guard.mjs", "src/app/atmosphereCompat.test.mjs", "src/standalone/application.js", "src/standalone/controls.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 8ed8b859a..a5c43b973 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2274,6 +2274,7 @@ "src/app/layers/traffic.js", "src/app/operations.js", "src/app/scene.js", + "src/app/zoomOutGuard.js", "src/sources/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", diff --git a/scripts/qa-zoom-out-guard.mjs b/scripts/qa-zoom-out-guard.mjs new file mode 100644 index 000000000..770a4148d --- /dev/null +++ b/scripts/qa-zoom-out-guard.mjs @@ -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(); +} diff --git a/src/app/controls.js b/src/app/controls.js index ecf52f302..99bc2555f 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -1,6 +1,7 @@ import { catalogControlServices } from './catalog.js'; import { StyleManager } from '../ui/composition.js'; import { flyToAustin } from '../camera.js'; +import { installZoomOutGuard } from './zoomOutGuard.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; import { t } from '../i18n/index.js'; @@ -41,6 +42,12 @@ export function createApplicationControls({ defer(() => styleManager.orbitController.stop()); defer(() => styleManager.hud.destroy()); defer(() => styleManager.dispose()); + defer( + installZoomOutGuard(viewer, { + subscribeCameraHandoff: (listener) => + styleManager.subscribeCameraHandoff?.(listener), + }), + ); // The previous multi-canvas weather compositor remains disabled. Cockpit // clouds use a separate, capped low-resolution GPU pass that never attaches // Cesium fog or post-process stages and is fully stopped in map mode. diff --git a/src/app/controls.test.mjs b/src/app/controls.test.mjs new file mode 100644 index 000000000..3db3b7d8b --- /dev/null +++ b/src/app/controls.test.mjs @@ -0,0 +1,68 @@ +import test, { after, before } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import '../testSupport/stubMgrs.mjs'; +import { installVueDom } from '../testSupport/vueDom.mjs'; +import { MAXIMUM_ZOOM_DISTANCE_M } from './zoomOutGuard.js'; + +const { createApplicationControls } = await import('./controls.js'); + +let uninstallDom; +before(() => { + uninstallDom = installVueDom(); +}); +after(() => uninstallDom()); + +test('application controls install the zoom-out guard and remove it on teardown', () => { + const canvas = document.createElement('canvas'); + const controller = { maximumZoomDistance: Infinity }; + const scene = { + mode: Cesium.SceneMode.SCENE3D, + screenSpaceCameraController: controller, + preRender: new Cesium.Event(), + postUpdate: new Cesium.Event(), + }; + const camera = { + position: Cesium.Cartesian3.fromDegrees(0, 0, 1_000_000), + positionCartographic: { height: 1_000_000 }, + }; + const viewer = { scene, camera, canvas }; + const handoffs = new Set(); + class Controls { + hasShareState = true; + orbitController = { stop() {} }; + hud = { destroy() {} }; + dispose() {} + subscribeCameraHandoff(listener) { + handoffs.add(listener); + return () => handoffs.delete(listener); + } + } + const cleanups = []; + createApplicationControls({ + scene: { + viewer, + mapStackController: {}, + operations: { + surface: { controlServices: {} }, + searchAndFlyTo() {}, + requests: { regional: {}, weather: {} }, + }, + }, + loaderStatus: {}, + Controls, + catalog: { get: (id) => ({ id }) }, + defer: (cleanup) => cleanups.push(cleanup), + }); + + assert.equal(controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M); + assert.equal(scene.preRender.numberOfListeners, 1); + assert.equal(scene.postUpdate.numberOfListeners, 1); + assert.equal(handoffs.size, 1); + + for (const cleanup of cleanups.reverse()) cleanup(); + assert.equal(controller.maximumZoomDistance, Infinity); + assert.equal(scene.preRender.numberOfListeners, 0); + assert.equal(scene.postUpdate.numberOfListeners, 0); + assert.equal(handoffs.size, 0); +}); diff --git a/src/app/zoomOutGuard.js b/src/app/zoomOutGuard.js new file mode 100644 index 000000000..51d2fdfcd --- /dev/null +++ b/src/app/zoomOutGuard.js @@ -0,0 +1,241 @@ +import * as Cesium from 'cesium'; +import { governorRequestRender } from '../renderGovernor.js'; + +/** Whole-Earth input limit; explicit camera destinations can go farther. */ +export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000; +/** Below this, oblique browsing is left alone. */ +export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000; +/** Look this far below the Earth's limb, so the globe stays on screen. */ +export const LIMB_MARGIN_RAD = Cesium.Math.toRadians(8); +const PITCH_EASE = 0.35; +const PITCH_SNAP_RAD = Cesium.Math.toRadians(0.1); +const HEIGHT_EPSILON_M = 0.0001; +const EARTH_RADIUS_M = 6_371_000; +const INPUT_BINDINGS = Object.values(Cesium.CameraEventType).flatMap((type) => + [undefined, ...Object.values(Cesium.KeyboardEventModifier)].map( + (modifier) => ({ + type, + modifier, + }), + ), +); + +/** + * The shallowest pitch (radians, negative is down) that still keeps the Earth + * framed from height `h`, or null where tilted views are left alone. + * The limb sits acos(R / (R + h)) below the local horizontal. + */ +export function maxPitchForHeight(h) { + if (!(h >= PITCH_CLAMP_MIN_HEIGHT_M)) return null; + const limb = Math.acos(EARTH_RADIUS_M / (EARTH_RADIUS_M + h)); + return -Math.min(limb + LIMB_MARGIN_RAD, Cesium.Math.PI_OVER_TWO); +} + +function matchesZoomBinding(bindings, type, modifier) { + if (Array.isArray(bindings)) + return bindings.some((binding) => + matchesZoomBinding(binding, type, modifier), + ); + return typeof bindings === 'number' + ? bindings === type && modifier === undefined + : bindings?.eventType === type && bindings?.modifier === modifier; +} + +function readInput(aggregator, bindings) { + let outward = false; + let cancel = false; + for (const { type, modifier } of INPUT_BINDINGS) { + if (!aggregator.isMoving(type, modifier)) continue; + const movement = aggregator.getMovement(type, modifier); + const pinch = type === Cesium.CameraEventType.PINCH; + const distance = pinch ? movement.distance : movement; + const delta = distance.endPosition.y - distance.startPosition.y; + // Cesium tilts by the fingers' midpoint while zooming by their spacing, + // both in the same quarter-pixel units. Fingers rarely keep a perfectly + // even spacing, so the larger of the two movements decides the gesture. + const tilt = pinch + ? movement.angleAndHeight.endPosition.y - + movement.angleAndHeight.startPosition.y + : 0; + if ( + matchesZoomBinding(bindings, type, modifier) && + Math.abs(delta) > Math.abs(tilt) + ) { + outward ||= delta < 0; + cancel ||= delta > 0; + } else { + // Includes modifier-specific tilts and parallel two-finger movement. + cancel = true; + } + } + aggregator.reset(); + return { outward, cancel }; +} + +/** + * Keep outward user zooms bounded and framed, without taking camera ownership + * from tilts, tracked entities or flights. Returns an idempotent disposer. + */ +export function installZoomOutGuard( + viewer, + { + requestRender = governorRequestRender, + subscribeCameraHandoff, + createAggregator = (canvas) => new Cesium.CameraEventAggregator(canvas), + } = {}, +) { + const scene = viewer?.scene; + const controller = scene?.screenSpaceCameraController; + const camera = viewer?.camera; + const canvas = viewer?.canvas; + if ( + !controller || + !camera || + !canvas || + !scene.preRender || + !scene.postUpdate + ) + throw new TypeError('A complete Cesium viewer is required'); + + const aggregator = createAggregator(canvas); + const originalMaximumZoomDistance = controller.maximumZoomDistance; + const ownsCeiling = originalMaximumZoomDistance > MAXIMUM_ZOOM_DISTANCE_M; + let maximumZoomDistance = Math.min( + originalMaximumZoomDistance, + MAXIMUM_ZOOM_DISTANCE_M, + ); + controller.maximumZoomDistance = maximumZoomDistance; + // Cesium clamps any zoom that starts above the limit to the limit, in either + // direction. A camera placed higher by a flight or share link keeps its own + // height as the limit, so it zooms in gradually and can't climb further. + const followCeiling = () => { + if (!ownsCeiling || controller.maximumZoomDistance !== maximumZoomDistance) + return; + // While tracking, Cesium measures zoom from the target, not the Earth, so + // the camera's altitude would lift the ceiling with every outward step. + const untracked = + scene.mode === Cesium.SceneMode.SCENE3D && + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY); + const height = untracked ? camera.positionCartographic?.height : undefined; + maximumZoomDistance = + height > MAXIMUM_ZOOM_DISTANCE_M ? height : MAXIMUM_ZOOM_DISTANCE_M; + controller.maximumZoomDistance = maximumZoomDistance; + }; + followCeiling(); + + let lastHeight = camera.positionCartographic?.height; + let lastPosition = Cesium.Cartesian3.clone(camera.position); + let lastHeading = camera.heading; + let lastPitch = camera.pitch; + let lastRoll = camera.roll; + let zoomingOut = false; + let settling = false; + const cancel = () => { + zoomingOut = false; + settling = false; + }; + const guard = () => { + const input = readInput(aggregator, controller.zoomEventTypes); + const height = camera.positionCartographic?.height; + const climbing = height > lastHeight + HEIGHT_EPSILON_M; + const descending = height < lastHeight - HEIGHT_EPSILON_M; + try { + if ( + scene.mode !== Cesium.SceneMode.SCENE3D || + controller.enableInputs === false || + controller.enableZoom === false || + !Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY) || + // Cesium has no public running-flight flag. + camera._currentFlight || + input.cancel || + (descending && !settling) + ) { + cancel(); + return; + } + if (input.outward) { + zoomingOut = true; + // A genuine outward gesture can be blocked by the controller's ceiling. + settling = true; + } + if (climbing) { + if (!zoomingOut) return; + settling = true; + } else { + zoomingOut = false; + // Outward inertia can lower altitude when Cesium clamps slant range at + // the ceiling. Finish that correction; fresh inward input cancels above. + // A stationary correction owns only the pose it last produced. + if ( + !input.outward && + ((!descending && + !Cesium.Cartesian3.equals(camera.position, lastPosition)) || + Math.abs(Cesium.Math.negativePiToPi(camera.heading - lastHeading)) > + 1e-10 || + Math.abs(camera.pitch - lastPitch) > 1e-10 || + Math.abs(Cesium.Math.negativePiToPi(camera.roll - lastRoll)) > + 1e-10) + ) + settling = false; + if (!settling) return; + } + const maxPitch = maxPitchForHeight(height); + if (maxPitch === null || !(camera.pitch > maxPitch)) { + settling = false; + } else { + const error = maxPitch - camera.pitch; + const easedPitch = + Math.abs(error) <= PITCH_SNAP_RAD + ? maxPitch + : camera.pitch + error * PITCH_EASE; + // A large wheel step must not leave the view above the limb while easing. + const limbPitch = -Math.acos( + EARTH_RADIUS_M / (EARTH_RADIUS_M + height), + ); + const pitch = Math.min(easedPitch, limbPitch); + camera.setView({ + orientation: { heading: camera.heading, pitch, roll: camera.roll }, + }); + settling = pitch > maxPitch; + } + // Observe the end of inertia too; no arbitrary timeout cuts off a long zoom. + if (zoomingOut || settling) requestRender('zoom-out-guard'); + } finally { + lastHeight = camera.positionCartographic?.height; + lastPosition = Cesium.Cartesian3.clone(camera.position, lastPosition); + lastHeading = camera.heading; + lastPitch = camera.pitch; + lastRoll = camera.roll; + } + }; + const removePreRender = scene.preRender.addEventListener(guard); + // Fires every frame, after Cesium has applied this frame's input, while + // preRender fires only on frames that draw. Input that didn't move the camera + // (such as wheel at the ceiling) would otherwise wait for an unrelated frame. + const removePostUpdate = scene.postUpdate.addEventListener(() => { + followCeiling(); + if ( + INPUT_BINDINGS.some(({ type, modifier }) => + aggregator.isMoving(type, modifier), + ) + ) + requestRender('zoom-out-guard'); + }); + const removeCameraHandoff = subscribeCameraHandoff?.(() => { + cancel(); + // Discard input queued before an explicit navigation action took ownership. + aggregator.reset(); + }); + + let active = true; + return () => { + if (!active) return; + active = false; + removeCameraHandoff?.(); + removePreRender?.(); + removePostUpdate?.(); + aggregator.destroy(); + if (controller.maximumZoomDistance === maximumZoomDistance) + controller.maximumZoomDistance = originalMaximumZoomDistance; + }; +} diff --git a/src/app/zoomOutGuard.test.mjs b/src/app/zoomOutGuard.test.mjs new file mode 100644 index 000000000..4785cee8b --- /dev/null +++ b/src/app/zoomOutGuard.test.mjs @@ -0,0 +1,764 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + installZoomOutGuard, + maxPitchForHeight, + MAXIMUM_ZOOM_DISTANCE_M, +} from './zoomOutGuard.js'; + +const deg = Cesium.Math.toDegrees; +const { WHEEL, PINCH, RIGHT_DRAG, LEFT_DRAG, MIDDLE_DRAG } = + Cesium.CameraEventType; +const { CTRL, SHIFT, ALT } = Cesium.KeyboardEventModifier; + +test('maxPitchForHeight leaves low-altitude tilt alone', () => { + assert.equal(maxPitchForHeight(0), null); + assert.equal(maxPitchForHeight(299_999), null); + assert.equal(maxPitchForHeight(NaN), null); + assert.equal(maxPitchForHeight(undefined), null); +}); + +test('maxPitchForHeight looks 8 degrees below the limb', () => { + assert.ok(Math.abs(deg(maxPitchForHeight(1_000_000)) + 38.19) < 0.01); + assert.equal(deg(maxPitchForHeight(1e12)), -90); +}); + +test('maxPitchForHeight steepens monotonically with height', () => { + let previous = 0; + for (let h = 300_000; h <= 100_000_000; h *= 1.5) { + const pitch = maxPitchForHeight(h); + assert.ok(pitch <= previous, `pitch at ${h} m`); + previous = pitch; + } +}); + +function guardFixture({ maximumZoomDistance = Infinity } = {}) { + const preRender = new Set(); + const postUpdate = new Set(); + const handoffs = new Set(); + const container = new EventTarget(); + const canvas = new EventTarget(); + const movements = new Map(); + const key = (type, modifier) => `${type}:${modifier}`; + const aggregator = { + resets: 0, + destroys: 0, + isMoving: (type, modifier) => movements.has(key(type, modifier)), + getMovement: (type, modifier) => movements.get(key(type, modifier)), + reset() { + movements.clear(); + this.resets++; + }, + destroy() { + this.destroys++; + movements.clear(); + }, + }; + const camera = { + height: 170_000, + longitude: 0, + latitude: 0, + heading: 0.4, + pitch: Cesium.Math.toRadians(-25), + roll: 0, + transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), + views: [], + get positionCartographic() { + return { + height: this.height, + longitude: this.longitude, + latitude: this.latitude, + }; + }, + get position() { + return this.positionWC; + }, + get positionWC() { + return Cesium.Cartesian3.fromRadians( + this.longitude, + this.latitude, + this.height, + ); + }, + setView({ orientation }) { + this.views.push(orientation); + this.heading = orientation.heading; + this.pitch = orientation.pitch; + this.roll = orientation.roll; + }, + }; + const controller = { + maximumZoomDistance, + enableInputs: true, + enableZoom: true, + zoomEventTypes: [ + RIGHT_DRAG, + WHEEL, + PINCH, + { eventType: WHEEL, modifier: CTRL }, + ], + }; + const scene = { + mode: Cesium.SceneMode.SCENE3D, + screenSpaceCameraController: controller, + preRender: { + addEventListener(handler) { + preRender.add(handler); + return () => preRender.delete(handler); + }, + }, + postUpdate: { + addEventListener(handler) { + postUpdate.add(handler); + return () => postUpdate.delete(handler); + }, + }, + }; + const renders = []; + const dispose = installZoomOutGuard( + { camera, container, canvas, scene }, + { + subscribeCameraHandoff(listener) { + handoffs.add(listener); + return () => handoffs.delete(listener); + }, + createAggregator(element) { + assert.equal(element, canvas); + return aggregator; + }, + requestRender: (reason) => renders.push(reason), + }, + ); + // A frame Cesium skips drawing in request-render mode: no preRender. + const idleFrame = () => { + for (const handler of postUpdate) handler(); + }; + const frame = (height = camera.height) => { + camera.height = height; + idleFrame(); + for (const handler of preRender) handler(); + }; + const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => { + const distance = { + startPosition: new Cesium.Cartesian2(0, 0), + endPosition: new Cesium.Cartesian2(0, deltaY), + }; + movements.set( + key(type, modifier), + type === PINCH + ? { + distance, + angleAndHeight: { + startPosition: new Cesium.Cartesian2(0, 0), + endPosition: new Cesium.Cartesian2(0, tiltDeltaY), + }, + } + : distance, + ); + // Also exercise the former raw DOM path when running against the PR head. + const event = new Event( + type === WHEEL ? 'wheel' : type === PINCH ? 'touchmove' : 'pointermove', + ); + Object.assign(event, { + buttons: type === RIGHT_DRAG ? 2 : type === MIDDLE_DRAG ? 4 : 1, + ctrlKey: modifier === CTRL, + shiftKey: modifier === SHIFT, + altKey: modifier === ALT, + touches: type === PINCH ? [1, 2] : [], + }); + container.dispatchEvent(event); + }; + return { + camera, + controller, + scene, + aggregator, + preRender, + postUpdate, + handoffs, + handoff: () => { + for (const listener of handoffs) listener(); + }, + renders, + dispose, + frame, + idleFrame, + movement, + }; +} + +test('a tilted wheel zoom-out pitches down and preserves heading and roll', () => { + const f = guardFixture(); + f.frame(); + f.movement(WHEEL, -10); + f.frame(1_000_000); + const target = maxPitchForHeight(1_000_000); + assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25)); + assert.ok(f.camera.pitch > target, 'eases rather than snapping'); + assert.equal(f.camera.heading, 0.4); + assert.equal(f.camera.roll, 0); + for (let i = 1; i <= 40; i++) f.frame(1_000_000 + i); + assert.ok( + Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-3, + ); + assert.ok(f.renders.length > 0); +}); + +test('a large outward step immediately keeps the Earth limb in view', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -100); + f.frame(20_000_000); + const limbPitch = -Math.acos(6_371_000 / (6_371_000 + 20_000_000)); + assert.ok(f.camera.pitch <= limbPitch); + assert.ok(f.camera.pitch > maxPitchForHeight(20_000_000)); +}); + +test('pinch, right-drag and configured Ctrl+wheel can zoom out', () => { + for (const [type, modifier] of [[PINCH], [RIGHT_DRAG], [WHEEL, CTRL]]) { + const f = guardFixture(); + f.frame(500_000); + f.movement(type, -10, { modifier }); + f.frame(700_000); + assert.equal(f.camera.views.length, 1, `${type}:${modifier}`); + f.dispose(); + } +}); + +test('Ctrl+right-drag tilt is left at the pose chosen by Cesium', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(RIGHT_DRAG, 3, { modifier: CTRL }); + // Real Cesium's orbit tilt raises altitude with no zoom movement. + f.camera.pitch = Cesium.Math.toRadians(-26.07722459498313); + f.frame(529_392.3759876974); + assert.equal(f.camera.views.length, 0); + assert.equal(deg(f.camera.pitch), -26.07722459498313); +}); + +test('parallel two-finger tilt is not mistaken for pinch zoom', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(PINCH, 0, { tiltDeltaY: 3 }); + f.frame(600_000); + assert.equal(f.camera.views.length, 0); +}); + +test('two-finger tilt with finger-spacing drift is left to Cesium', () => { + for (const [spacing, tilt] of [ + [-0.01, 3], + [-2, 3], + [-3, -3], + ]) { + const f = guardFixture(); + f.frame(500_000); + f.movement(PINCH, spacing, { tiltDeltaY: tilt }); + // Real Cesium's two-finger tilt climbs and picks its own pitch. + f.camera.pitch = Cesium.Math.toRadians(-26.07746); + f.frame(529_406); + assert.equal(f.camera.views.length, 0, `${spacing}, ${tilt}`); + f.dispose(); + } +}); + +test('a pinch that closes the fingers more than it moves them still zooms out', () => { + // One finger still, the other sliding towards it: the midpoint moves half as far. + const f = guardFixture(); + f.frame(500_000); + f.movement(PINCH, -10, { tiltDeltaY: -5 }); + f.frame(700_000); + assert.equal(f.camera.views.length, 1); + f.dispose(); +}); + +test('unconfigured wheel modifiers and one-finger drag do not enable the guard', () => { + for (const [type, modifier] of [[WHEEL, SHIFT], [WHEEL, ALT], [LEFT_DRAG]]) { + const f = guardFixture(); + f.frame(500_000); + f.movement(type, -10, { modifier }); + f.frame(600_000); + assert.equal(f.camera.views.length, 0, `${type}:${modifier}`); + f.dispose(); + } +}); + +test('current controller zoom bindings are honored', () => { + const f = guardFixture(); + f.frame(500_000); + f.controller.zoomEventTypes = { eventType: MIDDLE_DRAG, modifier: ALT }; + f.movement(WHEEL, -10); + f.frame(600_000); + assert.equal(f.camera.views.length, 0); + f.movement(MIDDLE_DRAG, -10, { modifier: ALT }); + f.frame(700_000); + assert.equal(f.camera.views.length, 1); +}); + +test('programmatic height changes alone never activate the guard', () => { + const f = guardFixture(); + f.frame(170_000); + f.frame(2_000_000); + f.frame(3_000_000); + assert.equal(f.camera.views.length, 0); +}); + +test('inward zoom does not activate the guard even when altitude rises', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, 10); + f.frame(600_000); + assert.equal(f.camera.views.length, 0); +}); + +test('outward input with falling height does not alter pitch', () => { + const f = guardFixture(); + f.frame(2_000_000); + f.movement(WHEEL, -10); + f.frame(1_500_000); + f.frame(1_500_000); + assert.equal(f.camera.views.length, 0); +}); + +test('pitch finishes settling when the zoom stops changing height', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(2_000_000); + const firstPitch = f.camera.pitch; + for (let i = 0; i < 40; i++) f.frame(); + assert.ok(f.camera.pitch < firstPitch); + assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10); + const views = f.camera.views.length; + f.frame(); + assert.equal( + f.camera.views.length, + views, + 'settled pose does not keep rendering', + ); +}); + +test('completed settling does not authorize a later programmatic climb', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(700_000); + for (let i = 0; i < 40; i++) f.frame(); + const views = f.camera.views.length; + f.frame(3_000_000); + assert.equal(f.camera.views.length, views); +}); + +test('a fresh inward zoom or unrelated gesture cancels pending correction', () => { + for (const [type, delta, options] of [ + [WHEEL, 10, {}], + [RIGHT_DRAG, 3, { modifier: CTRL }], + [LEFT_DRAG, 3, {}], + [MIDDLE_DRAG, 3, {}], + [PINCH, 0, { tiltDeltaY: 3 }], + ]) { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(700_000); + const views = f.camera.views.length; + f.movement(type, delta, options); + f.frame(800_000); + f.frame(900_000); + assert.equal(f.camera.views.length, views, `${type}:${options.modifier}`); + f.dispose(); + } +}); + +test('an external pose change at fixed height cancels pending correction', () => { + for (const field of ['heading', 'pitch', 'roll', 'longitude', 'latitude']) { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(700_000); + const views = f.camera.views.length; + f.camera[field] += 0.1; + f.frame(); + f.frame(900_000); + assert.equal(f.camera.views.length, views, field); + f.dispose(); + } +}); + +test('disabled controls, flights, tracked transforms and non-3D scenes cancel intent', () => { + const cases = [ + (f) => { + f.controller.enableInputs = false; + return () => { + f.controller.enableInputs = true; + }; + }, + (f) => { + f.controller.enableZoom = false; + return () => { + f.controller.enableZoom = true; + }; + }, + (f) => { + f.camera._currentFlight = {}; + return () => { + delete f.camera._currentFlight; + }; + }, + (f) => { + f.camera.transform = Cesium.Matrix4.fromTranslation( + new Cesium.Cartesian3(1, 2, 3), + ); + return () => { + f.camera.transform = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY); + }; + }, + (f) => { + f.scene.mode = Cesium.SceneMode.SCENE2D; + return () => { + f.scene.mode = Cesium.SceneMode.SCENE3D; + }; + }, + ]; + for (const block of cases) { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(700_000); + const views = f.camera.views.length; + const restore = block(f); + f.movement(WHEEL, -10); + f.frame(800_000); + restore(); + f.frame(900_000); + assert.equal(f.camera.views.length, views); + f.movement(WHEEL, -10); + f.frame(1_000_000); + assert.equal(f.camera.views.length, views + 1); + f.dispose(); + } +}); + +test('installer sets the ceiling and disposer releases aggregator and frame listener', () => { + const f = guardFixture(); + assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M); + assert.equal(f.preRender.size, 1); + assert.equal(f.postUpdate.size, 1); + f.frame(); + assert.ok(f.aggregator.resets > 0); + f.dispose(); + f.dispose(); + assert.equal(f.controller.maximumZoomDistance, Infinity); + assert.equal(f.aggregator.destroys, 1); + assert.equal(f.preRender.size, 0); + assert.equal(f.postUpdate.size, 0); + assert.equal(f.handoffs.size, 0); +}); + +test('installer preserves a stricter host zoom ceiling', () => { + const f = guardFixture({ maximumZoomDistance: 123 }); + assert.equal(f.controller.maximumZoomDistance, 123); + f.dispose(); + assert.equal(f.controller.maximumZoomDistance, 123); +}); + +test('disposer keeps a ceiling someone else changed later', () => { + const f = guardFixture(); + f.controller.maximumZoomDistance = 5; + f.dispose(); + assert.equal(f.controller.maximumZoomDistance, 5); +}); + +test('installer rejects an incomplete viewer', () => { + assert.throws(() => installZoomOutGuard({}), TypeError); +}); + +test('outward input at the ceiling reframes a deliberately tilted camera', () => { + const f = guardFixture(); + f.frame(MAXIMUM_ZOOM_DISTANCE_M); + f.movement(WHEEL, -10); + f.frame(); + for (let i = 0; i < 40; i++) f.frame(); + assert.ok( + Math.abs(f.camera.pitch - maxPitchForHeight(MAXIMUM_ZOOM_DISTANCE_M)) < + 1e-10, + ); + f.dispose(); +}); + +test('input that leaves the camera still requests a frame to read it', () => { + const f = guardFixture(); + f.frame(MAXIMUM_ZOOM_DISTANCE_M); + f.idleFrame(); + assert.deepEqual(f.renders, [], 'an idle scene stays idle'); + // At the ceiling Cesium doesn't move the camera, so nothing would draw. + f.movement(WHEEL, -10); + f.idleFrame(); + assert.deepEqual(f.renders, ['zoom-out-guard']); + f.frame(); + assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25), 'read that frame'); + f.dispose(); +}); + +test('equivalent heading and roll wraps do not cancel stationary settling', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(2_000_000); + f.camera.heading += Cesium.Math.TWO_PI; + f.camera.roll += Cesium.Math.TWO_PI; + for (let i = 0; i < 40; i++) f.frame(); + assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10); + f.dispose(); +}); + +test('a real Cesium camera settles across the north heading wrap at Istanbul', () => { + const scene = { + canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 }, + drawingBufferWidth: 1200, + drawingBufferHeight: 800, + mapProjection: new Cesium.GeographicProjection(), + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + preRender: new Cesium.Event(), + postUpdate: new Cesium.Event(), + screenSpaceCameraController: { + maximumZoomDistance: Infinity, + enableInputs: true, + zoomEventTypes: [WHEEL], + }, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + const setHeight = (height) => + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, height), + orientation: { heading: 0, pitch: Cesium.Math.toRadians(-25), roll: 0 }, + }); + setHeight(1_000_000); + let outward = false; + const dispose = installZoomOutGuard( + { scene, camera, canvas: scene.canvas }, + { + createAggregator: () => ({ + isMoving: (type, modifier) => + outward && type === WHEEL && modifier === undefined, + getMovement: () => ({ + startPosition: { y: 0 }, + endPosition: { y: -10 }, + }), + reset: () => { + outward = false; + }, + destroy() {}, + }), + requestRender() {}, + }, + ); + try { + setHeight(MAXIMUM_ZOOM_DISTANCE_M); + outward = true; + for (let i = 0; i < 40; i++) scene.preRender.raiseEvent(scene); + assert.ok( + Math.abs( + camera.pitch - maxPitchForHeight(camera.positionCartographic.height), + ) < 1e-8, + ); + } finally { + dispose(); + } +}); + +test('an outward inertia ceiling adjustment can descend while pitch settles', () => { + const f = guardFixture(); + f.frame(39_000_000); + f.movement(WHEEL, -10); + f.frame(39_999_999); + const before = f.camera.pitch; + // Cesium limits slant range on an inertial pick, slightly below the altitude cap. + f.frame(39_997_258); + for (let i = 0; i < 40; i++) f.frame(); + assert.ok(f.camera.pitch < before); + assert.ok( + Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-10, + ); + f.dispose(); +}); + +test('a descending programmatic tilt cancels pending correction', () => { + const f = guardFixture(); + f.frame(39_000_000); + f.movement(WHEEL, -10); + f.frame(39_999_999); + const count = f.camera.views.length; + f.camera.pitch += 0.1; + f.frame(39_997_258); + for (let i = 0; i < 40; i++) f.frame(); + assert.equal(f.camera.views.length, count); + f.dispose(); +}); + +test('explicit navigation cancels outward inertia before a climbing UI tilt', () => { + const f = guardFixture(); + f.movement(WHEEL, -10); + f.frame(1_000_000); + f.movement(WHEEL, -10); + f.handoff(); + const writes = f.camera.views.length; + f.camera.pitch = Cesium.Math.toRadians(-25); + f.frame(1_100_000); + f.frame(1_200_000); + assert.equal(f.camera.views.length, writes); + assert.equal(deg(f.camera.pitch), -25); + // A subsequent user zoom starts a new correction normally. + f.movement(WHEEL, -10); + f.frame(1_300_000); + assert.ok(f.camera.views.length > writes); + f.dispose(); +}); + +// A real Cesium camera and controller, fed by DOM wheel events on the canvas. +function realControllerScene(startHeight) { + const canvas = Object.assign(new EventTarget(), { + clientWidth: 1200, + clientHeight: 800, + width: 1200, + height: 800, + style: {}, + // Keep Cesium's input handler off the missing `document`. + disableRootEvents: true, + onwheel: null, + getBoundingClientRect: () => ({ + left: 0, + top: 0, + width: 1200, + height: 800, + }), + }); + const scene = { + canvas, + drawingBufferWidth: 1200, + drawingBufferHeight: 800, + mapProjection: new Cesium.GeographicProjection(), + ellipsoid: Cesium.Ellipsoid.WGS84, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + verticalExaggeration: 1, + verticalExaggerationRelativeHeight: 0, + preRender: new Cesium.Event(), + postUpdate: new Cesium.Event(), + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + const controller = new Cesium.ScreenSpaceCameraController(scene); + controller.inertiaZoom = 0; + scene.screenSpaceCameraController = controller; + const setHeight = (h) => + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, h), + orientation: { heading: 0, pitch: -Cesium.Math.PI_OVER_TWO, roll: 0 }, + }); + setHeight(startHeight); + const dispose = installZoomOutGuard( + { scene, camera, canvas }, + { requestRender() {} }, + ); + // Scene.render order: the controller applies input, then postUpdate, then + // preRender on frames that draw. + const frame = () => { + controller.update(); + scene.postUpdate.raiseEvent(scene); + scene.preRender.raiseEvent(scene); + }; + const wheel = (deltaY) => + canvas.dispatchEvent( + Object.assign(new Event('wheel'), { + deltaY, + deltaMode: 0, + clientX: 600, + clientY: 400, + }), + ); + const height = () => camera.positionCartographic.height; + return { + scene, + camera, + controller, + setHeight, + frame, + wheel, + height, + dispose, + }; +} + +test('a camera placed above the ceiling cannot zoom out and zooms in without a jump', () => { + const s = realControllerScene(60_000_000); + try { + s.frame(); + s.wheel(1); + s.frame(); + assert.ok(Math.abs(s.height() - 60_000_000) < 1, `${s.height()} m`); + s.wheel(-1); + s.frame(); + const firstStep = s.height(); + assert.ok(firstStep < 60_000_000, 'zooms in'); + assert.ok(firstStep > 50_000_000, `${firstStep} m is gradual`); + // The camera can no longer climb back above where it has descended to. + s.wheel(1); + s.frame(); + assert.ok(Math.abs(s.height() - firstStep) < 1, `${s.height()} m`); + for (let i = 0; i < 40; i++) { + s.wheel(-100); + s.frame(); + } + assert.ok(s.height() < MAXIMUM_ZOOM_DISTANCE_M); + assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M); + for (let i = 0; i < 80; i++) { + s.wheel(100); + s.frame(); + } + assert.ok(Math.abs(s.height() - MAXIMUM_ZOOM_DISTANCE_M) < 1_000); + } finally { + s.dispose(); + } + assert.equal(s.controller.maximumZoomDistance, Infinity); +}); + +test('a share-link view restored above the ceiling is honoured by later frames', () => { + const s = realControllerScene(1_000_000); + try { + s.frame(); + s.setHeight(97_679_259); + s.frame(); + s.wheel(100); + s.frame(); + assert.ok(Math.abs(s.height() - 97_679_259) < 1, `${s.height()} m`); + } finally { + s.dispose(); + } +}); + +test('a tracked camera cannot climb past the ceiling from its target', () => { + const s = realControllerScene(1_000_000); + try { + s.frame(); + // In look-at mode Cesium measures zoom from the target, not the Earth. + s.camera.lookAt( + Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000), + new Cesium.HeadingPitchRange(0, -0.5, 5_000_000), + ); + for (let i = 0; i < 20; i++) { + s.wheel(100); + s.frame(); + } + const range = Cesium.Cartesian3.magnitude(s.camera.position); + assert.ok(range <= MAXIMUM_ZOOM_DISTANCE_M + 1_000, `${range} m`); + assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M); + } finally { + s.dispose(); + } +});