From a236504347307ee5cc3c3bcf7e957eece5292ae7 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 3 Nov 2026 14:32:30 +0000 Subject: [PATCH] fix: respect camera ownership and finish outward zoom framing --- changelog.d/1522-zoom-out-runaway.md | 8 +- docs/CURRENT-STATE.md | 23 +- scripts/format-scope.json | 2 +- scripts/qa-zoom-out-guard.mjs | 333 +++++++++++++++++ src/app/controls.js | 7 + src/app/scene.js | 2 - src/app/zoomOutGuard.js | 198 +++++++--- src/app/zoomOutGuard.test.mjs | 530 ++++++++++++++++++++++----- 8 files changed, 951 insertions(+), 152 deletions(-) create mode 100644 scripts/qa-zoom-out-guard.mjs diff --git a/changelog.d/1522-zoom-out-runaway.md b/changelog.d/1522-zoom-out-runaway.md index 0e617462d..1e9c61a78 100644 --- a/changelog.d/1522-zoom-out-runaway.md +++ b/changelog.d/1522-zoom-out-runaway.md @@ -1,3 +1,5 @@ -- Keep the globe on screen when zooming out from a tilted view. User zoom now - stops at 40,000 km, and above 300 km a zoom-out pitches the view down so the - Earth stays framed instead of sliding off into space (asturksever, #1522). +- Keep the globe on screen when zooming out from a tilted view. User zoom is + capped at 40,000 km, and above 300 km outward zoom pitches the view down so + Earth stays framed. Correction finishes when zoom stops, respects tilt + gestures and stricter host limits, and yields to other camera actions + (asturksever, #1522). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f88d6e39d..4cec16fd8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -44,13 +44,22 @@ pinching traverses globe and street scales with fewer gestures. Cesium retains its distance-aware zoom, collision handling and minimum/maximum distance limits. Fine-pointer desktop mouse and trackpad sensitivity is unchanged. -User zoom stops at 40,000 km above the surface, a whole-Earth view; camera -flights, share links and story views are not limited. Above 300 km, a -wheel, pinch or right-drag zoom-out from a shallow, tilted view pitches the -camera down as it climbs, keeping the view at least 8° below the Earth's limb, -so the globe stays on screen instead of sliding off into space. Oblique views -below 300 km, zooming in, tilting by drag, tracked entities, cockpit tracking -and camera flights are left alone. +User zoom is capped at 40,000 km (or a stricter host limit). Explicit camera +flight, share-link and story destinations can go farther; Cesium can still use +the limit when it computes an automatic bounding-sphere framing distance. +Above 300 km, outward movement from the controller's configured zoom bindings +(wheel, pinch distance and unmodified right-drag by default) pitches the camera +down towards 8° below the Earth's limb. Large steps reach the limb immediately; +the remaining margin eases into place, including after zooming stops or reaches +the ceiling. Continuous outward inertia remains guarded without a time cutoff. +Parallel two-finger tilts, modifier-specific tilts and other camera gestures +cancel the correction. Explicit navigation handoffs, including toolbar tilt, +cancel it before animation begins. Zooming in, disabled zoom/input, non-3D modes, tracked +transforms and flights also cancel it; external pose changes cancel stationary +settling. Oblique views below 300 km are left alone. + +`QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises +the real browser controller at desktop and phone widths with keyless fixtures. Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9052475ac..99f76388c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -57,8 +57,8 @@ "src/app/mapLifecycle.test.mjs", "src/app/startupCamera.test.mjs", "src/app/viewer.js", - "src/app/zoomOutGuard.js", "src/app/zoomOutGuard.test.mjs", + "scripts/qa-zoom-out-guard.mjs", "src/app/atmosphereCompat.test.mjs", "src/standalone/application.js", "src/standalone/controls.js", 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/scene.js b/src/app/scene.js index 3ca680b0a..1455b8268 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -4,7 +4,6 @@ import { createApplicationViewer, installTrackpadPinchZoom, } from '../app/viewer.js'; -import { installZoomOutGuard } from './zoomOutGuard.js'; import { registerDataCredits } from '../data/dataCredits.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; @@ -59,7 +58,6 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); - defer(installZoomOutGuard(viewer)); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/app/zoomOutGuard.js b/src/app/zoomOutGuard.js index 14d09556d..ccf6ca78b 100644 --- a/src/app/zoomOutGuard.js +++ b/src/app/zoomOutGuard.js @@ -1,18 +1,24 @@ import * as Cesium from 'cesium'; import { governorRequestRender } from '../renderGovernor.js'; -/** Whole-Earth view. Programmatic flights ignore it; only user input stops here. */ +/** Whole-Earth input limit; explicit camera destinations can go farther. */ export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000; /** Below this, oblique browsing is left alone. */ export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000; /** Look this far below the Earth's limb, so the globe stays on screen. */ export const LIMB_MARGIN_RAD = Cesium.Math.toRadians(8); -/** How long after the last zoom input the camera still counts as user-zoomed. */ -export const USER_ZOOM_WINDOW_MS = 500; -/** Fraction of the remaining pitch error closed per frame. */ const PITCH_EASE = 0.35; const PITCH_SNAP_RAD = Cesium.Math.toRadians(0.1); +const HEIGHT_EPSILON_M = 0.0001; const EARTH_RADIUS_M = 6_371_000; +const INPUT_BINDINGS = Object.values(Cesium.CameraEventType).flatMap((type) => + [undefined, ...Object.values(Cesium.KeyboardEventModifier)].map( + (modifier) => ({ + type, + modifier, + }), + ), +); /** * The shallowest pitch (radians, negative is down) that still keeps the Earth @@ -25,82 +31,164 @@ export function maxPitchForHeight(h) { return -Math.min(limb + LIMB_MARGIN_RAD, Cesium.Math.PI_OVER_TWO); } -function isUserZoomInput(event) { - if (event.type === 'wheel') return true; - if (event.type === 'touchmove') return (event.touches?.length ?? 0) >= 2; - // Cesium's default zoom bindings also include a right-button drag. - return event.type === 'pointermove' && (event.buttons & 2) === 2; +function matchesZoomBinding(bindings, type, modifier) { + if (Array.isArray(bindings)) + return bindings.some((binding) => + matchesZoomBinding(binding, type, modifier), + ); + return typeof bindings === 'number' + ? bindings === type && modifier === undefined + : bindings?.eventType === type && bindings?.modifier === modifier; +} + +function readInput(aggregator, bindings) { + let outward = false; + let cancel = false; + for (const { type, modifier } of INPUT_BINDINGS) { + if (!aggregator.isMoving(type, modifier)) continue; + const movement = aggregator.getMovement(type, modifier); + const distance = + type === Cesium.CameraEventType.PINCH ? movement.distance : movement; + const delta = distance.endPosition.y - distance.startPosition.y; + if (matchesZoomBinding(bindings, type, modifier) && delta !== 0) { + outward ||= delta < 0; + cancel ||= delta > 0; + } else { + // Includes modifier-specific tilts and parallel two-finger movement. + cancel = true; + } + } + aggregator.reset(); + return { outward, cancel }; } /** - * Stop a tilted wheel/pinch zoom-out from flying the camera off into space. - * - * Cesium zooms a shallow view out by backing along the cursor ray with the - * pitch held fixed, and with no ceiling. The limb drops below a fixed pitch - * by ~700 km, then the steps grow with height and never stop. This caps the - * zoom distance and, only while the user is zooming out, pitches the view - * down to keep the globe framed. Returns its disposer. + * Keep outward user zooms bounded and framed, without taking camera ownership + * from tilts, tracked entities or flights. Returns an idempotent disposer. */ export function installZoomOutGuard( viewer, - { now = () => performance.now(), requestRender = governorRequestRender } = {}, + { + requestRender = governorRequestRender, + subscribeCameraHandoff, + createAggregator = (canvas) => new Cesium.CameraEventAggregator(canvas), + } = {}, ) { const scene = viewer?.scene; const controller = scene?.screenSpaceCameraController; const camera = viewer?.camera; - const container = viewer?.container; - if (!controller || !camera || !container || !scene.preRender) + const canvas = viewer?.canvas; + if (!controller || !camera || !canvas || !scene.preRender) throw new TypeError('A complete Cesium viewer is required'); + const aggregator = createAggregator(canvas); const originalMaximumZoomDistance = controller.maximumZoomDistance; - controller.maximumZoomDistance = MAXIMUM_ZOOM_DISTANCE_M; + const maximumZoomDistance = Math.min( + originalMaximumZoomDistance, + MAXIMUM_ZOOM_DISTANCE_M, + ); + controller.maximumZoomDistance = maximumZoomDistance; - let lastUserZoomAt = -Infinity; - const markInput = (event) => { - if (isUserZoomInput(event)) lastUserZoomAt = now(); + let lastHeight = camera.positionCartographic?.height; + let lastPosition = Cesium.Cartesian3.clone(camera.position); + let lastHeading = camera.heading; + let lastPitch = camera.pitch; + let lastRoll = camera.roll; + let zoomingOut = false; + let settling = false; + const cancel = () => { + zoomingOut = false; + settling = false; }; - const inputTypes = ['wheel', 'touchmove', 'pointermove']; - for (const type of inputTypes) - container.addEventListener(type, markInput, { - capture: true, - passive: true, - }); - - let lastHeight = NaN; const guard = () => { + const input = readInput(aggregator, controller.zoomEventTypes); const height = camera.positionCartographic?.height; - const previousHeight = lastHeight; - lastHeight = height; - if (!(height > previousHeight)) return; // Zoom-out only. - if (now() - lastUserZoomAt > USER_ZOOM_WINDOW_MS) return; - if (controller.enableInputs === false) return; - if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)) - return; - // Cesium keeps no public flag for a running flyTo; never fight one. - if (camera._currentFlight) return; - const maxPitch = maxPitchForHeight(height); - if (maxPitch === null || !(camera.pitch > maxPitch)) return; - const error = maxPitch - camera.pitch; - const pitch = - Math.abs(error) <= PITCH_SNAP_RAD - ? maxPitch - : camera.pitch + error * PITCH_EASE; - camera.setView({ - orientation: { heading: camera.heading, pitch, roll: camera.roll }, - }); - lastHeight = camera.positionCartographic?.height; - requestRender('zoom-out-guard'); + const climbing = height > lastHeight + HEIGHT_EPSILON_M; + const descending = height < lastHeight - HEIGHT_EPSILON_M; + try { + if ( + scene.mode !== Cesium.SceneMode.SCENE3D || + controller.enableInputs === false || + controller.enableZoom === false || + !Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY) || + // Cesium has no public running-flight flag. + camera._currentFlight || + input.cancel || + (descending && !settling) + ) { + cancel(); + return; + } + if (input.outward) { + zoomingOut = true; + // A genuine outward gesture can be blocked by the controller's ceiling. + settling = true; + } + if (climbing) { + if (!zoomingOut) return; + settling = true; + } else { + zoomingOut = false; + // Outward inertia can lower altitude when Cesium clamps slant range at + // the ceiling. Finish that correction; fresh inward input cancels above. + // A stationary correction owns only the pose it last produced. + if ( + !input.outward && + ((!descending && + !Cesium.Cartesian3.equals(camera.position, lastPosition)) || + Math.abs(Cesium.Math.negativePiToPi(camera.heading - lastHeading)) > + 1e-10 || + Math.abs(camera.pitch - lastPitch) > 1e-10 || + Math.abs(Cesium.Math.negativePiToPi(camera.roll - lastRoll)) > + 1e-10) + ) + settling = false; + if (!settling) return; + } + const maxPitch = maxPitchForHeight(height); + if (maxPitch === null || !(camera.pitch > maxPitch)) { + settling = false; + } else { + const error = maxPitch - camera.pitch; + const easedPitch = + Math.abs(error) <= PITCH_SNAP_RAD + ? maxPitch + : camera.pitch + error * PITCH_EASE; + // A large wheel step must not leave the view above the limb while easing. + const limbPitch = -Math.acos( + EARTH_RADIUS_M / (EARTH_RADIUS_M + height), + ); + const pitch = Math.min(easedPitch, limbPitch); + camera.setView({ + orientation: { heading: camera.heading, pitch, roll: camera.roll }, + }); + settling = pitch > maxPitch; + } + // Observe the end of inertia too; no arbitrary timeout cuts off a long zoom. + if (zoomingOut || settling) requestRender('zoom-out-guard'); + } finally { + lastHeight = camera.positionCartographic?.height; + lastPosition = Cesium.Cartesian3.clone(camera.position, lastPosition); + lastHeading = camera.heading; + lastPitch = camera.pitch; + lastRoll = camera.roll; + } }; const removePreRender = scene.preRender.addEventListener(guard); + const removeCameraHandoff = subscribeCameraHandoff?.(() => { + cancel(); + // Discard input queued before an explicit navigation action took ownership. + aggregator.reset(); + }); let active = true; return () => { if (!active) return; active = false; + removeCameraHandoff?.(); removePreRender?.(); - for (const type of inputTypes) - container.removeEventListener(type, markInput, true); - if (controller.maximumZoomDistance === MAXIMUM_ZOOM_DISTANCE_M) + aggregator.destroy(); + if (controller.maximumZoomDistance === maximumZoomDistance) controller.maximumZoomDistance = originalMaximumZoomDistance; }; } diff --git a/src/app/zoomOutGuard.test.mjs b/src/app/zoomOutGuard.test.mjs index 568490b00..400113b79 100644 --- a/src/app/zoomOutGuard.test.mjs +++ b/src/app/zoomOutGuard.test.mjs @@ -5,10 +5,12 @@ import { installZoomOutGuard, maxPitchForHeight, MAXIMUM_ZOOM_DISTANCE_M, - USER_ZOOM_WINDOW_MS, } from './zoomOutGuard.js'; const deg = Cesium.Math.toDegrees; +const { WHEEL, PINCH, RIGHT_DRAG, LEFT_DRAG, MIDDLE_DRAG } = + Cesium.CameraEventType; +const { CTRL, SHIFT, ALT } = Cesium.KeyboardEventModifier; test('maxPitchForHeight leaves low-altitude tilt alone', () => { assert.equal(maxPitchForHeight(0), null); @@ -18,7 +20,6 @@ test('maxPitchForHeight leaves low-altitude tilt alone', () => { }); test('maxPitchForHeight looks 8 degrees below the limb', () => { - // Limb at 1,000 km is 30.2 degrees below the horizontal. assert.ok(Math.abs(deg(maxPitchForHeight(1_000_000)) + 38.19) < 0.01); assert.equal(deg(maxPitchForHeight(1e12)), -90); }); @@ -33,31 +34,51 @@ test('maxPitchForHeight steepens monotonically with height', () => { }); function guardFixture({ maximumZoomDistance = Infinity } = {}) { - let clock = 0; - const listeners = new Map(); const preRender = new Set(); - const container = { - addEventListener(type, handler, options) { - assert.equal(options.capture, true); - listeners.set(type, handler); + const handoffs = new Set(); + const container = new EventTarget(); + const canvas = new EventTarget(); + const movements = new Map(); + const key = (type, modifier) => `${type}:${modifier}`; + const aggregator = { + resets: 0, + destroys: 0, + isMoving: (type, modifier) => movements.has(key(type, modifier)), + getMovement: (type, modifier) => movements.get(key(type, modifier)), + reset() { + movements.clear(); + this.resets++; }, - removeEventListener(type, handler, capture) { - assert.equal(capture, true); - if (listeners.get(type) === handler) listeners.delete(type); - }, - emit(event) { - listeners.get(event.type)?.(event); + destroy() { + this.destroys++; + movements.clear(); }, }; const camera = { height: 170_000, + longitude: 0, + latitude: 0, heading: 0.4, pitch: Cesium.Math.toRadians(-25), roll: 0, transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), views: [], get positionCartographic() { - return { height: this.height }; + return { + height: this.height, + longitude: this.longitude, + latitude: this.latitude, + }; + }, + get position() { + return this.positionWC; + }, + get positionWC() { + return Cesium.Cartesian3.fromRadians( + this.longitude, + this.latitude, + this.height, + ); }, setView({ orientation }) { this.views.push(orientation); @@ -66,51 +87,103 @@ function guardFixture({ maximumZoomDistance = Infinity } = {}) { this.roll = orientation.roll; }, }; - const controller = { maximumZoomDistance, enableInputs: true }; - const viewer = { - camera, - container, - scene: { - screenSpaceCameraController: controller, - preRender: { - addEventListener(handler) { - preRender.add(handler); - return () => preRender.delete(handler); - }, + const controller = { + maximumZoomDistance, + enableInputs: true, + enableZoom: true, + zoomEventTypes: [ + RIGHT_DRAG, + WHEEL, + PINCH, + { eventType: WHEEL, modifier: CTRL }, + ], + }; + const scene = { + mode: Cesium.SceneMode.SCENE3D, + screenSpaceCameraController: controller, + preRender: { + addEventListener(handler) { + preRender.add(handler); + return () => preRender.delete(handler); }, }, }; const renders = []; - const dispose = installZoomOutGuard(viewer, { - now: () => clock, - requestRender: (reason) => renders.push(reason), - }); - const frame = (height) => { + const dispose = installZoomOutGuard( + { camera, container, canvas, scene }, + { + subscribeCameraHandoff(listener) { + handoffs.add(listener); + return () => handoffs.delete(listener); + }, + createAggregator(element) { + assert.equal(element, canvas); + return aggregator; + }, + requestRender: (reason) => renders.push(reason), + }, + ); + const frame = (height = camera.height) => { camera.height = height; for (const handler of preRender) handler(); }; + const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => { + const distance = { + startPosition: new Cesium.Cartesian2(0, 0), + endPosition: new Cesium.Cartesian2(0, deltaY), + }; + movements.set( + key(type, modifier), + type === PINCH + ? { + distance, + angleAndHeight: { + startPosition: new Cesium.Cartesian2(0, 0), + endPosition: new Cesium.Cartesian2(0, tiltDeltaY), + }, + } + : distance, + ); + // Also exercise the former raw DOM path when running against the PR head. + const event = new Event( + type === WHEEL ? 'wheel' : type === PINCH ? 'touchmove' : 'pointermove', + ); + Object.assign(event, { + buttons: type === RIGHT_DRAG ? 2 : type === MIDDLE_DRAG ? 4 : 1, + ctrlKey: modifier === CTRL, + shiftKey: modifier === SHIFT, + altKey: modifier === ALT, + touches: type === PINCH ? [1, 2] : [], + }); + container.dispatchEvent(event); + }; return { camera, controller, - container, - listeners, + scene, + aggregator, preRender, + handoffs, + handoff: () => { + for (const listener of handoffs) listener(); + }, renders, dispose, frame, - advance: (ms) => (clock += ms), + movement, }; } -test('a tilted wheel zoom-out pitches down to keep the globe framed', () => { +test('a tilted wheel zoom-out pitches down and preserves heading and roll', () => { const f = guardFixture(); - f.frame(170_000); - f.container.emit({ type: 'wheel' }); + f.frame(); + f.movement(WHEEL, -10); f.frame(1_000_000); const target = maxPitchForHeight(1_000_000); assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25)); assert.ok(f.camera.pitch > target, 'eases rather than snapping'); assert.equal(f.camera.heading, 0.4); + assert.equal(f.camera.roll, 0); for (let i = 1; i <= 40; i++) f.frame(1_000_000 + i); assert.ok( Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-3, @@ -118,74 +191,232 @@ test('a tilted wheel zoom-out pitches down to keep the globe framed', () => { assert.ok(f.renders.length > 0); }); -test('two-finger touch and right-drag count as zoom input, one finger does not', () => { +test('a large outward step immediately keeps the Earth limb in view', () => { const f = guardFixture(); f.frame(500_000); - f.container.emit({ type: 'touchmove', touches: [1] }); - f.container.emit({ type: 'pointermove', buttons: 1 }); - f.frame(600_000); - assert.equal(f.camera.views.length, 0); - f.container.emit({ type: 'touchmove', touches: [1, 2] }); - f.frame(700_000); - assert.equal(f.camera.views.length, 1); - f.container.emit({ type: 'pointermove', buttons: 2 }); - f.frame(800_000); - assert.equal(f.camera.views.length, 2); + f.movement(WHEEL, -100); + f.frame(20_000_000); + const limbPitch = -Math.acos(6_371_000 / (6_371_000 + 20_000_000)); + assert.ok(f.camera.pitch <= limbPitch); + assert.ok(f.camera.pitch > maxPitchForHeight(20_000_000)); }); -test('guard leaves the camera alone without recent user zoom input', () => { +test('pinch, right-drag and configured Ctrl+wheel can zoom out', () => { + for (const [type, modifier] of [[PINCH], [RIGHT_DRAG], [WHEEL, CTRL]]) { + const f = guardFixture(); + f.frame(500_000); + f.movement(type, -10, { modifier }); + f.frame(700_000); + assert.equal(f.camera.views.length, 1, `${type}:${modifier}`); + f.dispose(); + } +}); + +test('Ctrl+right-drag tilt is left at the pose chosen by Cesium', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(RIGHT_DRAG, 3, { modifier: CTRL }); + // Real Cesium's orbit tilt raises altitude with no zoom movement. + f.camera.pitch = Cesium.Math.toRadians(-26.07722459498313); + f.frame(529_392.3759876974); + assert.equal(f.camera.views.length, 0); + assert.equal(deg(f.camera.pitch), -26.07722459498313); +}); + +test('parallel two-finger tilt is not mistaken for pinch zoom', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(PINCH, 0, { tiltDeltaY: 3 }); + f.frame(600_000); + assert.equal(f.camera.views.length, 0); +}); + +test('unconfigured wheel modifiers and one-finger drag do not enable the guard', () => { + for (const [type, modifier] of [[WHEEL, SHIFT], [WHEEL, ALT], [LEFT_DRAG]]) { + const f = guardFixture(); + f.frame(500_000); + f.movement(type, -10, { modifier }); + f.frame(600_000); + assert.equal(f.camera.views.length, 0, `${type}:${modifier}`); + f.dispose(); + } +}); + +test('current controller zoom bindings are honored', () => { + const f = guardFixture(); + f.frame(500_000); + f.controller.zoomEventTypes = { eventType: MIDDLE_DRAG, modifier: ALT }; + f.movement(WHEEL, -10); + f.frame(600_000); + assert.equal(f.camera.views.length, 0); + f.movement(MIDDLE_DRAG, -10, { modifier: ALT }); + f.frame(700_000); + assert.equal(f.camera.views.length, 1); +}); + +test('programmatic height changes alone never activate the guard', () => { const f = guardFixture(); f.frame(170_000); - f.frame(2_000_000); // programmatic flight, no input - f.container.emit({ type: 'wheel' }); - f.advance(USER_ZOOM_WINDOW_MS + 1); + f.frame(2_000_000); f.frame(3_000_000); assert.equal(f.camera.views.length, 0); }); -test('guard only acts while zooming out', () => { - const f = guardFixture(); - f.frame(2_000_000); - f.container.emit({ type: 'wheel' }); - f.frame(1_500_000); - f.frame(1_500_000); - assert.equal(f.camera.views.length, 0); -}); - -test('guard yields to disabled inputs, tracked transforms and flights', () => { +test('inward zoom does not activate the guard even when altitude rises', () => { const f = guardFixture(); f.frame(500_000); - f.container.emit({ type: 'wheel' }); - - f.controller.enableInputs = false; + f.movement(WHEEL, 10); f.frame(600_000); - f.controller.enableInputs = true; - - f.camera.transform = Cesium.Matrix4.fromTranslation( - new Cesium.Cartesian3(1, 2, 3), - ); - f.frame(700_000); - f.camera.transform = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY); - - f.camera._currentFlight = {}; - f.frame(800_000); - delete f.camera._currentFlight; assert.equal(f.camera.views.length, 0); - - f.frame(900_000); - assert.equal(f.camera.views.length, 1); }); -test('installer sets the zoom ceiling and the disposer restores it', () => { - const f = guardFixture({ maximumZoomDistance: 123 }); +test('outward input with falling height does not alter pitch', () => { + const f = guardFixture(); + f.frame(2_000_000); + f.movement(WHEEL, -10); + f.frame(1_500_000); + f.frame(1_500_000); + assert.equal(f.camera.views.length, 0); +}); + +test('pitch finishes settling when the zoom stops changing height', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(2_000_000); + const firstPitch = f.camera.pitch; + for (let i = 0; i < 40; i++) f.frame(); + assert.ok(f.camera.pitch < firstPitch); + assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10); + const views = f.camera.views.length; + f.frame(); + assert.equal( + f.camera.views.length, + views, + 'settled pose does not keep rendering', + ); +}); + +test('completed settling does not authorize a later programmatic climb', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(700_000); + for (let i = 0; i < 40; i++) f.frame(); + const views = f.camera.views.length; + f.frame(3_000_000); + assert.equal(f.camera.views.length, views); +}); + +test('a fresh inward zoom or unrelated gesture cancels pending correction', () => { + for (const [type, delta, options] of [ + [WHEEL, 10, {}], + [RIGHT_DRAG, 3, { modifier: CTRL }], + [LEFT_DRAG, 3, {}], + [MIDDLE_DRAG, 3, {}], + [PINCH, 0, { tiltDeltaY: 3 }], + ]) { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(700_000); + const views = f.camera.views.length; + f.movement(type, delta, options); + f.frame(800_000); + f.frame(900_000); + assert.equal(f.camera.views.length, views, `${type}:${options.modifier}`); + f.dispose(); + } +}); + +test('an external pose change at fixed height cancels pending correction', () => { + for (const field of ['heading', 'pitch', 'roll', 'longitude', 'latitude']) { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(700_000); + const views = f.camera.views.length; + f.camera[field] += 0.1; + f.frame(); + f.frame(900_000); + assert.equal(f.camera.views.length, views, field); + f.dispose(); + } +}); + +test('disabled controls, flights, tracked transforms and non-3D scenes cancel intent', () => { + const cases = [ + (f) => { + f.controller.enableInputs = false; + return () => { + f.controller.enableInputs = true; + }; + }, + (f) => { + f.controller.enableZoom = false; + return () => { + f.controller.enableZoom = true; + }; + }, + (f) => { + f.camera._currentFlight = {}; + return () => { + delete f.camera._currentFlight; + }; + }, + (f) => { + f.camera.transform = Cesium.Matrix4.fromTranslation( + new Cesium.Cartesian3(1, 2, 3), + ); + return () => { + f.camera.transform = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY); + }; + }, + (f) => { + f.scene.mode = Cesium.SceneMode.SCENE2D; + return () => { + f.scene.mode = Cesium.SceneMode.SCENE3D; + }; + }, + ]; + for (const block of cases) { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(700_000); + const views = f.camera.views.length; + const restore = block(f); + f.movement(WHEEL, -10); + f.frame(800_000); + restore(); + f.frame(900_000); + assert.equal(f.camera.views.length, views); + f.movement(WHEEL, -10); + f.frame(1_000_000); + assert.equal(f.camera.views.length, views + 1); + f.dispose(); + } +}); + +test('installer sets the ceiling and disposer releases aggregator and frame listener', () => { + const f = guardFixture(); assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M); - assert.equal(f.listeners.size, 3); assert.equal(f.preRender.size, 1); + f.frame(); + assert.ok(f.aggregator.resets > 0); f.dispose(); f.dispose(); + assert.equal(f.controller.maximumZoomDistance, Infinity); + assert.equal(f.aggregator.destroys, 1); + assert.equal(f.preRender.size, 0); + assert.equal(f.handoffs.size, 0); +}); + +test('installer preserves a stricter host zoom ceiling', () => { + const f = guardFixture({ maximumZoomDistance: 123 }); + assert.equal(f.controller.maximumZoomDistance, 123); + f.dispose(); assert.equal(f.controller.maximumZoomDistance, 123); - assert.equal(f.listeners.size, 0); - assert.equal(f.preRender.size, 0); }); test('disposer keeps a ceiling someone else changed later', () => { @@ -198,3 +429,134 @@ test('disposer keeps a ceiling someone else changed later', () => { test('installer rejects an incomplete viewer', () => { assert.throws(() => installZoomOutGuard({}), TypeError); }); + +test('outward input at the ceiling reframes a deliberately tilted camera', () => { + const f = guardFixture(); + f.frame(MAXIMUM_ZOOM_DISTANCE_M); + f.movement(WHEEL, -10); + f.frame(); + for (let i = 0; i < 40; i++) f.frame(); + assert.ok( + Math.abs(f.camera.pitch - maxPitchForHeight(MAXIMUM_ZOOM_DISTANCE_M)) < + 1e-10, + ); + f.dispose(); +}); + +test('equivalent heading and roll wraps do not cancel stationary settling', () => { + const f = guardFixture(); + f.frame(500_000); + f.movement(WHEEL, -10); + f.frame(2_000_000); + f.camera.heading += Cesium.Math.TWO_PI; + f.camera.roll += Cesium.Math.TWO_PI; + for (let i = 0; i < 40; i++) f.frame(); + assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10); + f.dispose(); +}); + +test('a real Cesium camera settles across the north heading wrap at Istanbul', () => { + const scene = { + canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 }, + drawingBufferWidth: 1200, + drawingBufferHeight: 800, + mapProjection: new Cesium.GeographicProjection(), + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + preRender: new Cesium.Event(), + screenSpaceCameraController: { + maximumZoomDistance: Infinity, + enableInputs: true, + zoomEventTypes: [WHEEL], + }, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + const setHeight = (height) => + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, height), + orientation: { heading: 0, pitch: Cesium.Math.toRadians(-25), roll: 0 }, + }); + setHeight(1_000_000); + let outward = false; + const dispose = installZoomOutGuard( + { scene, camera, canvas: scene.canvas }, + { + createAggregator: () => ({ + isMoving: (type, modifier) => + outward && type === WHEEL && modifier === undefined, + getMovement: () => ({ + startPosition: { y: 0 }, + endPosition: { y: -10 }, + }), + reset: () => { + outward = false; + }, + destroy() {}, + }), + requestRender() {}, + }, + ); + try { + setHeight(MAXIMUM_ZOOM_DISTANCE_M); + outward = true; + for (let i = 0; i < 40; i++) scene.preRender.raiseEvent(scene); + assert.ok( + Math.abs( + camera.pitch - maxPitchForHeight(camera.positionCartographic.height), + ) < 1e-8, + ); + } finally { + dispose(); + } +}); + +test('an outward inertia ceiling adjustment can descend while pitch settles', () => { + const f = guardFixture(); + f.frame(39_000_000); + f.movement(WHEEL, -10); + f.frame(39_999_999); + const before = f.camera.pitch; + // Cesium limits slant range on an inertial pick, slightly below the altitude cap. + f.frame(39_997_258); + for (let i = 0; i < 40; i++) f.frame(); + assert.ok(f.camera.pitch < before); + assert.ok( + Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-10, + ); + f.dispose(); +}); + +test('a descending programmatic tilt cancels pending correction', () => { + const f = guardFixture(); + f.frame(39_000_000); + f.movement(WHEEL, -10); + f.frame(39_999_999); + const count = f.camera.views.length; + f.camera.pitch += 0.1; + f.frame(39_997_258); + for (let i = 0; i < 40; i++) f.frame(); + assert.equal(f.camera.views.length, count); + f.dispose(); +}); + +test('explicit navigation cancels outward inertia before a climbing UI tilt', () => { + const f = guardFixture(); + f.movement(WHEEL, -10); + f.frame(1_000_000); + f.movement(WHEEL, -10); + f.handoff(); + const writes = f.camera.views.length; + f.camera.pitch = Cesium.Math.toRadians(-25); + f.frame(1_100_000); + f.frame(1_200_000); + assert.equal(f.camera.views.length, writes); + assert.equal(deg(f.camera.pitch), -25); + // A subsequent user zoom starts a new correction normally. + f.movement(WHEEL, -10); + f.frame(1_300_000); + assert.ok(f.camera.views.length > writes); + f.dispose(); +});