From 99520b529c4d1a3a4adc7268ca935d66bcdba923 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:02:54 +0530 Subject: [PATCH 01/10] fix(camera): keep the globe in view from orbit From the whole-globe view the camera could end up facing empty space with only stars on screen. Cesium's free look (Shift+drag) turns the camera in place, and from 18,000 km the planet's edge is only about 15 degrees from nadir, so a few drags turned it out of frame. The rolled pose was then saved into the share hash, so a reload or shared link opened on stars too. - Allow free look only below 1,000 km. Orbit, tilt and zoom are unchanged. - Measure how much of the frame shows the planet's surface by casting a 32x18 grid of view rays at the WGS84 ellipsoid. While the scope mask is on (the default) only the rays inside its keyhole count, since the mask hides or dims the rest of the frame. - When a camera move ends above 1,000 km with no ray on the surface, turn straight down at the same position and heading. Limb glow and a sliver up to about one grid cell thick can count as out of view. The end of a move comes from Cesium's moveEnd and from a check of its own that samples the pose every 250 ms after moveStart and acts once it has held for half a second: at some of these poses the camera drifts by a rounding error every frame, so Cesium never sees it stop and never raises moveEnd. The zoom-out guard from #1522 keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking away, such as a saved or shared view that did, or a drag that Cesium turns into an in-place look below its 7,500 km trackball height (one that starts on the stars, or a tilt drag with the screen centre off the planet). Views with more surface, tracked or locked cameras and the 2D and Columbus modes are left alone. - Voice tilt and in-place turns of a free 3D globe camera above 1,000 km stop when about a tenth of the frame still shows the planet (or about as much as it showed when it started, two grid cells of sampling noise allowed), never below about 1% of the visible frame; a view that already shows less only keeps or gains what it has. A turn that cannot go half a degree further without crossing that limit is refused with a reason instead of reported as done, and a turn it accepts turns at least that half degree. A turn that keeps or gains surface is not refused, even after the window has been resized, except that near the limit, where the planet's edge crosses the sample grid, sampling wobble can occasionally refuse one (most often above about 15,000 km and on phone or ultrawide frames); nothing then turns away from the planet. A turn that starts where the last voice turn left the camera keeps that turn's limit, or a higher one if the planet now fills more, so repeating the request does not take any more; once anything else has moved or turned the camera, a turn is measured from what is on screen. A request made while the camera is tracked is judged by the pose voice's release of the follow camera will leave, before that release, so a refused turn keeps the tracking. The frame measure and the look-step limit live in src/globeInView.js, shared by the viewer and the voice camera verbs. --- docs/CURRENT-STATE.md | 40 +++ scripts/package-boundaries.json | 5 + src/app/application.test.mjs | 431 ++++++++++++++++++++++++++- src/app/scene.js | 11 + src/app/viewer.js | 172 +++++++++++ src/cameraGlobeRotation.test.mjs | 491 +++++++++++++++++++++++++++++++ src/cameraVerbs.js | 179 ++++++++++- src/globeInView.js | 167 +++++++++++ 8 files changed, 1491 insertions(+), 5 deletions(-) create mode 100644 src/globeInView.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 87b918a4e..3fde50e05 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -65,6 +65,46 @@ 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. +Cesium's free look (Shift+drag, which turns the camera in place) works only +below 1,000 km. Above that height a few drags could turn the whole planet out of +frame and leave only stars, so Shift+drag does nothing there. Orbit, tilt and +zoom are unchanged at every height. How much of the frame shows the planet's +surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while +the scope mask is on (the default) only the rays inside its keyhole count, since +the mask hides or dims the rest. Voice tilt and in-place turns of a free 3D +camera above 1,000 km stop when about a tenth of the frame still shows the +surface (or about as much as it showed when it started), never less than about +1% of the visible frame, and a view that already shows less only keeps or gains +what it has. A turn that cannot go half a degree further without crossing that +limit is refused with a reason instead of reported as done, and a turn it +accepts turns at least that half degree. A turn that keeps or gains surface is +not refused, even after the window has been resized, except that near the limit, +where the planet's edge crosses the sample grid, sampling wobble can +occasionally refuse one (most often above about 15,000 km and on phone or +ultrawide frames); nothing then turns away from the planet. A turn that starts +where the last voice turn left the camera keeps that turn's limit, or a higher +one if the planet now fills more, so repeating the request does not take any +more; once anything else has moved or turned the camera, a turn is measured from +what is on screen. When a camera move ends above 1,000 km with no ray on the +surface, the camera turns straight down at the same position and heading. The +end of a move is taken from Cesium's move-end event and from a check of its own +that samples the pose every 250 ms after a move starts and acts once it has held +for half a second, because at some of these poses the camera drifts by a +rounding error every frame and Cesium never sees it stop. That check keeps +watching until move-end arrives, recovering once after each move that comes to +rest, and gives up five minutes after the camera last moved. Limb glow, and a +surface sliver up to about one grid cell thick (some 50 px on a 900 px tall +frame), can count as out of view. The zoom-out guard above keeps wheel and +pinch zoom-outs in view; this catches the other ways a move can end looking +away, such as a saved or shared view that did, or a drag +that Cesium turns into an in-place look below its 7,500 km trackball height (one +that starts on the stars, or a tilt drag with the screen centre off the planet). +Views with more surface than that, such as the oblique orbital Director shots, +are left as they are, and so are tracked or locked cameras and the 2D and +Columbus modes. A voice turn requested while the camera is tracked is judged +by the pose voice's release of the follow camera will leave, before that +release, so a refused turn keeps the tracking. + 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/package-boundaries.json b/scripts/package-boundaries.json index 44c481152..b029af646 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -158,6 +158,7 @@ "modules": [ "src/app/viewer.js", "src/app/atmosphereCompat.js", + "src/globeInView.js", "src/sky/starrySky.js", "src/sky/stars.js", "src/data/bundledJson.js", @@ -2297,6 +2298,7 @@ "src/camera.js", "src/cameraGroundGuard.js", "src/cameraVerbs.js", + "src/globeInView.js", "src/cctvFocusPolicy.js", "src/cctvFocusRequest.js", "src/celestialRing.js", @@ -3604,6 +3606,9 @@ "src/annotations/worldAnnotationRenderer.js", "src/cameraGroundGuard.js", "src/cameraVerbs.js", + "src/globeInView.js", + "src/scopeMaskPolicy.js", + "src/scopeMask.js", "src/services/groundFloor.js", "src/data/routeSteps.js", "src/layers/directions/index.js", diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 3e9079b87..401eefa0b 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -2,7 +2,20 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createApplication } from './application.js'; -import { installTrackpadPinchZoom } from './viewer.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import * as Cesium from 'cesium'; +import { + installFreeLookHeightLimit, + installGlobeInViewRecovery, + installTrackpadPinchZoom, +} from './viewer.js'; +import { + FREE_LOOK_MAX_HEIGHT_METERS, + globeFrameShare, + globeOutOfFrame, + lookAngleWithinFrame, + lookStepLosesGlobe, +} from '../globeInView.js'; const phases = ['Scene', 'Controls', 'Data', 'Tools']; function fixture(overrides = {}) { @@ -343,3 +356,419 @@ test('trackpad pinch falls back to the original event when relay construction fa assert.equal(event.propagationStopped, false); assert.equal(f.relayed.length, 0); }); + +function freeLookFixture({ enableLook = true, height = 0 } = {}) { + const listeners = new Set(); + const controller = { enableLook }; + const camera = { positionCartographic: { height } }; + const viewer = { + camera, + scene: { + screenSpaceCameraController: controller, + preUpdate: { + addEventListener(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }, + }; + const frame = (nextHeight) => { + camera.positionCartographic = { height: nextHeight }; + for (const listener of listeners) listener(); + }; + return { viewer, controller, listeners, frame }; +} + +test('free look follows camera height and is restored on dispose', () => { + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + assert.equal(f.controller.enableLook, true); + + f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1); + assert.equal(f.controller.enableLook, false); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS); + assert.equal(f.controller.enableLook, true); + f.frame(Number.NaN); + assert.equal(f.controller.enableLook, true); + + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + dispose(); + dispose(); + assert.equal(f.controller.enableLook, true); + assert.equal(f.listeners.size, 0); +}); + +test('free look stays off when the viewer had it disabled', () => { + const f = freeLookFixture({ enableLook: false, height: 500 }); + installFreeLookHeightLimit(f.viewer); + f.frame(10); + assert.equal(f.controller.enableLook, false); +}); + +// A real Cesium camera on the smallest scene its constructor reads, so the +// frustum test runs on Cesium's own view math rather than a stub. +function realCamera({ width = 1600, height = 900, mode } = {}) { + const scene = { + canvas: { clientWidth: width, clientHeight: height }, + drawingBufferWidth: width, + drawingBufferHeight: height, + mapProjection: new Cesium.GeographicProjection(), + mode: mode ?? Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + ellipsoid: Cesium.Ellipsoid.WGS84, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + renders: 0, + requestRender() { + this.renders += 1; + }, + }; + const camera = new Cesium.Camera(scene); + camera.frustum.aspectRatio = width / height; + return { camera, scene, viewer: { camera, scene } }; +} + +function aim( + camera, + { lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 }, +) { + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt), + orientation: { + heading: Cesium.Math.toRadians(heading), + pitch: Cesium.Math.toRadians(pitch), + roll: Cesium.Math.toRadians(roll), + }, + }); +} + +const reportedStuckView = { + alt: 17_996_811, + heading: 180, + pitch: -42, + roll: 180, +}; +// Oblique poses that still show part of the planet: Director recipe shots +// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit. +const partialGlobeViews = [ + { lon: -30, lat: 20, alt: 19_000_000, pitch: -65 }, + { lon: 2, lat: 46, alt: 8_000_000, pitch: -52 }, + { lon: 110, lat: 20, alt: 9_500_000, pitch: -66 }, + { lon: 0, lat: 0, alt: 2_000_000, pitch: -30 }, +]; + +// Stars and a few pixels of limb, no surface: where a frame limit measured +// against a sphere of the planet's largest radius used to stop a voice tilt. +const limbOnlyView = { alt: 18_000_000, pitch: -56.75 }; + +/** + * An oracle independent of the code under test: the share of an 80×45 grid of + * Cesium pick rays across the canvas that hit the ellipsoid surface. + */ +function surfacePixelShare(camera, width, height, { keyhole = false } = {}) { + const radius = (KEYHOLE_OUTER_RADIUS * height) / 2; + let hits = 0; + let counted = 0; + for (let row = 0; row < 45; row += 1) { + for (let column = 0; column < 80; column += 1) { + const x = ((column + 0.5) * width) / 80; + const y = ((row + 0.5) * height) / 45; + if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius) + continue; + counted += 1; + const ray = camera.getPickRay(new Cesium.Cartesian2(x, y)); + if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84)) + hits += 1; + } + } + return hits / counted; +} + +test('the globe is out of frame only when no surface is on screen', () => { + const cases = [ + [reportedStuckView, 1225, 923, true], + [limbOnlyView, 1600, 900, true], + [{ alt: 17_996_811, pitch: -90 }, 1225, 923, false], + ...partialGlobeViews.map((view) => [view, 1600, 900, false]), + ]; + for (const [view, width, height, out] of cases) { + const { camera } = realCamera({ width, height }); + aim(camera, view); + const label = JSON.stringify(view); + assert.equal(globeOutOfFrame(camera), out, label); + assert.equal(surfacePixelShare(camera, width, height) === 0, out, label); + assert.ok( + Math.abs( + globeFrameShare(camera) - surfacePixelShare(camera, width, height), + ) < 0.03, + `${label}: the frame share matches the pick-ray oracle`, + ); + } +}); + +test('a move that ends with the globe out of frame turns back to nadir', () => { + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const dispose = installGlobeInViewRecovery(viewer); + aim(camera, reportedStuckView); + const position = Cesium.Cartesian3.clone(camera.positionWC); + const heading = camera.heading; + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6)); + assert.ok( + Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3), + ); + assert.equal(globeOutOfFrame(camera), false); + assert.equal(scene.renders, 1); + + // Looking up near the ground, then zoomed out past the limit. + aim(camera, { alt: 3_834_000, pitch: 10 }); + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + + // Only the limb on a 16:9 screen still counts as lost. + const wide = realCamera({ width: 1600, height: 900 }); + const disposeWide = installGlobeInViewRecovery(wide.viewer); + aim(wide.camera, limbOnlyView); + wide.camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6)); + disposeWide(); + + dispose(); + dispose(); + aim(camera, reportedStuckView); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert'); +}); + +// True when a moveEnd leaves the camera's view direction as it was. +function moveEndKeepsView(camera) { + const direction = Cesium.Cartesian3.clone(camera.directionWC); + camera.moveEnd.raiseEvent(); + return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9); +} + +test('globe recovery leaves partial, low, locked and 2D views alone', () => { + for (const view of partialGlobeViews) { + const { camera, viewer } = realCamera(); + installGlobeInViewRecovery(viewer); + aim(camera, view); + assert.ok(moveEndKeepsView(camera), JSON.stringify(view)); + } + + const low = realCamera(); + installGlobeInViewRecovery(low.viewer); + aim(low.camera, { alt: 900_000, pitch: 85 }); + assert.equal(globeOutOfFrame(low.camera), true); + assert.ok(moveEndKeepsView(low.camera), 'below the limit'); + + // Locked on a point in space, between it and the planet, facing away. + const locked = realCamera(); + installGlobeInViewRecovery(locked.viewer); + locked.camera.lookAtTransform( + Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)), + new Cesium.Cartesian3(-1_000_000, 0, 0), + ); + assert.equal(globeOutOfFrame(locked.camera), true); + assert.ok(moveEndKeepsView(locked.camera), 'locked camera'); + + // Tracking an entity that has no position yet keeps an identity transform. + const tracked = realCamera(); + tracked.viewer.trackedEntity = { id: 'pending-fix' }; + installGlobeInViewRecovery(tracked.viewer); + aim(tracked.camera, reportedStuckView); + assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera'); + + const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW }); + installGlobeInViewRecovery(flat.viewer); + flat.scene.mode = Cesium.SceneMode.SCENE3D; + aim(flat.camera, reportedStuckView); + flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW; + assert.ok(moveEndKeepsView(flat.camera), 'Columbus view'); +}); + +test('the look limit from orbit tolerates grid noise and never reaches zero', () => { + const radians = Cesium.Math.toRadians; + // About 2% of a 16:9 frame on the planet, below the 10% limit. + const sliver = { alt: 18_000_000, pitch: -60 }; + const { camera } = realCamera(); + aim(camera, sliver); + const start = surfacePixelShare(camera, 1600, 900); + assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`); + + // Sideways across the sliver: the share wobbles by a cell, the planet stays. + assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false); + const startShare = globeFrameShare(camera); + for (let i = 0; i < 20; i += 1) { + const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), { + startShare, + }); + assert.equal(step, radians(0.25), `sideways step ${i + 1}`); + camera.lookLeft(step); + } + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + + // Toward the planet is always allowed. + aim(camera, sliver); + assert.equal( + lookAngleWithinFrame(camera, 'lookDown', radians(10)), + radians(10), + ); + + // Away from it stops short of losing it. + aim(camera, sliver); + const away = lookAngleWithinFrame(camera, 'lookUp', radians(30)); + assert.ok(away < radians(30)); + camera.lookUp(away); + assert.ok(globeFrameShare(camera) > 0); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('the look limit leaves a view with no surface to the move-end recovery', () => { + const { camera } = realCamera({ width: 1225, height: 923 }); + aim(camera, reportedStuckView); + assert.equal( + lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)), + Cesium.Math.toRadians(5), + ); +}); + +// Surface only in the corners of a 16:9 frame, all of it outside the scope's +// keyhole: the full frame shows some planet, the scope shows none. +const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 }; + +test('with a scope keyhole, surface hidden in the masked corners counts as none', () => { + const { camera } = realCamera(); + aim(camera, cornerOnlyView); + const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS }; + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0); + assert.equal(globeOutOfFrame(camera), false); + assert.equal(globeOutOfFrame(camera, keyhole), true); + aim(camera, { alt: 18_000_000, pitch: -90 }); + assert.ok( + Math.abs( + globeFrameShare(camera, keyhole) - + surfacePixelShare(camera, 1600, 900, { keyhole: true }), + ) < 0.03, + 'the keyhole share matches the pick-ray oracle', + ); +}); + +test('the recovery follows the scope: corner-only views are turned back only behind it', () => { + for (const [scopeOn, expectRecovery] of [ + [true, true], + [false, false], + ]) { + const { camera, viewer } = realCamera(); + installGlobeInViewRecovery(viewer, { + keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0), + }); + aim(camera, cornerOnlyView); + camera.moveEnd.raiseEvent(); + assert.equal( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + expectRecovery, + `scope ${scopeOn ? 'on' : 'off'}`, + ); + } +}); + +function fakeTimers() { + let next = 1; + const pending = new Map(); + return { + setTimeout: (callback) => { + const id = next++; + pending.set(id, callback); + return id; + }, + clearTimeout: (id) => pending.delete(id), + pending: () => pending.size, + /** Run the earliest pending callback, as 250 ms passing would. */ + tick() { + const [id, callback] = pending.entries().next().value ?? []; + if (id === undefined) return false; + pending.delete(id); + callback(); + return true; + }, + }; +} + +test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => { + // At this pose the camera can drift by a rounding error every frame, so + // Cesium never sees it stop and never raises moveEnd for it. + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough'); + // A rounding-error drift between samples still counts as at rest. + camera.lookUp(1e-15); + timers.tick(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + // Under that drift Cesium raises no new moveStart, so a later move that + // ends looking away is caught by the same check. + aim(camera, reportedStuckView); + timers.tick(); + timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'not yet at rest'); + timers.tick(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + camera.moveEnd.raiseEvent(); + assert.equal(timers.pending(), 0, 'moveEnd ends the check'); +}); + +test('the settle check follows a long move and gives up five minutes after the last one', () => { + // A drag of half a minute that stops at a stuck pose is still recovered. + const long = fakeTimers(); + const dragged = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(dragged.viewer, long); + aim(dragged.camera, reportedStuckView); + dragged.camera.moveStart.raiseEvent(); + for (let i = 0; i < 120; i += 1) { + dragged.camera.moveLeft(0.01); + long.tick(); + } + assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving'); + while (long.tick()); + assert.ok( + Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once it stopped', + ); + + // A camera that keeps moving is followed, and left alone, for as long as it + // moves; once it rests the check gives up five minutes later. + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 2000; i += 1) { + camera.moveLeft(0.01); + timers.tick(); + } + assert.equal(timers.pending(), 1, 'still following after eight minutes'); + assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone'); + let idleSamples = 0; + while (timers.tick()) idleSamples += 1; + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once it stopped', + ); + assert.ok( + idleSamples >= 1200 && idleSamples <= 1205, + `gave up ${idleSamples} samples after it stopped`, + ); + + const disposed = fakeTimers(); + const other = realCamera({ width: 1225, height: 923 }); + const dispose = installGlobeInViewRecovery(other.viewer, disposed); + aim(other.camera, reportedStuckView); + other.camera.moveStart.raiseEvent(); + dispose(); + assert.equal(disposed.pending(), 0, 'dispose cancels the pending check'); +}); diff --git a/src/app/scene.js b/src/app/scene.js index 1455b8268..9c115638d 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -2,9 +2,13 @@ import { createApplicationOperations } from './operations.js'; import * as Cesium from 'cesium'; import { createApplicationViewer, + installFreeLookHeightLimit, + installGlobeInViewRecovery, installTrackpadPinchZoom, } from '../app/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import { isScopeMaskEnabled } from '../scopeMask.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; import { loadPhotorealisticTileset } from '../mapStartup.js'; @@ -58,6 +62,13 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); + defer(installFreeLookHeightLimit(viewer)); + defer( + installGlobeInViewRecovery(viewer, { + // The scope mask hides everything outside its keyhole. + keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), + }), + ); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/app/viewer.js b/src/app/viewer.js index c8b841352..ce182a0ab 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -1,6 +1,10 @@ import * as Cesium from 'cesium'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { StarrySky } from '../sky/starrySky.js'; +import { + FREE_LOOK_MAX_HEIGHT_METERS, + globeOutOfFrame, +} from '../globeInView.js'; const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -103,6 +107,174 @@ export function installTrackpadPinchZoom( }; } +/** + * Allow Cesium's free look only below `maxHeight` and return its disposer. + * Orbit, tilt and zoom stay available at every height. `enableLook` gates the + * Shift+drag binding only; a drag that starts on the stars below Cesium's + * trackball height still turns the camera in place, which + * `installGlobeInViewRecovery` catches. + */ +export function installFreeLookHeightLimit( + viewer, + maxHeight = FREE_LOOK_MAX_HEIGHT_METERS, +) { + const scene = viewer?.scene; + const controller = scene?.screenSpaceCameraController; + const camera = viewer?.camera; + if (!controller || !camera || !scene?.preUpdate) + throw new TypeError('A complete Cesium viewer is required'); + + const originalEnableLook = controller.enableLook; + const sync = () => { + const height = camera.positionCartographic?.height; + controller.enableLook = + originalEnableLook && !(Number.isFinite(height) && height > maxHeight); + }; + sync(); + const removeListener = scene.preUpdate.addEventListener(sync); + + let active = true; + return () => { + if (!active) return; + active = false; + removeListener(); + controller.enableLook = originalEnableLook; + }; +} + +// How often a moving camera is sampled to see whether it has come to rest, how +// far a pose may drift between samples and still count as at rest, and how +// long after the camera last moved the check gives up if Cesium never raises +// moveEnd. +const SETTLE_SAMPLE_MS = 250; +const SETTLE_POSITION_TOLERANCE_M = 1e-3; +const SETTLE_DIRECTION_TOLERANCE = 1e-9; +const SETTLE_GIVE_UP_MS = 5 * 60_000; + +/** + * Turn the camera back to the planet when a move ends above `minHeight` with + * no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about + * one sample cell thick can count as none), and return the disposer. Views with + * more surface, such as an oblique orbital shot, are left as they are. This + * catches a saved or shared view that looked away, and a drag that Cesium turns + * into an in-place look below its trackball height whatever `enableLook` says. + * Heading and position are kept. Tracked cameras, locked ones (non-identity + * transform) and the 2D and Columbus modes are left alone. `keyholeRadius` + * returns the radius of the visible circle when a scope mask hides the rest of + * the frame (see `globeFrameShare`), or 0; it is read at every check. + * + * Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the + * last frame's to the last rounding error, and at some of these poses the + * camera drifts by a rounding error every frame, so Cesium never sees it stop. + * So each `moveStart` also starts a check of its own that samples the pose + * every 250 ms, with a tolerance for that drift, and once the pose has held + * for half a second after moving runs the same recovery. It keeps watching + * until Cesium raises `moveEnd`, since under that drift Cesium raises no new + * `moveStart` for a later move, and gives up five minutes after the camera + * last moved. + * `setTimeout` and `clearTimeout` can be injected for tests. + */ +export function installGlobeInViewRecovery( + viewer, + { + minHeight = FREE_LOOK_MAX_HEIGHT_METERS, + keyholeRadius = () => 0, + setTimeout: schedule = globalThis.setTimeout, + clearTimeout: unschedule = globalThis.clearTimeout, + } = {}, +) { + const scene = viewer?.scene; + const camera = viewer?.camera; + if (!scene || !camera?.moveEnd || !camera?.moveStart) + throw new TypeError('A complete Cesium viewer is required'); + + const recover = () => { + if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return; + if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)) + return; + const height = camera.positionCartographic?.height; + if ( + !(height > minHeight) || + !globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() }) + ) + return; + camera.setView({ + orientation: { + heading: camera.heading, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + }); + scene.requestRender?.(); + }; + + const lastPosition = new Cesium.Cartesian3(); + const lastDirection = new Cesium.Cartesian3(); + let settleTimer = null; + let settleElapsed = 0; + let restingSamples = 0; + let movedSinceCheck = false; + const stopSettling = () => { + if (settleTimer !== null) unschedule(settleTimer); + settleTimer = null; + }; + const sample = () => { + settleTimer = null; + settleElapsed += SETTLE_SAMPLE_MS; + const atRest = + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + lastPosition, + 0, + SETTLE_POSITION_TOLERANCE_M, + ) && + Cesium.Cartesian3.equalsEpsilon( + camera.directionWC, + lastDirection, + 0, + SETTLE_DIRECTION_TOLERANCE, + ); + restingSamples = atRest ? restingSamples + 1 : 0; + if (!atRest) { + movedSinceCheck = true; + settleElapsed = 0; + } + // Two quiet samples in a row after a move: at rest for half a second, as + // for moveEnd. + if (restingSamples >= 2 && movedSinceCheck) { + movedSinceCheck = false; + recover(); + } + if (settleElapsed >= SETTLE_GIVE_UP_MS) return; + Cesium.Cartesian3.clone(camera.positionWC, lastPosition); + Cesium.Cartesian3.clone(camera.directionWC, lastDirection); + settleTimer = schedule(sample, SETTLE_SAMPLE_MS); + }; + const startSettling = () => { + stopSettling(); + settleElapsed = 0; + restingSamples = 0; + movedSinceCheck = true; + Cesium.Cartesian3.clone(camera.positionWC, lastPosition); + Cesium.Cartesian3.clone(camera.directionWC, lastDirection); + settleTimer = schedule(sample, SETTLE_SAMPLE_MS); + }; + const removeMoveEnd = camera.moveEnd.addEventListener(() => { + stopSettling(); + recover(); + }); + const removeMoveStart = camera.moveStart.addEventListener(startSettling); + + let active = true; + return () => { + if (!active) return; + active = false; + stopSettling(); + removeMoveEnd(); + removeMoveStart(); + }; +} + /** Cesium.Viewer options of the standard globe viewer. */ export function applicationViewerOptions(creditContainer) { return { diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 814e4de1e..f15ae4978 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -13,6 +13,9 @@ import { moveCamera, } from './cameraVerbs.js'; import { getRenderGovernorDiagnostics } from './renderGovernor.js'; +import { MIN_GLOBE_FRAME_SHARE, globeFrameShare } from './globeInView.js'; +import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; +import { setScopeMaskEnabled } from './scopeMask.js'; const radians = Cesium.Math.toRadians; const clone = (value) => Cesium.Cartesian3.clone(value); @@ -1347,3 +1350,491 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer samePose(h.camera, before); assert.equal(hasRenderHold(), false); }); + +/** + * An oracle independent of the code under test: the share of an 80×45 grid of + * Cesium pick rays across the canvas that hit the ellipsoid surface. With + * `keyhole`, only the pixels inside the scope's visible circle count. + */ +function surfacePixelShare(camera, width, height, { keyhole = false } = {}) { + const radius = (KEYHOLE_OUTER_RADIUS * height) / 2; + let hits = 0; + let counted = 0; + for (let row = 0; row < 45; row += 1) { + for (let column = 0; column < 80; column += 1) { + const x = ((column + 0.5) * width) / 80; + const y = ((row + 0.5) * height) / 45; + if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius) + continue; + counted += 1; + const ray = camera.getPickRay(new Cesium.Cartesian2(x, y)); + if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84)) + hits += 1; + } + } + return hits / counted; +} + +/** Run a test with the scope mask on or off, restoring the default after. */ +function withScope(t, enabled) { + setScopeMaskEnabled(enabled); + t.after(() => setScopeMaskEnabled(true)); +} + +// The frame limit is measured on a coarser grid than the oracle. +const SHARE_SLACK = 0.02; +// Far-out poses, height in metres and pitch in degrees, where a few tilts up +// are accepted before the limit refuses the next one. +const FAR_OUT_POSES = [ + [35_884_000, -86.26], + [35_884_000, -89], + [30_000_000, -86], + [25_000_000, -80], + [40_000_000, -88], +]; + +test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => { + withScope(t, false); // the whole frame is visible + // Slightly off nadir: from straight down a tilt is clamped before it moves. + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + const results = []; + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + results.push(result); + if (result.ok) finishOnce(); + assert.ok( + surfacePixelShare(camera, 1600, 900) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `after tilt ${i + 1}`, + ); + } + // From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down + // to about -67°: the first tilt stops there, and later ones are refused. + assert.deepEqual( + results.map((result) => result.ok), + [true, false, false, false], + ); + assert.match(results[1].error, /globe out of view/); + assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66)); +}); + +test('voice turn in place from orbit stops with the planet still on screen', (t) => { + withScope(t, false); // the whole frame is visible + const { camera, tick } = harness(t, { pitch: -75 }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok, + true, + ); + const heading = camera.heading; + for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) { + tick(); + assert.ok( + surfacePixelShare(camera, 1280, 800) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `tick ${i + 1}`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.notEqual(camera.heading, heading, 'the turn moved before stopping'); +}); + +test('a long voice turn below the limit keeps the share it started with', (t) => { + withScope(t, false); // the whole frame is visible + // About 2% of a 16:9 frame on the planet: the limit is that share, less two + // grid cells of sampling noise, for the whole motion rather than per step. + const { camera, tick } = harness(t, { + pitch: -60, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok, + true, + ); + for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) { + tick(); + assert.ok( + globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9, + `tick ${i + 1}`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('repeated voice turns from one spot cannot each take a little more', (t) => { + withScope(t, false); // the whole frame is visible + // About 2% of a 16:9 frame on the planet. Every turn from this position is + // measured against the same baseline, so asking again stops being allowed. + const { camera, finishOnce, tick } = harness(t, { + pitch: -60, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + const floor = start - 2 / (32 * 18) - 1e-9; + const results = []; + for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) { + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ motion: 'rotate', direction: 'left', mode }); + results.push(result.ok); + if (!result.ok) continue; + if (mode === 'once') finishOnce(); + else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick(); + assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`); + } + } + assert.ok(results.includes(false), 'a turn at the limit is refused'); + assert.equal(results.at(-1), false); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('a voice turn after something else turned the camera starts over', (t) => { + withScope(t, false); // the whole frame is visible + // The same spot, first framed straight down, then turned to about 2% of the + // frame by something other than voice: a turn left is measured from there. + const { camera, finishOnce } = harness(t, { + pitch: -90, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok, + true, + ); + finishOnce(); + camera.setView({ + orientation: { heading: 0, pitch: radians(-60), roll: 0 }, + }); + const start = globeFrameShare(camera); + assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9); +}); + +test('nudging the camera between voice turns cannot slide the planet away', (t) => { + withScope(t, false); // the whole frame is visible + // A wheel notch or an orbit drag between requests starts each turn afresh; + // the 1% floor still holds, and below it a turn may only keep what it has. + for (const [nudge, pitch] of [ + [(camera) => camera.moveBackward(2), -60], + [(camera) => camera.rotateRight(radians(0.2)), -60], + [(camera) => camera.moveBackward(2), -58], + ]) { + const { camera, tick } = harness(t, { + pitch, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + const floor = Math.min(0.01, start) - 1e-9; + for (let i = 0; i < 12; i += 1) { + nudge(camera); + if ( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }) + .ok + ) + for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick(); + assert.ok( + globeFrameShare(camera) >= floor, + `start ${start}, turn ${i + 1}`, + ); + } + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + } +}); + +test('after the frame changes size, a voice turn toward the planet is allowed', (t) => { + withScope(t, false); // the whole frame is visible + // A tilt stops at its limit; then the window widens at the same pose, so the + // frame shows less than the carried limit. Toward the planet must still go. + const { camera, canvas, viewer, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok, + true, + ); + finishOnce(); + canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200; + camera.frustum.aspectRatio = 3200 / 900; + const narrowed = globeFrameShare(camera); + assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`); + + const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }); + assert.equal(down.ok, true, down.error); + finishOnce(); + assert.ok(globeFrameShare(camera) > narrowed); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok, + true, + ); + finishOnce(); + const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + if (up.ok) finishOnce(); + assert.ok(surfacePixelShare(camera, 3200, 900) > 0); +}); + +test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => { + withScope(t, true); + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + const results = []; + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + results.push(result.ok); + if (result.ok) finishOnce(); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: true }) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `after tilt ${i + 1}`, + ); + } + assert.equal(results[0], true); + assert.equal(results.at(-1), false, 'a tilt past the limit is refused'); +}); + +test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => { + // An oblique orbit view: turning in place swings the planet toward a corner, + // where the full frame still shows it but the scope mask hides it. + for (const [height, pitch] of [ + [5_000_000, -70], + [3_000_000, -75], + [18_000_000, -80], + ]) { + withScope(t, true); + const { camera, tick } = harness(t, { + height, + pitch, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }) + .ok, + true, + ); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: true }) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `${height / 1000} km at ${pitch}°`, + ); + } +}); + +test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => { + // A small planet disc: its sampled share wobbles as it crosses the grid. + // Whatever the limit decides, "done" must mean the camera turned, and some + // of these tilts must be accepted for that to be checked at all. + let accepted = 0; + for (const [height, pitch] of FAR_OUT_POSES) { + withScope(t, true); + const { camera, tick } = harness(t, { + latitude: 45.37, + longitudeDeg: -33.33, + height, + heading: 89.9, + pitch, + viewportWidth: 1280, + viewportHeight: 1024, + }); + for (let i = 0; i < 4; i += 1) { + const before = camera.pitch; + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + if (!result.ok) { + assert.match(result.error, /zoom in first/); + continue; + } + accepted += 1; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + assert.ok( + camera.pitch - before > radians(0.5) - 1e-9, + `${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`, + ); + } + assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0); + } + assert.ok(accepted > 0, 'no far-out tilt was accepted'); +}); + +test('a voice turn reported as done turns at least the half degree its check proved', (t) => { + // A grazing sliver: the first frame's small step lands in a dip of the + // sampled share, which used to stop the turn before it moved at all. + for (const scope of [true, false]) { + withScope(t, scope); + const { camera, tick } = harness(t, { + latitude: 20, + longitudeDeg: 10, + height: 18_000_000, + pitch: -58, + heading: 37, + viewportWidth: 1600, + viewportHeight: 900, + }); + const before = Cesium.Cartesian3.clone(camera.directionWC); + const result = moveCamera({ + motion: 'rotate', + direction: 'left', + mode: 'once', + }); + if (!result.ok) { + assert.match(result.error, /globe out of view/); + continue; + } + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60); + const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC); + assert.ok( + turned >= radians(0.5) - 1e-9, + `scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`, + ); + assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0); + } +}); + +/** Track an entity below `camera`, locking it into that entity's frame. */ +function trackFrom(viewer, camera) { + viewer.trackedEntity = { id: 'high-satellite' }; + const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000); + camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below)); +} + +test('a voice turn that releases a tracked camera is checked as released', (t) => { + // Voice releases the follow camera inside the navigation that runs the + // motion, so the request is judged by the pose the release will leave. + let accepted = 0; + for (const [scope, direction, motion] of [ + [true, 'up', 'tilt'], + [false, 'up', 'tilt'], + [false, 'left', 'rotate'], + ]) { + withScope(t, scope); + const { camera, viewer, tick } = harness(t, { + latitude: 20, + longitudeDeg: 10, + height: 18_000_000, + pitch: -58, + heading: 37, + viewportWidth: 1600, + viewportHeight: 900, + }); + trackFrom(viewer, camera); + const releaseThenRun = (start) => { + viewer.trackedEntity = undefined; + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + return start(); + }; + const before = Cesium.Cartesian3.clone(camera.directionWC); + const result = moveCamera( + { motion, direction, mode: 'once' }, + releaseThenRun, + ); + const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`; + if (!result.ok) { + assert.match(result.error, /globe out of view/, label); + continue; + } + accepted += 1; + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60); + const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC); + assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0, + `${label}: the planet stays on screen`, + ); + } + assert.ok(accepted > 0, 'no released turn was accepted'); +}); + +test('a refused voice turn leaves a tracked camera tracking', (t) => { + for (const scope of [true, false]) { + withScope(t, scope); + const { camera, viewer, tick } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + // Tilt up to the limit untracked, then track from there. + for (let i = 0; i < 40; i += 1) { + if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok) + break; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + } + trackFrom(viewer, camera); + const tracked = viewer.trackedEntity; + const transform = Cesium.Matrix4.clone(camera.transform); + let released = false; + const result = moveCamera( + { motion: 'tilt', direction: 'up', mode: 'once' }, + (start) => { + released = true; + viewer.trackedEntity = undefined; + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + return start(); + }, + ); + const label = `scope ${scope ? 'on' : 'off'}`; + assert.equal(result.ok, false, label); + assert.match(result.error, /globe out of view/, label); + assert.equal(released, false, label); + assert.equal(viewer.trackedEntity, tracked, label); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label); + } +}); + +test('the voice frame limit leaves Columbus view alone', (t) => { + // At its limit in 3D a further tilt up is refused; the same tilt in Columbus + // view is not limited by the frame. + withScope(t, false); + const { viewer, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok, + true, + ); + finishOnce(); + const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.match(refused.error || '', /globe out of view/); + viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW; + const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.doesNotMatch(result.error || '', /globe out of view/); +}); + +test('voice tilt below the free-look height is not limited by the frame', (t) => { + const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 }); + const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.equal(result.ok, true); + finishOnce(); + near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt'); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index e037472cb..964b5c1a7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -20,11 +20,18 @@ import * as Cesium from 'cesium'; import { GLOBE_VIEW } from './locations.js'; +import { + globeFrameShare, + lookAngleWithinFrame, + lookStepLosesGlobe, +} from './globeInView.js'; import { projectEarthDiscToViewport, earthDiscScreenRadius, getKeyholeGeometry, + KEYHOLE_OUTER_RADIUS, } from './celestialRing.js'; +import { isScopeMaskEnabled } from './scopeMask.js'; import { holdContinuousRender, releaseContinuousRender, @@ -1008,6 +1015,137 @@ export function interruptCameraMotionIfActive( return interruptCameraMotion(reason); } +/** + * A free globe camera: 3D, not tracking, not locked to a frame. The frame + * limit for in-place turns applies to it alone, as the move-end recovery does. + */ +function isFreeGlobeCamera(cam) { + return ( + _viewer.scene.mode === Cesium.SceneMode.SCENE3D && + !_viewer.trackedEntity && + Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) + ); +} + +// The frame share that in-place voice turns measure their limit against, with +// the pose the last voice turn left the camera in. A turn that starts where +// the last one left off keeps that baseline (raised if the planet now fills +// more of the frame), so repeating a request cannot take a little more each +// time. Once anything else has moved or turned the camera, a turn starts over +// from what is on screen. +let _lookBaseline = null; + +// The pre-check's proof that a half-degree turn from this pose keeps the planet +// in view: which way, from which direction. A motion that starts from that +// pose turns at least that far, so a request reported as done really moves +// even when a smaller first step lands in a dip of the sampled share. +const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5); +let _provenLook = null; + +/** The part of the frame a viewer can see: the scope keyhole when it is on. */ +function visibleFrame() { + return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 }; +} + +/** The frame share an in-place turn from `cam`'s pose is limited against. */ +function lookBaselineShare(cam) { + const share = globeFrameShare(cam, visibleFrame()); + const continues = + _lookBaseline && + Cesium.Cartesian3.equalsEpsilon( + _lookBaseline.position, + cam.positionWC, + 0, + 1, + ) && + Cesium.Cartesian3.equalsEpsilon( + _lookBaseline.direction, + cam.directionWC, + 0, + 1e-9, + ); + _lookBaseline = { + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + share: continues ? Math.max(_lookBaseline.share, share) : share, + }; + return _lookBaseline.share; +} + +/** + * One in-place look step of motion `m`. From orbit a free globe camera turns + * only as far as keeps the planet in frame, measured against the baseline the + * turn started from, then the motion ends there. + */ +function lookWithinFrame(cam, m, look, angle) { + let allowed = angle; + if (isFreeGlobeCamera(cam)) { + if (m.globeShareAtStart === undefined) { + m.globeShareAtStart = lookBaselineShare(cam); + m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0; + m.turnedRad = 0; + } + _provenLook = null; + allowed = lookAngleWithinFrame(cam, look, angle, { + ...visibleFrame(), + startShare: m.globeShareAtStart, + }); + const owed = Math.min(angle, m.provenTurnRad - m.turnedRad); + if (allowed < owed) allowed = owed; + m.turnedRad += allowed; + } + if (allowed > 0) cam[look](allowed); + // The pose this turn leaves, for a turn that continues from it. + if (_lookBaseline) + Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction); + if (allowed < angle) interruptCameraMotion('globe-out-of-frame'); +} + +/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */ +function lookProven(cam, look) { + return ( + _provenLook?.look === look && + Cesium.Cartesian3.equalsEpsilon( + _provenLook.direction, + cam.directionWC, + 0, + 1e-9, + ) + ); +} + +/** + * The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft' + * or 'lookRight') must pass from orbit: refused when even half a degree would + * cross the frame limit, otherwise proven for that half degree from this pose. + * With `released`, the turn runs through a navigation that first releases a + * tracked or locked camera and keeps its world pose. The frame measure and the + * turn are the same in any reference frame, so such a camera is judged as it + * is, before the release, and a refusal leaves tracking as it was. Otherwise a + * camera that is not a free globe camera is not checked. Returns the refusal, + * or null. + */ +function checkLookFromOrbit(look, { released = false } = {}) { + _provenLook = null; + if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null; + const cam = _viewer.camera; + if (!released && !isFreeGlobeCamera(cam)) return null; + if ( + lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, { + ...visibleFrame(), + startShare: lookBaselineShare(cam), + }) + ) + return { + ok: false, + action: 'move_camera', + error: + 'From this height that would turn the globe out of view — zoom in first, or turn the other way.', + }; + _provenLook = { look, direction: Cesium.Cartesian3.clone(cam.directionWC) }; + return null; +} + function clearLookAt() { try { _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); @@ -1226,10 +1364,15 @@ function onTick() { interruptCameraMotion('tilt-clamp'); return; } - if (up) cam.lookUp(stepRad); - else cam.lookDown(stepRad); - } else if (m.direction === 'left') cam.lookLeft(stepRad); - else cam.lookRight(stepRad); + lookWithinFrame(cam, m, up ? 'lookUp' : 'lookDown', stepRad); + } else { + lookWithinFrame( + cam, + m, + m.direction === 'left' ? 'lookLeft' : 'lookRight', + stepRad, + ); + } } else if (m.kind === 'route') { // The dolly's shaping lives in advanceRouteFlight(); the tick only // applies the pose and honours the same completion path as before. @@ -1413,6 +1556,7 @@ export function initCameraVerbs( _isCockpitActive = typeof isCockpitActive === 'function' ? isCockpitActive : null; _viewer = viewer; + _lookBaseline = null; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); if (_trackingRemover) _trackingRemover(); @@ -1524,6 +1668,27 @@ export function moveCamera(args = {}, runNavigation = null) { }; } } + const look = + motion === 'tilt' || motion === 'rotate' + ? { + up: 'lookUp', + down: 'lookDown', + left: 'lookLeft', + right: 'lookRight', + }[direction] + : null; + // Honest limits from orbit too: a stopped turn rests within 0.05° of its + // limit, so one that cannot go half a degree further is refused rather than + // run and reported as done, and one that can is then turned at least that + // far. Near the limit the sampled share wobbles as the planet's edge crosses + // the grid, so a turn that would keep the planet can occasionally be refused + // too. + // A camera that is tracked or locked now is judged as the navigation that + // runs the turn will release it, so a refusal keeps the tracking. + const lookRefusal = + look && + checkLookFromOrbit(look, { released: typeof runNavigation === 'function' }); + if (lookRefusal) return lookRefusal; if ( (motion === 'rotate_globe' || (motion === 'orbit' && typeof runNavigation !== 'function')) && @@ -1609,6 +1774,12 @@ export function moveCamera(args = {}, runNavigation = null) { } const start = () => { interruptCameraMotion('replaced'); + // Checked again only if the release left the camera somewhere other than + // where the check above expected it. + if (look && !lookProven(_viewer.camera, look)) { + const refusal = checkLookFromOrbit(look); + if (refusal) return refusal; + } const state = { kind: motion, direction: direction || 'right', diff --git a/src/globeInView.js b/src/globeInView.js new file mode 100644 index 000000000..1ccbcda23 --- /dev/null +++ b/src/globeInView.js @@ -0,0 +1,167 @@ +import * as Cesium from 'cesium'; + +// Cesium's free look (Shift+drag) turns the camera in place. Near the ground +// that surveys a city; from orbit a few drags turn the whole planet out of +// frame and leave only stars. +export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000; + +// A voice turn from orbit stops when about this share of the frame still shows +// the planet's surface. +export const MIN_GLOBE_FRAME_SHARE = 0.1; + +const GRID_COLUMNS = 32; +const GRID_ROWS = 18; +// A turn across a sliver of planet moves its edge through sample points, so +// the share wobbles by a cell or two without the planet leaving. +const GRID_NOISE_CELLS = 2; +// Whatever the baseline, a voice turn never takes the planet below this share +// of the frame; a view that already shows less may only keep what it has. +const LOOK_ABSOLUTE_FLOOR_SHARE = 0.01; +const sampleRay = new Cesium.Ray(); +const sampleOffset = new Cesium.Cartesian3(); + +/** + * Cast a 32×18 grid of view rays, one through the centre of each cell, at the + * WGS84 ellipsoid. With `keyholeRadius` (a radius around the frame centre, in + * units of half the frame height) only the cells inside that circle count: the + * scope mask hides the rest. Returns the surface hits and the cells counted, or + * null for a frustum other than a perspective one. + */ +function sampleFrame(camera, keyholeRadius) { + const frustum = camera.frustum; + if (!(frustum instanceof Cesium.PerspectiveFrustum)) return null; + const aspect = frustum.aspectRatio; + const tanY = Math.tan(frustum.fovy / 2); + const tanX = tanY * aspect; + const keyholeSquared = keyholeRadius > 0 ? keyholeRadius * keyholeRadius : 0; + Cesium.Cartesian3.clone(camera.positionWC, sampleRay.origin); + let hits = 0; + let cells = 0; + for (let row = 0; row < GRID_ROWS; row += 1) { + const rowY = (2 * (row + 0.5)) / GRID_ROWS - 1; + for (let column = 0; column < GRID_COLUMNS; column += 1) { + const columnX = (2 * (column + 0.5)) / GRID_COLUMNS - 1; + if ( + keyholeSquared && + (columnX * aspect) ** 2 + rowY * rowY > keyholeSquared + ) + continue; + cells += 1; + Cesium.Cartesian3.multiplyByScalar( + camera.rightWC, + columnX * tanX, + sampleOffset, + ); + Cesium.Cartesian3.add( + camera.directionWC, + sampleOffset, + sampleRay.direction, + ); + Cesium.Cartesian3.multiplyByScalar( + camera.upWC, + rowY * tanY, + sampleOffset, + ); + Cesium.Cartesian3.add( + sampleRay.direction, + sampleOffset, + sampleRay.direction, + ); + Cesium.Cartesian3.normalize(sampleRay.direction, sampleRay.direction); + if ( + Cesium.IntersectionTests.rayEllipsoid(sampleRay, Cesium.Ellipsoid.WGS84) + ) + hits += 1; + } + } + return { hits, cells }; +} + +/** + * Share of the frame, from 0 to 1, that shows the planet's surface: the + * fraction of a 32×18 grid of view rays, one through the centre of each cell, + * that hit the WGS84 ellipsoid. With `keyholeRadius` (see `sampleFrame`) it is + * the share of the visible circle instead. A frustum other than a perspective + * one counts as fully covered. + */ +export function globeFrameShare(camera, { keyholeRadius = 0 } = {}) { + const sample = sampleFrame(camera, keyholeRadius); + if (!sample || sample.cells === 0) return 1; + return sample.hits / sample.cells; +} + +/** True when none of the planet's surface is in `camera`'s visible frame. */ +export function globeOutOfFrame(camera, options) { + return globeFrameShare(camera, options) === 0; +} + +const lookProbes = new WeakMap(); +const LOOK_EDGE_TOLERANCE = Cesium.Math.toRadians(0.05); + +/** + * How much of one in-place look step (`lookUp`, `lookDown`, `lookLeft` or + * `lookRight` by a non-negative `angle` in radians) keeps the planet in frame + * from orbit. Above the free-look height the step may not leave less than + * `MIN_GLOBE_FRAME_SHARE` of the visible frame on the planet, or less than + * `startShare` when that is lower, give or take two grid cells of sampling + * noise. However the baseline was taken, it never leaves less than 1% of the + * frame, and a view that already shows less may only keep or gain what it has. + * `startShare` is the baseline the turn is measured against (the current share + * by default): callers pass one value to every step of a motion, and to every + * turn that continues from where the last one left off, so the limit cannot + * slide down a step or a command at a time. `keyholeRadius` limits the measure + * to the scope's visible circle, as in `globeFrameShare`. + * With nothing on screen the limit is nothing either, so the step is left to + * the move-end recovery. Returns `angle` when the whole step fits, otherwise a + * smaller angle that fits, found by bisection to within 0.05°, so a stopped + * motion rests at the limit. Steps are tried on a copy; `camera` is not moved. + */ +export function lookAngleWithinFrame( + camera, + look, + angle, + { startShare, keyholeRadius = 0 } = {}, +) { + if ( + !(angle > 0) || + !(camera.positionCartographic?.height > FREE_LOOK_MAX_HEIGHT_METERS) + ) + return angle; + const sample = sampleFrame(camera, keyholeRadius); + if (!sample || sample.cells === 0) return angle; + const share = sample.hits / sample.cells; + const baseline = startShare ?? share; + // Capped at what is on screen now, so a step that keeps or gains surface is + // never refused, even when the frame has shrunk since the baseline was taken + // (a resized window or a turned phone). + const floor = Math.min( + share, + Math.max( + Math.min(MIN_GLOBE_FRAME_SHARE, baseline) - + GRID_NOISE_CELLS / sample.cells, + Math.min(LOOK_ABSOLUTE_FLOOR_SHARE, baseline), + ), + ); + const fits = (step) => { + const probe = Cesium.Camera.clone(camera, lookProbes.get(camera)); + lookProbes.set(camera, probe); + probe[look](step); + // Shares are whole grid cells; the epsilon keeps rounding in `floor` from + // costing a cell. + return globeFrameShare(probe, { keyholeRadius }) >= floor - 1e-9; + }; + if (fits(angle)) return angle; + let inside = 0; + let outside = angle; + while (outside - inside > LOOK_EDGE_TOLERANCE) { + const middle = (inside + outside) / 2; + if (fits(middle)) inside = middle; + else outside = middle; + } + return inside; +} + +/** True when an in-place look step of `angle` would lose part of its turn. */ +export function lookStepLosesGlobe(camera, look, angle, options) { + return lookAngleWithinFrame(camera, look, angle, options) < angle; +} From d36b28f352389c7f9caff1c46801cd698f93b209 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 01:19:22 +0530 Subject: [PATCH 02/10] docs(changelog): add the globe-in-view fragment --- changelog.d/1532-globe-in-view.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/1532-globe-in-view.md diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md new file mode 100644 index 000000000..f697959b2 --- /dev/null +++ b/changelog.d/1532-globe-in-view.md @@ -0,0 +1,8 @@ +- Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned + the camera in place, so a few drags from the whole-globe view left only + stars, and the pose stayed in the share link. Free look now works only below + 1,000 km; a camera move that ends above it with no planet surface in view + (inside the scope keyhole when the scope is on) turns straight down; and + voice tilt and turns from orbit stop while the planet is still on screen and + say so instead of reporting a turn they could not make (Manjunath, + @manjunath22466, #1532). From 64377db91abbb8d324a3dd90dda715d95f2e6570 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 11:59:11 +0530 Subject: [PATCH 03/10] fix(camera): leave flights and scenes to their framing Review follow-ups for the globe-in-view recovery and voice limits. - Leave a camera flight in progress and a playing Director scene, holds included, to their own framing. The recovery is now installed with the scene tools, which own the Director, and reads its running state. - Reuse a voice turn's half-degree proof only from the position and direction it was proven at, so a release that moves the camera checks the turn again. - Share one free-globe-camera check between the recovery, the voice turn limit and the globe rotation. - Keep an enableLook setting other code makes after the free-look limit is installed, instead of writing back the install-time value. - Name the nudging test for what it asserts, the 1% floor, and document that a view losing its surface without a camera move waits for the next one. --- docs/CURRENT-STATE.md | 7 +++-- src/app/application.test.mjs | 44 ++++++++++++++++++++++++++++++++ src/app/scene.js | 9 ------- src/app/tools.js | 16 +++++++++++- src/app/viewer.js | 27 +++++++++++++------- src/cameraGlobeRotation.test.mjs | 28 +++++++++++++++++++- src/cameraVerbs.js | 38 ++++++++++++--------------- src/globeInView.js | 12 +++++++++ 8 files changed, 137 insertions(+), 44 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3fde50e05..4244d5937 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -100,8 +100,11 @@ away, such as a saved or shared view that did, or a drag that Cesium turns into an in-place look below its 7,500 km trackball height (one that starts on the stars, or a tilt drag with the screen centre off the planet). Views with more surface than that, such as the oblique orbital Director shots, -are left as they are, and so are tracked or locked cameras and the 2D and -Columbus modes. A voice turn requested while the camera is tracked is judged +are left as they are, and so are tracked or locked cameras, a camera flight in +progress, a Director scene while it plays (holds included) and the 2D and +Columbus modes. A view that loses its surface without a camera move (a resize, +or turning the scope on) is left as it is until the next move. A voice turn +requested while the camera is tracked is judged by the pose voice's release of the follow camera will leave, before that release, so a refused turn keeps the tracking. diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 401eefa0b..d9d915f20 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -399,6 +399,26 @@ test('free look follows camera height and is restored on dispose', () => { assert.equal(f.listeners.size, 0); }); +test('free look keeps a setting other code makes after it is installed', () => { + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + f.controller.enableLook = false; + f.frame(10); + assert.equal(f.controller.enableLook, false, 'turned off stays off'); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + f.frame(10); + assert.equal(f.controller.enableLook, false, 'and stays off after orbit'); + f.controller.enableLook = true; + f.frame(10); + assert.equal(f.controller.enableLook, true, 'turned back on'); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + assert.equal(f.controller.enableLook, false, 'still limited from orbit'); + f.frame(10); + f.controller.enableLook = false; + dispose(); + assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting'); +}); + test('free look stays off when the viewer had it disabled', () => { const f = freeLookFixture({ enableLook: false, height: 500 }); installFreeLookHeightLimit(f.viewer); @@ -418,6 +438,7 @@ function realCamera({ width = 1600, height = 900, mode } = {}) { pixelRatio: 1, ellipsoid: Cesium.Ellipsoid.WGS84, mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + tweens: new Cesium.TweenCollection(), renders: 0, requestRender() { this.renders += 1; @@ -674,6 +695,29 @@ test('the recovery follows the scope: corner-only views are turned back only beh } }); +test('the recovery leaves a camera flight and an authored scene alone', () => { + let playing = true; + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + scene.camera = camera; + scene.screenSpaceCameraController = { enableInputs: true }; + installGlobeInViewRecovery(viewer, { paused: () => playing }); + aim(camera, reportedStuckView); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone'); + playing = false; + + // A flight that pauses looking away is left alone until it ends. + camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000), + duration: 5, + }); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone'); + camera.cancelFlight(); + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); +}); + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/scene.js b/src/app/scene.js index 9c115638d..8792a1f4d 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -3,12 +3,9 @@ import * as Cesium from 'cesium'; import { createApplicationViewer, installFreeLookHeightLimit, - installGlobeInViewRecovery, installTrackpadPinchZoom, } from '../app/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; -import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; -import { isScopeMaskEnabled } from '../scopeMask.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; import { loadPhotorealisticTileset } from '../mapStartup.js'; @@ -63,12 +60,6 @@ export async function createApplicationScene({ }); defer(installTrackpadPinchZoom(viewer)); defer(installFreeLookHeightLimit(viewer)); - defer( - installGlobeInViewRecovery(viewer, { - // The scope mask hides everything outside its keyhole. - keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), - }), - ); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/app/tools.js b/src/app/tools.js index 9d91f70a9..68033947d 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; -import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; +import { + installScopeMask, + destroyScopeMask, + isScopeMaskEnabled, +} from '../scopeMask.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import { installGlobeInViewRecovery } from './viewer.js'; import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installRenderGovernor, @@ -44,6 +50,14 @@ export function createApplicationTools({ .get('bhote-koshi-2026') ?.module.attachSceneController(sceneDirector); defer(() => sceneDirector.destroy()); + defer( + installGlobeInViewRecovery(viewer, { + // The scope mask hides everything outside its keyhole. + keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), + // An authored scene frames its own shots, holds included. + paused: () => sceneDirector.running, + }), + ); onSceneDirector?.(sceneDirector); const annotations = initAnnotations({ viewer, diff --git a/src/app/viewer.js b/src/app/viewer.js index ce182a0ab..c99a34d07 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -4,6 +4,7 @@ import { StarrySky } from '../sky/starrySky.js'; import { FREE_LOOK_MAX_HEIGHT_METERS, globeOutOfFrame, + isFreeGlobeCamera, } from '../globeInView.js'; const PINCH_ZOOM_MULTIPLIER = 8; @@ -109,7 +110,9 @@ export function installTrackpadPinchZoom( /** * Allow Cesium's free look only below `maxHeight` and return its disposer. - * Orbit, tilt and zoom stay available at every height. `enableLook` gates the + * Orbit, tilt and zoom stay available at every height. A value other code + * sets later is kept as the one to allow below that height, and restored on + * dispose. `enableLook` gates the * Shift+drag binding only; a drag that starts on the stars below Cesium's * trackball height still turns the camera in place, which * `installGlobeInViewRecovery` catches. @@ -124,11 +127,14 @@ export function installFreeLookHeightLimit( if (!controller || !camera || !scene?.preUpdate) throw new TypeError('A complete Cesium viewer is required'); - const originalEnableLook = controller.enableLook; + let wanted = controller.enableLook; + let written; const sync = () => { + if (written !== undefined && controller.enableLook !== written) + wanted = controller.enableLook; const height = camera.positionCartographic?.height; - controller.enableLook = - originalEnableLook && !(Number.isFinite(height) && height > maxHeight); + written = wanted && !(Number.isFinite(height) && height > maxHeight); + controller.enableLook = written; }; sync(); const removeListener = scene.preUpdate.addEventListener(sync); @@ -138,7 +144,7 @@ export function installFreeLookHeightLimit( if (!active) return; active = false; removeListener(); - controller.enableLook = originalEnableLook; + if (controller.enableLook === written) controller.enableLook = wanted; }; } @@ -159,7 +165,9 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * catches a saved or shared view that looked away, and a drag that Cesium turns * into an in-place look below its trackball height whatever `enableLook` says. * Heading and position are kept. Tracked cameras, locked ones (non-identity - * transform) and the 2D and Columbus modes are left alone. `keyholeRadius` + * transform), a camera flight in progress, the 2D and Columbus modes, and any + * time `paused` returns true (an authored scene playing) are left alone. + * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. * @@ -179,6 +187,7 @@ export function installGlobeInViewRecovery( { minHeight = FREE_LOOK_MAX_HEIGHT_METERS, keyholeRadius = () => 0, + paused = () => false, setTimeout: schedule = globalThis.setTimeout, clearTimeout: unschedule = globalThis.clearTimeout, } = {}, @@ -189,9 +198,9 @@ export function installGlobeInViewRecovery( throw new TypeError('A complete Cesium viewer is required'); const recover = () => { - if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return; - if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)) - return; + if (!isFreeGlobeCamera(viewer)) return; + // Cesium has no public running-flight flag. + if (camera._currentFlight || paused()) return; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index f15ae4978..543c01bff 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -1526,7 +1526,7 @@ test('a voice turn after something else turned the camera starts over', (t) => { assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9); }); -test('nudging the camera between voice turns cannot slide the planet away', (t) => { +test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => { withScope(t, false); // the whole frame is visible // A wheel notch or an orbit drag between requests starts each turn afresh; // the 1% floor still holds, and below it a turn may only keep what it has. @@ -1810,6 +1810,32 @@ test('a refused voice turn leaves a tracked camera tracking', (t) => { } }); +test('a turn proven at one position is checked again after a release moves the camera', (t) => { + withScope(t, false); + const { camera, tick } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + // Tilt up to the limit, then check from 4,000 km closer along the same view + // direction, where the next tilt fits; the release moves the camera back. + for (let i = 0; i < 40; i += 1) { + if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok) + break; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + } + camera.moveForward(4_000_000); + const result = moveCamera( + { motion: 'tilt', direction: 'up', mode: 'once' }, + (start) => { + camera.moveBackward(4_000_000); + return start(); + }, + ); + assert.equal(result.ok, false); + assert.match(result.error, /globe out of view/); +}); + test('the voice frame limit leaves Columbus view alone', (t) => { // At its limit in 3D a further tilt up is refused; the same tilt in Columbus // view is not limited by the frame. diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 964b5c1a7..3c8424b82 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -22,6 +22,7 @@ import * as Cesium from 'cesium'; import { GLOBE_VIEW } from './locations.js'; import { globeFrameShare, + isFreeGlobeCamera, lookAngleWithinFrame, lookStepLosesGlobe, } from './globeInView.js'; @@ -1015,18 +1016,6 @@ export function interruptCameraMotionIfActive( return interruptCameraMotion(reason); } -/** - * A free globe camera: 3D, not tracking, not locked to a frame. The frame - * limit for in-place turns applies to it alone, as the move-end recovery does. - */ -function isFreeGlobeCamera(cam) { - return ( - _viewer.scene.mode === Cesium.SceneMode.SCENE3D && - !_viewer.trackedEntity && - Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) - ); -} - // The frame share that in-place voice turns measure their limit against, with // the pose the last voice turn left the camera in. A turn that starts where // the last one left off keeps that baseline (raised if the planet now fills @@ -1036,7 +1025,7 @@ function isFreeGlobeCamera(cam) { let _lookBaseline = null; // The pre-check's proof that a half-degree turn from this pose keeps the planet -// in view: which way, from which direction. A motion that starts from that +// in view: which way, from which position and direction. A motion that starts from that // pose turns at least that far, so a request reported as done really moves // even when a smaller first step lands in a dip of the sampled share. const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5); @@ -1079,7 +1068,7 @@ function lookBaselineShare(cam) { */ function lookWithinFrame(cam, m, look, angle) { let allowed = angle; - if (isFreeGlobeCamera(cam)) { + if (isFreeGlobeCamera(_viewer)) { if (m.globeShareAtStart === undefined) { m.globeShareAtStart = lookBaselineShare(cam); m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0; @@ -1105,6 +1094,12 @@ function lookWithinFrame(cam, m, look, angle) { function lookProven(cam, look) { return ( _provenLook?.look === look && + Cesium.Cartesian3.equalsEpsilon( + _provenLook.position, + cam.positionWC, + 0, + 1, + ) && Cesium.Cartesian3.equalsEpsilon( _provenLook.direction, cam.directionWC, @@ -1129,7 +1124,7 @@ function checkLookFromOrbit(look, { released = false } = {}) { _provenLook = null; if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null; const cam = _viewer.camera; - if (!released && !isFreeGlobeCamera(cam)) return null; + if (!released && !isFreeGlobeCamera(_viewer)) return null; if ( lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, { ...visibleFrame(), @@ -1142,7 +1137,11 @@ function checkLookFromOrbit(look, { released = false } = {}) { error: 'From this height that would turn the globe out of view — zoom in first, or turn the other way.', }; - _provenLook = { look, direction: Cesium.Cartesian3.clone(cam.directionWC) }; + _provenLook = { + look, + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + }; return null; } @@ -1329,12 +1328,7 @@ function onTick() { // Tracking can acquire the camera without going through navigation. // Yield without resetting its pose/frame: the world-space arc proof // only describes Cesium's rotation while the frame is identity. - if ( - _viewer.scene.mode !== Cesium.SceneMode.SCENE3D || - _viewer.trackedEntity || - _isCockpitActive?.() || - !Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) - ) { + if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) { interruptCameraMotion('globe-camera-owner-changed'); return; } diff --git a/src/globeInView.js b/src/globeInView.js index 1ccbcda23..98a3445dc 100644 --- a/src/globeInView.js +++ b/src/globeInView.js @@ -9,6 +9,18 @@ export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000; // the planet's surface. export const MIN_GLOBE_FRAME_SHARE = 0.1; +/** + * A free globe camera: the viewer is in 3D, follows no tracked entity, and its + * camera is not locked to a reference frame. + */ +export function isFreeGlobeCamera(viewer) { + return ( + viewer.scene.mode === Cesium.SceneMode.SCENE3D && + !viewer.trackedEntity && + Cesium.Matrix4.equals(viewer.camera.transform, Cesium.Matrix4.IDENTITY) + ); +} + const GRID_COLUMNS = 32; const GRID_ROWS = 18; // A turn across a sliver of planet moves its edge through sample points, so From 2f9d13f39d1b05d60aeded65e4a0334f13952734 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 12:20:26 +0530 Subject: [PATCH 04/10] fix(camera): orbit on drags from the stars above 1,000 km Below Cesium's trackball height (7,500 km by default) a drag that starts on the stars turns the camera in place, whatever enableLook says, which was the drag path to a view with no planet in it. Lower that height to the free-look height with the free-look limit, never raising it, and restore it on dispose. Above 1,000 km such a drag now orbits the globe, as it already did above 7,500 km, at a similar rate, and a tilt drag from the stars there tilts about the planet's edge or does nothing instead of turning the camera. Drags that start on the planet are unchanged. --- docs/CURRENT-STATE.md | 9 +++++---- src/app/application.test.mjs | 23 ++++++++++++++++++++++- src/app/viewer.js | 17 +++++++++++------ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4244d5937..94c2dc1aa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -67,7 +67,10 @@ the real browser controller at desktop and phone widths with keyless fixtures. Cesium's free look (Shift+drag, which turns the camera in place) works only below 1,000 km. Above that height a few drags could turn the whole planet out of -frame and leave only stars, so Shift+drag does nothing there. Orbit, tilt and +frame and leave only stars, so Shift+drag does nothing there. Cesium also +turns the camera in place for a drag that starts on the stars below its +trackball height (7,500 km by default); that height is lowered to 1,000 km, so +such a drag orbits the globe above it, as it does higher up. Orbit, tilt and zoom are unchanged at every height. How much of the frame shows the planet's surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope mask is on (the default) only the rays inside its keyhole count, since @@ -96,9 +99,7 @@ rest, and gives up five minutes after the camera last moved. Limb glow, and a surface sliver up to about one grid cell thick (some 50 px on a 900 px tall frame), can count as out of view. The zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking -away, such as a saved or shared view that did, or a drag -that Cesium turns into an in-place look below its 7,500 km trackball height (one -that starts on the stars, or a tilt drag with the screen centre off the planet). +away, such as a saved or shared view that did. Views with more surface than that, such as the oblique orbital Director shots, are left as they are, and so are tracked or locked cameras, a camera flight in progress, a Director scene while it plays (holds included) and the 2D and diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index d9d915f20..fa489cc69 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -359,7 +359,7 @@ test('trackpad pinch falls back to the original event when relay construction fa function freeLookFixture({ enableLook = true, height = 0 } = {}) { const listeners = new Set(); - const controller = { enableLook }; + const controller = { enableLook, minimumTrackBallHeight: 7_500_000 }; const camera = { positionCartographic: { height } }; const viewer = { camera, @@ -399,6 +399,27 @@ test('free look follows camera height and is restored on dispose', () => { assert.equal(f.listeners.size, 0); }); +test('drags that start on the stars orbit above the free-look height', () => { + // Below Cesium's trackball height such a drag turns the camera in place. + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + assert.equal( + f.controller.minimumTrackBallHeight, + FREE_LOOK_MAX_HEIGHT_METERS, + ); + dispose(); + assert.equal(f.controller.minimumTrackBallHeight, 7_500_000); + + const lower = freeLookFixture({ height: 500 }); + lower.controller.minimumTrackBallHeight = 500_000; + installFreeLookHeightLimit(lower.viewer)(); + assert.equal( + lower.controller.minimumTrackBallHeight, + 500_000, + 'never raised', + ); +}); + test('free look keeps a setting other code makes after it is installed', () => { const f = freeLookFixture({ height: 500 }); const dispose = installFreeLookHeightLimit(f.viewer); diff --git a/src/app/viewer.js b/src/app/viewer.js index c99a34d07..e44d1aa10 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -112,10 +112,10 @@ export function installTrackpadPinchZoom( * Allow Cesium's free look only below `maxHeight` and return its disposer. * Orbit, tilt and zoom stay available at every height. A value other code * sets later is kept as the one to allow below that height, and restored on - * dispose. `enableLook` gates the - * Shift+drag binding only; a drag that starts on the stars below Cesium's - * trackball height still turns the camera in place, which - * `installGlobeInViewRecovery` catches. + * dispose. `enableLook` gates the Shift+drag binding only: below its trackball + * height Cesium also turns the camera in place for a drag that starts on the + * stars. That height is lowered to `maxHeight` too, so above it such a drag + * orbits the globe, as it does above Cesium's own 7,500 km. */ export function installFreeLookHeightLimit( viewer, @@ -127,6 +127,9 @@ export function installFreeLookHeightLimit( if (!controller || !camera || !scene?.preUpdate) throw new TypeError('A complete Cesium viewer is required'); + const originalTrackBallHeight = controller.minimumTrackBallHeight; + const trackBallHeight = Math.min(originalTrackBallHeight, maxHeight); + controller.minimumTrackBallHeight = trackBallHeight; let wanted = controller.enableLook; let written; const sync = () => { @@ -145,6 +148,8 @@ export function installFreeLookHeightLimit( active = false; removeListener(); if (controller.enableLook === written) controller.enableLook = wanted; + if (controller.minimumTrackBallHeight === trackBallHeight) + controller.minimumTrackBallHeight = originalTrackBallHeight; }; } @@ -162,8 +167,8 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about * one sample cell thick can count as none), and return the disposer. Views with * more surface, such as an oblique orbital shot, are left as they are. This - * catches a saved or shared view that looked away, and a drag that Cesium turns - * into an in-place look below its trackball height whatever `enableLook` says. + * catches a saved or shared view that looked away, or any other move that ends + * looking away from the planet. * Heading and position are kept. Tracked cameras, locked ones (non-identity * transform), a camera flight in progress, the 2D and Columbus modes, and any * time `paused` returns true (an authored scene playing) are left alone. From 29935cc094480308ef381a51bd9af14249911699 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 14:36:10 +0530 Subject: [PATCH 05/10] fix(camera): run a recovery check a scene deferred once it ends A check skipped because a flight or a playing Director scene was in progress used to be dropped, so a run that ended or held on a stars-only shot stayed there until the next camera move. Keep watching instead: the settle check retries once the flight or scene ends, within its existing five-minute bound, and a moveEnd that arrives while it is deferred keeps the check armed rather than stopping it. Also test that the free-look limit never raises Cesium's trackball height and that dispose keeps a value set after install, note in its JSDoc the enableLook case it cannot tell apart, and document the trackball change and the retry in CURRENT-STATE and the changelog. --- changelog.d/1532-globe-in-view.md | 11 +++++---- docs/CURRENT-STATE.md | 32 +++++++++++++------------ src/app/application.test.mjs | 39 ++++++++++++++++++++++++++++++- src/app/viewer.js | 25 ++++++++++++-------- 4 files changed, 76 insertions(+), 31 deletions(-) diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md index f697959b2..676dfebf3 100644 --- a/changelog.d/1532-globe-in-view.md +++ b/changelog.d/1532-globe-in-view.md @@ -1,8 +1,9 @@ - Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned the camera in place, so a few drags from the whole-globe view left only stars, and the pose stayed in the share link. Free look now works only below - 1,000 km; a camera move that ends above it with no planet surface in view - (inside the scope keyhole when the scope is on) turns straight down; and - voice tilt and turns from orbit stop while the planet is still on screen and - say so instead of reporting a turn they could not make (Manjunath, - @manjunath22466, #1532). + 1,000 km, and above it a drag that starts on the stars orbits the globe + instead of turning the camera; a camera move that ends above it with no + planet surface in view (inside the scope keyhole when the scope is on) turns + straight down; and voice tilt and turns from orbit stop while the planet is + still on screen and say so instead of reporting a turn they could not make + (Manjunath, @manjunath22466, #1532). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 94c2dc1aa..6fbf52a1e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -67,10 +67,11 @@ the real browser controller at desktop and phone widths with keyless fixtures. Cesium's free look (Shift+drag, which turns the camera in place) works only below 1,000 km. Above that height a few drags could turn the whole planet out of -frame and leave only stars, so Shift+drag does nothing there. Cesium also -turns the camera in place for a drag that starts on the stars below its -trackball height (7,500 km by default); that height is lowered to 1,000 km, so -such a drag orbits the globe above it, as it does higher up. Orbit, tilt and +frame and leave only stars, so Shift+drag does nothing there. Cesium also turns +the camera in place for a drag that starts on the stars below its trackball +height (7,500 km by default); that height is lowered to 1,000 km, so such a drag +orbits the globe above it, as it does higher up, and a tilt drag from the stars +there tilts about the planet's edge or does nothing. Otherwise orbit, tilt and zoom are unchanged at every height. How much of the frame shows the planet's surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope mask is on (the default) only the rays inside its keyhole count, since @@ -97,17 +98,18 @@ rounding error every frame and Cesium never sees it stop. That check keeps watching until move-end arrives, recovering once after each move that comes to rest, and gives up five minutes after the camera last moved. Limb glow, and a surface sliver up to about one grid cell thick (some 50 px on a 900 px tall -frame), can count as out of view. The zoom-out guard above keeps wheel and -pinch zoom-outs in view; this catches the other ways a move can end looking -away, such as a saved or shared view that did. -Views with more surface than that, such as the oblique orbital Director shots, -are left as they are, and so are tracked or locked cameras, a camera flight in -progress, a Director scene while it plays (holds included) and the 2D and -Columbus modes. A view that loses its surface without a camera move (a resize, -or turning the scope on) is left as it is until the next move. A voice turn -requested while the camera is tracked is judged -by the pose voice's release of the follow camera will leave, before that -release, so a refused turn keeps the tracking. +frame), can count as out of view. The zoom-out guard above keeps wheel and pinch +zoom-outs in view; this catches the other ways a move can end looking away, such +as a saved or shared view that did. Views with more surface than that, such as +the oblique orbital Director shots, are left as they are, and so are tracked or +locked cameras, a camera flight in progress, a Director scene while it plays +(holds included) and the 2D and Columbus modes; a check a flight or scene defers +runs once it ends. A Director shot loaded or replayed outside a run is not a +playing scene. A view that loses its surface without a camera move (a resize, or +turning the scope on) is left as it is until the next move. A voice turn +requested while the camera is tracked is judged by the pose voice's release of +the follow camera will leave, before that release, so a refused turn keeps the +tracking. 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/src/app/application.test.mjs b/src/app/application.test.mjs index fa489cc69..3e4a359e7 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -412,12 +412,24 @@ test('drags that start on the stars orbit above the free-look height', () => { const lower = freeLookFixture({ height: 500 }); lower.controller.minimumTrackBallHeight = 500_000; - installFreeLookHeightLimit(lower.viewer)(); + const disposeLower = installFreeLookHeightLimit(lower.viewer); assert.equal( lower.controller.minimumTrackBallHeight, 500_000, 'never raised', ); + disposeLower(); + assert.equal(lower.controller.minimumTrackBallHeight, 500_000); + + const later = freeLookFixture({ height: 500 }); + const disposeLater = installFreeLookHeightLimit(later.viewer); + later.controller.minimumTrackBallHeight = 2_000_000; + disposeLater(); + assert.equal( + later.controller.minimumTrackBallHeight, + 2_000_000, + 'dispose keeps a value set after install', + ); }); test('free look keeps a setting other code makes after it is installed', () => { @@ -739,6 +751,31 @@ test('the recovery leaves a camera flight and an authored scene alone', () => { assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); }); +test('a check a scene or flight defers runs once it ends', () => { + for (const viaMoveEnd of [false, true]) { + let playing = true; + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, { + paused: () => playing, + ...timers, + }); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + // A hold on a stars-only shot: quiet, with or without Cesium's moveEnd. + for (let i = 0; i < 4; i += 1) timers.tick(); + if (viaMoveEnd) camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays'); + assert.equal(timers.pending(), 1, 'still watching'); + playing = false; + timers.tick(); + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + `recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`, + ); + } +}); + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index e44d1aa10..1bc94ddd8 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -112,7 +112,9 @@ export function installTrackpadPinchZoom( * Allow Cesium's free look only below `maxHeight` and return its disposer. * Orbit, tilt and zoom stay available at every height. A value other code * sets later is kept as the one to allow below that height, and restored on - * dispose. `enableLook` gates the Shift+drag binding only: below its trackball + * dispose; a `false` written while the camera is above `maxHeight` cannot be + * told apart from this limit's own write, so dispose restores the earlier + * value then. `enableLook` gates the Shift+drag binding only: below its trackball * height Cesium also turns the camera in place for a drag that starts on the * stars. That height is lowered to `maxHeight` too, so above it such a drag * orbits the globe, as it does above Cesium's own 7,500 km. @@ -171,7 +173,8 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * looking away from the planet. * Heading and position are kept. Tracked cameras, locked ones (non-identity * transform), a camera flight in progress, the 2D and Columbus modes, and any - * time `paused` returns true (an authored scene playing) are left alone. + * time `paused` returns true (an authored scene playing) are left alone; a + * check they defer runs again once they end, within the settle check's bound. * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. @@ -202,16 +205,18 @@ export function installGlobeInViewRecovery( if (!scene || !camera?.moveEnd || !camera?.moveStart) throw new TypeError('A complete Cesium viewer is required'); + // Returns false while a flight or a paused scene defers the check, so the + // caller can try again once it ends. const recover = () => { - if (!isFreeGlobeCamera(viewer)) return; + if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused()) return; + if (camera._currentFlight || paused()) return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || !globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() }) ) - return; + return true; camera.setView({ orientation: { heading: camera.heading, @@ -220,6 +225,7 @@ export function installGlobeInViewRecovery( }, }); scene.requestRender?.(); + return true; }; const lastPosition = new Cesium.Cartesian3(); @@ -255,10 +261,8 @@ export function installGlobeInViewRecovery( } // Two quiet samples in a row after a move: at rest for half a second, as // for moveEnd. - if (restingSamples >= 2 && movedSinceCheck) { + if (restingSamples >= 2 && movedSinceCheck && recover()) movedSinceCheck = false; - recover(); - } if (settleElapsed >= SETTLE_GIVE_UP_MS) return; Cesium.Cartesian3.clone(camera.positionWC, lastPosition); Cesium.Cartesian3.clone(camera.directionWC, lastDirection); @@ -274,8 +278,9 @@ export function installGlobeInViewRecovery( settleTimer = schedule(sample, SETTLE_SAMPLE_MS); }; const removeMoveEnd = camera.moveEnd.addEventListener(() => { - stopSettling(); - recover(); + if (recover()) stopSettling(); + // Deferred: keep sampling so the check runs once the flight or scene ends. + else if (settleTimer === null) startSettling(); }); const removeMoveStart = camera.moveStart.addEventListener(startSettling); From 852e93d074761477575cbead33f2f12dd71c7bbf Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 14:45:29 +0530 Subject: [PATCH 06/10] fix(camera): keep a deferred recovery check through long holds A Director hold longer than the settle check's five-minute bound used to expire the deferred check, so a run that ended on a stars-only shot after a long hold stayed there until the next move. While a flight or a playing scene defers the check, its bound no longer counts down; the flight or scene bounds the deferral itself. Also correct the trackball note in CURRENT-STATE: Cesium's tilt tests the screen centre, so any tilt (drag or touch) with the centre on the stars between 1,000 and 7,500 km now tilts about the planet's edge or does nothing, wherever it starts. Rotation drags that start on the planet are unchanged. --- docs/CURRENT-STATE.md | 80 ++++++++++++++++++------------------ src/app/application.test.mjs | 2 + src/app/viewer.js | 9 ++-- 3 files changed, 48 insertions(+), 43 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6fbf52a1e..3dd7a59d0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -70,46 +70,46 @@ below 1,000 km. Above that height a few drags could turn the whole planet out of frame and leave only stars, so Shift+drag does nothing there. Cesium also turns the camera in place for a drag that starts on the stars below its trackball height (7,500 km by default); that height is lowered to 1,000 km, so such a drag -orbits the globe above it, as it does higher up, and a tilt drag from the stars -there tilts about the planet's edge or does nothing. Otherwise orbit, tilt and -zoom are unchanged at every height. How much of the frame shows the planet's -surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while -the scope mask is on (the default) only the rays inside its keyhole count, since -the mask hides or dims the rest. Voice tilt and in-place turns of a free 3D -camera above 1,000 km stop when about a tenth of the frame still shows the -surface (or about as much as it showed when it started), never less than about -1% of the visible frame, and a view that already shows less only keeps or gains -what it has. A turn that cannot go half a degree further without crossing that -limit is refused with a reason instead of reported as done, and a turn it -accepts turns at least that half degree. A turn that keeps or gains surface is -not refused, even after the window has been resized, except that near the limit, -where the planet's edge crosses the sample grid, sampling wobble can -occasionally refuse one (most often above about 15,000 km and on phone or -ultrawide frames); nothing then turns away from the planet. A turn that starts -where the last voice turn left the camera keeps that turn's limit, or a higher -one if the planet now fills more, so repeating the request does not take any -more; once anything else has moved or turned the camera, a turn is measured from -what is on screen. When a camera move ends above 1,000 km with no ray on the -surface, the camera turns straight down at the same position and heading. The -end of a move is taken from Cesium's move-end event and from a check of its own -that samples the pose every 250 ms after a move starts and acts once it has held -for half a second, because at some of these poses the camera drifts by a -rounding error every frame and Cesium never sees it stop. That check keeps -watching until move-end arrives, recovering once after each move that comes to -rest, and gives up five minutes after the camera last moved. Limb glow, and a -surface sliver up to about one grid cell thick (some 50 px on a 900 px tall -frame), can count as out of view. The zoom-out guard above keeps wheel and pinch -zoom-outs in view; this catches the other ways a move can end looking away, such -as a saved or shared view that did. Views with more surface than that, such as -the oblique orbital Director shots, are left as they are, and so are tracked or -locked cameras, a camera flight in progress, a Director scene while it plays -(holds included) and the 2D and Columbus modes; a check a flight or scene defers -runs once it ends. A Director shot loaded or replayed outside a run is not a -playing scene. A view that loses its surface without a camera move (a resize, or -turning the scope on) is left as it is until the next move. A voice turn -requested while the camera is tracked is judged by the pose voice's release of -the follow camera will leave, before that release, so a refused turn keeps the -tracking. +orbits the globe above it, as it does higher up, and a tilt (drag or touch) +while the screen centre is on the stars there tilts about the planet's edge or +does nothing, wherever it starts. Rotation drags that start on the planet, and +zoom, are unchanged. How much of the frame shows the planet's surface is +measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope +mask is on (the default) only the rays inside its keyhole count, since the mask +hides or dims the rest. Voice tilt and in-place turns of a free 3D camera above +1,000 km stop when about a tenth of the frame still shows the surface (or about +as much as it showed when it started), never less than about 1% of the visible +frame, and a view that already shows less only keeps or gains what it has. A +turn that cannot go half a degree further without crossing that limit is refused +with a reason instead of reported as done, and a turn it accepts turns at least +that half degree. A turn that keeps or gains surface is not refused, even after +the window has been resized, except that near the limit, where the planet's edge +crosses the sample grid, sampling wobble can occasionally refuse one (most often +above about 15,000 km and on phone or ultrawide frames); nothing then turns away +from the planet. A turn that starts where the last voice turn left the camera +keeps that turn's limit, or a higher one if the planet now fills more, so +repeating the request does not take any more; once anything else has moved or +turned the camera, a turn is measured from what is on screen. When a camera move +ends above 1,000 km with no ray on the surface, the camera turns straight down +at the same position and heading. The end of a move is taken from Cesium's +move-end event and from a check of its own that samples the pose every 250 ms +after a move starts and acts once it has held for half a second, because at some +of these poses the camera drifts by a rounding error every frame and Cesium +never sees it stop. That check keeps watching until move-end arrives, recovering +once after each move that comes to rest, and gives up five minutes after the +camera last moved. Limb glow, and a surface sliver up to about one grid cell +thick (some 50 px on a 900 px tall frame), can count as out of view. The +zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the +other ways a move can end looking away, such as a saved or shared view that did. +Views with more surface than that, such as the oblique orbital Director shots, +are left as they are, and so are tracked or locked cameras, a camera flight in +progress, a Director scene while it plays (holds included) and the 2D and +Columbus modes; a check a flight or scene defers runs once it ends. A Director +shot loaded or replayed outside a run is not a playing scene. A view that loses +its surface without a camera move (a resize, or turning the scope on) is left as +it is until the next move. A voice turn requested while the camera is tracked is +judged by the pose voice's release of the follow camera will leave, before that +release, so a refused turn keeps the tracking. 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/src/app/application.test.mjs b/src/app/application.test.mjs index 3e4a359e7..da01ff61c 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -765,6 +765,8 @@ test('a check a scene or flight defers runs once it ends', () => { // A hold on a stars-only shot: quiet, with or without Cesium's moveEnd. for (let i = 0; i < 4; i += 1) timers.tick(); if (viaMoveEnd) camera.moveEnd.raiseEvent(); + // Longer than the settle check's five-minute bound. + for (let i = 0; i < 1500; i += 1) timers.tick(); assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays'); assert.equal(timers.pending(), 1, 'still watching'); playing = false; diff --git a/src/app/viewer.js b/src/app/viewer.js index 1bc94ddd8..ef36feb8a 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -174,7 +174,7 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * Heading and position are kept. Tracked cameras, locked ones (non-identity * transform), a camera flight in progress, the 2D and Columbus modes, and any * time `paused` returns true (an authored scene playing) are left alone; a - * check they defer runs again once they end, within the settle check's bound. + * check they defer runs once they end, however long they last. * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. @@ -261,8 +261,11 @@ export function installGlobeInViewRecovery( } // Two quiet samples in a row after a move: at rest for half a second, as // for moveEnd. - if (restingSamples >= 2 && movedSinceCheck && recover()) - movedSinceCheck = false; + if (restingSamples >= 2 && movedSinceCheck) { + if (recover()) movedSinceCheck = false; + // A flight or a playing scene bounds its own deferral. + else settleElapsed = 0; + } if (settleElapsed >= SETTLE_GIVE_UP_MS) return; Cesium.Cartesian3.clone(camera.positionWC, lastPosition); Cesium.Cartesian3.clone(camera.directionWC, lastDirection); From ef4b319f975dcd1515997101226311127867267e Mon Sep 17 00:00:00 2001 From: Manjunath Date: Thu, 5 Nov 2026 15:57:09 +0530 Subject: [PATCH 07/10] docs(changelog): credit the review behind the follow-ups --- changelog.d/1532-globe-in-view.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md index 676dfebf3..4ac444023 100644 --- a/changelog.d/1532-globe-in-view.md +++ b/changelog.d/1532-globe-in-view.md @@ -5,5 +5,7 @@ instead of turning the camera; a camera move that ends above it with no planet surface in view (inside the scope keyhole when the scope is on) turns straight down; and voice tilt and turns from orbit stop while the planet is - still on screen and say so instead of reporting a turn they could not make - (Manjunath, @manjunath22466, #1532). + still on screen and say so instead of reporting a turn they could not make. + Thanks to [Chad Elliott](https://github.com/THEtheChad) for the review that + closed the drag path and kept authored scenes framed (Manjunath, + @manjunath22466, #1532). From a20076fc5ef8976b1007170a05a3fc91765c48d3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:55:52 -0800 Subject: [PATCH 08/10] fix(camera): judge a voice tilt's frame limit by the way its pitch moves The pre-check picked the in-place look from args.direction alone, while the tilt itself turns down for any direction but up. A tilt sent as left or right at the frame limit was checked as a turn left or right, refused as losing the globe, though it turns down toward the planet. A rotate sent as up or down was checked as a look up or down, though it turns right. A tilt's look now comes from its goal pitch against the current pitch (tiltLook), and the tick tilts in place through tiltInPlaceWithinFrame(cam, m, goalPitch). Neither depends on the direction word, so a tilt to an absolute angle, or a tilt with no direction, can use them as they are. --- src/cameraGlobeRotation.test.mjs | 20 ++++++++++++++++ src/cameraVerbs.js | 40 +++++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 543c01bff..09fc93f00 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -1426,6 +1426,26 @@ test('voice tilt from orbit stops with the planet still on screen and then says assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66)); }); +// The frame limit judges a tilt by the way its pitch moves, not by the +// direction word: a tilt sent as left or right turns down. +for (const direction of ['left', 'right']) { + test(`a tilt ${direction} from the frame limit is judged as the look down it is`, (t) => { + withScope(t, false); + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true); + finishOnce(); + const atLimit = camera.pitch; + const result = moveCamera({ motion: 'tilt', direction }); + assert.equal(result.ok, true, result.error); + finishOnce(); + assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down'); + }); +} + test('voice turn in place from orbit stops with the planet still on screen', (t) => { withScope(t, false); // the whole frame is visible const { camera, tick } = harness(t, { pitch: -75 }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 3c8424b82..9715e24f7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1090,6 +1090,23 @@ function lookWithinFrame(cam, m, look, angle) { if (allowed < angle) interruptCameraMotion('globe-out-of-frame'); } +/** + * The in-place look a pitch change from `fromPitch` to `goalPitch` makes: + * 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by + * the way its pitch moves, whatever form or direction word it came in. + */ +function tiltLook(fromPitch, goalPitch) { + if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null; + if (goalPitch > fromPitch) return 'lookUp'; + return goalPitch < fromPitch ? 'lookDown' : null; +} + +/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */ +function tiltInPlaceWithinFrame(cam, m, goalPitch) { + const look = tiltLook(cam.pitch, goalPitch); + if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch)); +} + /** True when the pre-check proved a half-degree `look` from `cam`'s pose. */ function lookProven(cam, look) { return ( @@ -1358,7 +1375,7 @@ function onTick() { interruptCameraMotion('tilt-clamp'); return; } - lookWithinFrame(cam, m, up ? 'lookUp' : 'lookDown', stepRad); + tiltInPlaceWithinFrame(cam, m, next); } else { lookWithinFrame( cam, @@ -1662,15 +1679,20 @@ export function moveCamera(args = {}, runNavigation = null) { }; } } + // A tilt is judged by the way its pitch will move (any direction other + // than up tilts down); a rotate turns left only when told to, as it runs. const look = - motion === 'tilt' || motion === 'rotate' - ? { - up: 'lookUp', - down: 'lookDown', - left: 'lookLeft', - right: 'lookRight', - }[direction] - : null; + motion === 'tilt' + ? tiltLook( + _viewer.camera.pitch, + _viewer.camera.pitch + + Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1), + ) + : motion === 'rotate' + ? direction === 'left' + ? 'lookLeft' + : 'lookRight' + : null; // Honest limits from orbit too: a stopped turn rests within 0.05° of its // limit, so one that cannot go half a degree further is refused rather than // run and reported as done, and one that can is then turned at least that From 44106b2bfbc1634d5ea91175cc530f83c381cae5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:55:52 -0800 Subject: [PATCH 09/10] fix(camera): hold the globe recovery while a pointer is down Holding a drag still for half a second on a view that already showed no surface looked like a camera at rest, so the recovery turned the view down under the user's hand. The recovery now defers while a pointer pressed on the canvas is held, and runs once it is released, as for a flight or a playing scene. --- docs/CURRENT-STATE.md | 5 +++-- src/app/application.test.mjs | 33 +++++++++++++++++++++++++++++++++ src/app/viewer.js | 31 +++++++++++++++++++++++++------ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 27c31f4ce..545c4efd8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -103,8 +103,9 @@ zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking away, such as a saved or shared view that did. Views with more surface than that, such as the oblique orbital Director shots, are left as they are, and so are tracked or locked cameras, a camera flight in -progress, a Director scene while it plays (holds included) and the 2D and -Columbus modes; a check a flight or scene defers runs once it ends. A Director +progress, a Director scene while it plays (holds included), a pointer held +down on the globe (a drag held still) and the 2D and Columbus modes; a check a +flight, scene or held pointer defers runs once it ends. A Director shot loaded or replayed outside a run is not a playing scene. A view that loses its surface without a camera move (a resize, or turning the scope on) is left as it is until the next move. A voice turn requested while the camera is tracked is diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index da01ff61c..e25b2ce11 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -778,6 +778,39 @@ test('a check a scene or flight defers runs once it ends', () => { } }); +// Holding a drag still for half a second on a view that already showed no +// surface looked like a settled camera, and the view was turned down under +// the user's hand. +test('the recovery waits while a pointer is held on the globe', () => { + const timers = fakeTimers(); + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const document = new EventTarget(); + scene.canvas = Object.assign(new EventTarget(), { + clientWidth: 1225, + clientHeight: 923, + ownerDocument: document, + }); + const dispose = installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + scene.canvas.dispatchEvent(new Event('pointerdown')); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + camera.moveEnd.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'left alone while held'); + document.dispatchEvent(new Event('pointerup')); + for (let i = 0; i < 3; i += 1) timers.tick(); + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once the pointer is released', + ); + dispose(); + aim(camera, reportedStuckView); + scene.canvas.dispatchEvent(new Event('pointerdown')); + document.dispatchEvent(new Event('pointerup')); + assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners'); +}); + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index ef36feb8a..12ef67269 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -172,9 +172,10 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * catches a saved or shared view that looked away, or any other move that ends * looking away from the planet. * Heading and position are kept. Tracked cameras, locked ones (non-identity - * transform), a camera flight in progress, the 2D and Columbus modes, and any - * time `paused` returns true (an authored scene playing) are left alone; a - * check they defer runs once they end, however long they last. + * transform), a camera flight in progress, a pointer held down on the canvas + * (a drag held still), the 2D and Columbus modes, and any time `paused` + * returns true (an authored scene playing) are left alone; a check they defer + * runs once they end, however long they last. * `keyholeRadius` * returns the radius of the visible circle when a scope mask hides the rest of * the frame (see `globeFrameShare`), or 0; it is read at every check. @@ -205,12 +206,27 @@ export function installGlobeInViewRecovery( if (!scene || !camera?.moveEnd || !camera?.moveStart) throw new TypeError('A complete Cesium viewer is required'); - // Returns false while a flight or a paused scene defers the check, so the - // caller can try again once it ends. + // A pointer held on the globe is a gesture in progress, even when it holds + // still for half a second: the check waits for its release. + const canvas = scene.canvas; + const releaseTarget = canvas?.ownerDocument ?? canvas; + let pointerHeld = false; + const onPointerDown = () => { + pointerHeld = true; + }; + const onPointerUp = () => { + pointerHeld = false; + }; + canvas?.addEventListener?.('pointerdown', onPointerDown); + releaseTarget?.addEventListener?.('pointerup', onPointerUp); + releaseTarget?.addEventListener?.('pointercancel', onPointerUp); + + // Returns false while a flight, a paused scene or a held pointer defers the + // check, so the caller can try again once it ends. const recover = () => { if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused()) return false; + if (camera._currentFlight || paused() || pointerHeld) return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || @@ -294,6 +310,9 @@ export function installGlobeInViewRecovery( stopSettling(); removeMoveEnd(); removeMoveStart(); + canvas?.removeEventListener?.('pointerdown', onPointerDown); + releaseTarget?.removeEventListener?.('pointerup', onPointerUp); + releaseTarget?.removeEventListener?.('pointercancel', onPointerUp); }; } From f332150689a889718b1389dc094470d44ca46bd9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:16:05 -0800 Subject: [PATCH 10/10] fix(camera): hold the globe recovery until every pointer is released MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The held-pointer guard was one flag, so with two fingers down, lifting one cleared it and the recovery turned the view down under the other (pitch -42° to -90°). It now tracks each pressed pointer by id and defers until the last is released. A release outside the window that the page never sees could leave the recovery deferred for good. A lost window focus, a hidden page or a lost pointer capture now releases every held pointer, and a pointer that moves with no button down is released. --- docs/CURRENT-STATE.md | 8 ++- src/app/application.test.mjs | 96 ++++++++++++++++++++++++++++++++++++ src/app/viewer.js | 43 +++++++++++----- 3 files changed, 132 insertions(+), 15 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 545c4efd8..08cdf3a49 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -104,8 +104,12 @@ other ways a move can end looking away, such as a saved or shared view that did. Views with more surface than that, such as the oblique orbital Director shots, are left as they are, and so are tracked or locked cameras, a camera flight in progress, a Director scene while it plays (holds included), a pointer held -down on the globe (a drag held still) and the 2D and Columbus modes; a check a -flight, scene or held pointer defers runs once it ends. A Director +down on the globe (a drag held still; with several fingers down, until the +last is released) and the 2D and Columbus modes; a check a flight, scene or +held pointer defers runs once it ends. A release outside the window that the +page never sees counts as given once the window loses focus, the page is +hidden, the canvas loses the pointer's capture, or the pointer moves with no +button down. A Director shot loaded or replayed outside a run is not a playing scene. A view that loses its surface without a camera move (a resize, or turning the scope on) is left as it is until the next move. A voice turn requested while the camera is tracked is diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index e25b2ce11..c01878d89 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -811,6 +811,102 @@ test('the recovery waits while a pointer is held on the globe', () => { assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners'); }); +/** A held-pointer recovery over a canvas in a document in a window. */ +function heldPointerRecovery() { + const timers = fakeTimers(); + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const window = new EventTarget(); + const document = Object.assign(new EventTarget(), { + defaultView: window, + visibilityState: 'visible', + }); + scene.canvas = Object.assign(new EventTarget(), { + clientWidth: 1225, + clientHeight: 923, + ownerDocument: document, + }); + const dispose = installGlobeInViewRecovery(viewer, timers); + const pointer = (target, type, pointerId, init = {}) => + target.dispatchEvent( + Object.assign(new Event(type), { pointerId, ...init }), + ); + /** Press, move and hold still: the check defers while a pointer is held. */ + const holdStill = () => { + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + camera.moveEnd.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + }; + const settle = () => { + for (let i = 0; i < 3; i += 1) timers.tick(); + }; + const recovered = () => + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6); + return { + camera, + document, + window, + dispose, + pointer, + holdStill, + settle, + recovered, + canvas: scene.canvas, + }; +} + +// Releasing one of two fingers cleared the held flag, and the recovery +// turned the view down under the finger still on the glass. +test('the recovery waits until every held pointer is released', () => { + const h = heldPointerRecovery(); + h.pointer(h.canvas, 'pointerdown', 1); + h.pointer(h.canvas, 'pointerdown', 2); + h.holdStill(); + h.pointer(h.document, 'pointerup', 1); + h.settle(); + assert.equal(h.recovered(), false, 'left alone under the second finger'); + assert.equal(globeOutOfFrame(h.camera), true); + h.pointer(h.document, 'pointercancel', 2); + h.settle(); + assert.equal(h.recovered(), true, 'recovered once both are released'); + h.dispose(); +}); + +// A release the page never sees (the pointer let go outside the window) +// must not hold the recovery off for good. +for (const [label, lose] of [ + ['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))], + [ + 'the page is hidden', + (h) => { + h.document.visibilityState = 'hidden'; + h.document.dispatchEvent(new Event('visibilitychange')); + }, + ], + [ + 'the canvas loses the pointer capture', + (h) => h.pointer(h.canvas, 'lostpointercapture', 1), + ], + [ + 'the pointer moves with no button down', + (h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }), + ], +]) { + test(`a held pointer whose release is lost stops deferring when ${label}`, () => { + const h = heldPointerRecovery(); + h.pointer(h.canvas, 'pointerdown', 1); + h.holdStill(); + h.pointer(h.document, 'pointermove', 1, { buttons: 1 }); + h.settle(); + assert.equal(h.recovered(), false, 'left alone while held'); + lose(h); + h.settle(); + assert.equal(h.recovered(), true, 'recovered once the hold is let go'); + h.dispose(); + }); +} + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index 12ef67269..fc75c0498 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -207,26 +207,43 @@ export function installGlobeInViewRecovery( throw new TypeError('A complete Cesium viewer is required'); // A pointer held on the globe is a gesture in progress, even when it holds - // still for half a second: the check waits for its release. + // still for half a second: the check waits until every pointer pressed on + // it (each finger of a pinch) is released. A release the page never sees, + // outside the window, is taken as given when the window loses focus, the + // page is hidden, the canvas loses the pointer's capture, or the pointer + // moves with no button down. const canvas = scene.canvas; const releaseTarget = canvas?.ownerDocument ?? canvas; - let pointerHeld = false; - const onPointerDown = () => { - pointerHeld = true; + const releaseWindow = canvas?.ownerDocument?.defaultView; + const heldPointers = new Set(); + const onPointerDown = (event) => heldPointers.add(event?.pointerId); + const onPointerUp = (event) => heldPointers.delete(event?.pointerId); + const onPointerMove = (event) => { + if (event?.buttons === 0) heldPointers.delete(event.pointerId); }; - const onPointerUp = () => { - pointerHeld = false; + const releaseAll = () => heldPointers.clear(); + const onVisibilityChange = () => { + if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll(); }; - canvas?.addEventListener?.('pointerdown', onPointerDown); - releaseTarget?.addEventListener?.('pointerup', onPointerUp); - releaseTarget?.addEventListener?.('pointercancel', onPointerUp); + const listeners = [ + [canvas, 'pointerdown', onPointerDown], + [canvas, 'lostpointercapture', onPointerUp], + [releaseTarget, 'pointerup', onPointerUp], + [releaseTarget, 'pointercancel', onPointerUp], + [releaseTarget, 'pointermove', onPointerMove], + [canvas?.ownerDocument, 'visibilitychange', onVisibilityChange], + [releaseWindow, 'blur', releaseAll], + ]; + for (const [target, type, listener] of listeners) + target?.addEventListener?.(type, listener); // Returns false while a flight, a paused scene or a held pointer defers the // check, so the caller can try again once it ends. const recover = () => { if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused() || pointerHeld) return false; + if (camera._currentFlight || paused() || heldPointers.size > 0) + return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || @@ -310,9 +327,9 @@ export function installGlobeInViewRecovery( stopSettling(); removeMoveEnd(); removeMoveStart(); - canvas?.removeEventListener?.('pointerdown', onPointerDown); - releaseTarget?.removeEventListener?.('pointerup', onPointerUp); - releaseTarget?.removeEventListener?.('pointercancel', onPointerUp); + for (const [target, type, listener] of listeners) + target?.removeEventListener?.(type, listener); + heldPointers.clear(); }; }