From 96bc73181b76bd2a7eff939ffafb717f563cc904 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 17:25:40 -0800 Subject: [PATCH] Give the Cesium camera port its user camera controls camera.controls now maps the contract's CameraControls onto the scene's ScreenSpaceCameraController: the enable flags, zoom distances, zoom factor, coast as Cesium inertia (decay seconds, 0 for no coast), the collision switch, the tilt limit as the upper end of pitchRange, and the zoom bindings by name. suspend and enabled use the scene's shared input holds, so they count with the input port's and the dispatcher's. A follow's zoom hold now composes with the settings: minDistance and coast.zoom read and set what the follow gives back, a higher floor takes effect at once, and a follow never lengthens a shorter zoom coast. The ctrl+wheel binding check reuses the controls' binding helpers, and the controls checks run in the Cesium conformance set, the settings one in Node as well. --- scripts/format-scope.json | 2 + scripts/package-boundaries.json | 33 +++ src/data/infrastructure.test.mjs | 1 + src/render/cesium/appRenderer.test.mjs | 15 -- src/render/cesium/camera.js | 8 +- src/render/cesium/cameraControls.js | 241 ++++++++++++++++++++++ src/render/cesium/cameraControls.test.mjs | 187 +++++++++++++++++ src/render/cesium/cameraGoldens.js | 4 +- src/render/cesium/cameraPort.test.mjs | 6 +- src/render/cesium/ctrlWheelZoom.js | 11 +- src/render/cesium/follow.js | 53 ++++- src/render/cesium/harness.js | 2 + src/render/conformance/checks/camera.js | 47 +++++ 13 files changed, 574 insertions(+), 36 deletions(-) create mode 100644 src/render/cesium/cameraControls.js create mode 100644 src/render/cesium/cameraControls.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9b0fb6489..604624f27 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -54,6 +54,8 @@ "src/render/cesium/input.js", "src/render/cesium/input.test.mjs", "src/render/cesium/cameraInput.test.mjs", + "src/render/cesium/cameraControls.js", + "src/render/cesium/cameraControls.test.mjs", "src/render/input/router.js", "src/render/input/router.test.mjs", "src/render/cesium/internals.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b13050fda..9b46f62c1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -9,6 +9,7 @@ "src/data/infrastructure.js", "src/data/localGeojsonCore.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/render/cesium/follow.js", @@ -871,6 +872,7 @@ "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/render/cesium/follow.js", @@ -1003,6 +1005,7 @@ "src/render/cesium/effects.js", "src/data/followed.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/render/cesium/follow.js", @@ -1271,6 +1274,7 @@ "src/scenes/director.js", "src/data/followed.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/data/entityRecords.js", @@ -1415,6 +1419,7 @@ "src/layers/flights/policy.js", "src/layers/flights/queries.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/data/followed.js", @@ -1508,6 +1513,7 @@ "src/layers/military/policy.js", "src/layers/military/queries.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/geo/index.js", @@ -1657,6 +1663,7 @@ "src/render/rendererOf.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", + "src/render/cesium/cameraControls.js", "src/render/input/router.js" ], "external": ["cesium"] @@ -1752,6 +1759,7 @@ "src/render/cesium/common.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", + "src/render/cesium/cameraControls.js", "src/render/input/router.js" ], "external": ["cesium"] @@ -1780,6 +1788,7 @@ "src/layers/satellites/testing.js", "src/layers/satellites/tracking.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/data/inputOwnership.js", @@ -1828,6 +1837,7 @@ "src/layers/launches/controls.js", "src/layers/launches/index.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/data/entityRecords.js", @@ -1894,6 +1904,7 @@ "src/data/installationNames.js", "src/layers/installations/controls.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/render/cesium/follow.js", @@ -1977,6 +1988,8 @@ "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", + "src/render/cesium/cameraInput.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/render/cesium/follow.js", @@ -2126,6 +2139,7 @@ "src/render/cesium/common.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", + "src/render/cesium/cameraControls.js", "src/render/input/router.js", "src/queries/heights.js" ], @@ -2164,6 +2178,7 @@ "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/render/cesium/follow.js", @@ -2239,6 +2254,7 @@ "src/layers/streetLevel/viewerHost.js", "src/layers/streetLevel/cameraFraming.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/follow.js", "src/render/rendererOf.js", "src/render/camera/events.js", @@ -2328,6 +2344,7 @@ "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/render/cesium/follow.js", @@ -2403,6 +2420,7 @@ "src/layers/radio/navigation.js", "src/data/followed.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/data/entityRecords.js", @@ -2463,6 +2481,7 @@ "src/geo/index.js", "src/layers/submarineCables/interaction.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/render/cesium/follow.js", @@ -2548,7 +2567,9 @@ "src/maps/catalog.js", "src/maps/controller.js", "src/render/cesium/credits.js", + "src/render/cesium/cameraInput.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/camera/events.js", "src/geo/celestial.js", "src/geo/quaternion.js", @@ -2765,6 +2786,7 @@ "src/render/cesium/effects.js", "src/render/cesium/common.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/follow.js", "src/render/camera/events.js", "src/render/contract/index.js", @@ -3740,6 +3762,7 @@ "src/data/bhoteKoshiEmbeddedMedia.js", "src/data/bhoteKoshiLocator.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/follow.js", "src/data/bhoteKoshiIncidentPlaces.js", "src/data/bhoteKoshiFloodPath.js", @@ -4388,6 +4411,8 @@ "src/layers/firms/anchors.js", "src/locations.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", + "src/render/cesium/cameraInput.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/data/entityRecords.js", @@ -4473,6 +4498,7 @@ "src/render/cesium/common.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", + "src/render/cesium/cameraControls.js", "src/render/input/router.js", "src/input/sensorPick.js", "src/styles/styleStages.js", @@ -4494,6 +4520,8 @@ "src/geo/index.js", "src/ui/navigationController.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", + "src/render/cesium/cameraInput.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/data/followed.js", @@ -4760,6 +4788,7 @@ "src/render/cesium/internals.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", + "src/render/cesium/cameraControls.js", "src/render/input/router.js" ], "external": ["cesium"] @@ -5022,6 +5051,7 @@ "src/cyberSonar.js", "src/celestialRing.js", "src/render/cesium/camera.js", + "src/render/cesium/cameraControls.js", "src/render/cesium/credits.js", "src/render/creditMarkup.js", "src/render/cesium/follow.js", @@ -5174,6 +5204,7 @@ "src/render/cesium/common.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", + "src/render/cesium/cameraControls.js", "src/render/input/router.js", "src/queries/heights.js", "src/render/cesium/internals.js" @@ -5250,6 +5281,7 @@ "src/render/cesium/internals.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", + "src/render/cesium/cameraControls.js", "src/render/input/router.js" ], "external": ["cesium"] @@ -5361,6 +5393,7 @@ "src/data/entityRecords.js", "src/render/cesium/input.js", "src/render/cesium/ctrlWheelZoom.js", + "src/render/cesium/cameraControls.js", "src/render/input/router.js", "src/queries/heights.js" ], diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index bf5248fde..36ee961f7 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -187,6 +187,7 @@ test('consumer build includes only infrastructure code and resolves assets under const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ 'camera.js', + 'cameraControls.js', 'cameraInput.js', 'celestial.js', 'common.js', diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index a759145c5..1665e11f7 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -318,21 +318,6 @@ test('the application refuses a renderer it cannot run on, and destroys it', asy }); const EXPECTED_MISSING = [ - 'camera.controls', - 'camera.controls.coast', - 'camera.controls.coast.drag', - 'camera.controls.coast.zoom', - 'camera.controls.collision', - 'camera.controls.enableRotate', - 'camera.controls.enableTilt', - 'camera.controls.enableZoom', - 'camera.controls.enabled', - 'camera.controls.maxDistance', - 'camera.controls.minDistance', - 'camera.controls.pitchRange', - 'camera.controls.suspend', - 'camera.controls.zoomFactor', - 'camera.controls.zoomInputs', 'draw.atlas', 'draw.clampState', 'draw.on', diff --git a/src/render/cesium/camera.js b/src/render/cesium/camera.js index 5c6974660..c550ae564 100644 --- a/src/render/cesium/camera.js +++ b/src/render/cesium/camera.js @@ -2,13 +2,15 @@ * @module render/cesium/camera * @description The Cesium renderer's camera port over a Cesium camera: * poses, views, flights, `lookAt`, events and command hooks, each mapped - * onto Cesium's own camera call. + * onto Cesium's own camera call, and the user's camera controls + * (`cameraControls.js`) on the scene's camera controller. */ import * as Cesium from 'cesium'; import { createCameraEvents } from '../camera/events.js'; import { listenerList, toCartesian, uncaught } from './common.js'; import { cameraFlightActive } from './internals.js'; import { createCesiumFollow } from './follow.js'; +import { createCesiumControls } from './cameraControls.js'; import { cameraOf, findCamera, provideUnboundCamera } from '../rendererOf.js'; // Loads the factory an unbound viewer gets its credits port from. import './credits.js'; @@ -110,6 +112,8 @@ export function createCesiumCamera({ scene, camera, report, widget }) { }) : null; + const controlsPart = createCesiumControls({ scene }); + // Camera commands: hooks run before each one, so an owner can yield. const commandHooks = listenerList(report); const command = (kind) => commandHooks.fire({ kind }); @@ -287,6 +291,7 @@ export function createCesiumCamera({ scene, camera, report, widget }) { return following?.followFramed ?? false; }, followOffset: following?.followOffset ?? NO_FOLLOW_OFFSET, + controls: controlsPart.controls, release() { command('release'); camera.lookAtTransform(Cesium.Matrix4.IDENTITY); @@ -375,6 +380,7 @@ export function createCesiumCamera({ scene, camera, report, widget }) { port: cameraPort, dispose() { following?.dispose(); + controlsPart.dispose(); for (const off of removers.splice(0)) off(); cameraEvents.clear(); commandHooks.clear(); diff --git a/src/render/cesium/cameraControls.js b/src/render/cesium/cameraControls.js new file mode 100644 index 000000000..9591e4b60 --- /dev/null +++ b/src/render/cesium/cameraControls.js @@ -0,0 +1,241 @@ +/** + * @module render/cesium/cameraControls + * @description The Cesium camera port's `controls`: the contract's user + * camera settings mapped onto the scene's ScreenSpaceCameraController. + * + * - `suspend` and `enabled` are the scene's shared input holds + * (`cameraInput.js`), so a hold taken here, by the input port or by the + * dispatcher is counted with the rest and released on its own. + * - `coast` is Cesium's inertia: its speed falls as exp(-25 (1 - c) t), so a + * coast of s seconds is c = 1 - 1 / (25 s), and a coast of 0 is c = 1, + * which Cesium takes as no inertia at all. `drag` is `inertiaSpin`, which + * moves and tilts the globe in 3D (and `inertiaTranslate`, its pan in 2D); + * `zoom` is `inertiaZoom`. + * - `minDistance` and `coast.zoom` are the controller's own values: while a + * follow raises the zoom floor and stops zoom coasting, they read and set + * what the follow gives back when it ends. + * - `collision` is one switch in Cesium, `enableCollisionDetection`: the + * eye stays above the terrain and above every tileset that has its own + * `enableCollision` on. Any setting but 'none' turns it on, and reads + * back as set. + * - `pitchRange`'s upper end is Cesium's `maximumTiltAngle` (measured from + * straight down, so pitch + π/2); Cesium stops a user tilt short of + * straight down on its own, and has no other lower bound, so the lower + * end is kept and read back but not enforced. + * - `zoomInputs` are the controller's `zoomEventTypes`; a binding the + * contract has no name for is kept as it is when they are set. + */ +import * as Cesium from 'cesium'; +import { acquireCameraInput } from './cameraInput.js'; +import { followZoomBase, setFollowZoomBase } from './follow.js'; + +const HALF_PI = Math.PI / 2; +const { WHEEL, PINCH, RIGHT_DRAG } = Cesium.CameraEventType; +const { CTRL } = Cesium.KeyboardEventModifier; + +/** The contract's zoom inputs as Cesium bindings. */ +const ZOOM_BINDINGS = Object.freeze({ + wheel: WHEEL, + pinch: PINCH, + rightDrag: RIGHT_DRAG, + ctrlWheel: Object.freeze({ eventType: WHEEL, modifier: CTRL }), +}); + +/** A Cesium zoom binding's contract name, or null when it has none. */ +export function zoomInputName(binding) { + const type = binding?.eventType ?? binding; + const modifier = binding?.modifier; + if (modifier === undefined) { + if (type === WHEEL) return 'wheel'; + if (type === PINCH) return 'pinch'; + if (type === RIGHT_DRAG) return 'rightDrag'; + return null; + } + return type === WHEEL && modifier === CTRL ? 'ctrlWheel' : null; +} + +/** Cesium's event-type setting, which may be one binding, as a list. */ +export const bindingList = (types) => + Array.isArray(types) ? types : types === undefined ? [] : [types]; + +/** Coast seconds as a Cesium inertia coefficient, and back. */ +export const coastToInertia = (seconds) => + seconds > 0 ? 1 - 1 / (25 * seconds) : 1; +export const inertiaToCoast = (c) => + Number.isFinite(c) && c < 1 ? 1 / (25 * (1 - c)) : 0; + +/** + * Cesium's controller defaults, for a scene that has no controller of its + * own: the settings then only read back. Also a headless scene's controller. + */ +export const controllerDefaults = () => ({ + enableTilt: true, + enableRotate: true, + enableZoom: true, + inertiaSpin: 0.9, + inertiaTranslate: 0.9, + inertiaZoom: 0.8, + minimumZoomDistance: 1, + maximumZoomDistance: Number.POSITIVE_INFINITY, + zoomFactor: 5, + zoomEventTypes: [RIGHT_DRAG, WHEEL, PINCH], + enableCollisionDetection: true, + maximumTiltAngle: undefined, +}); + +/** + * @param {{scene: {screenSpaceCameraController?: object}}} options + * @returns {{controls: import('../contract/camera').CameraControls, + * dispose: () => void}} + */ +export function createCesiumControls({ scene }) { + const ownController = scene.screenSpaceCameraController; + const controller = ownController ?? controllerDefaults(); + + // The shared holds, taken on first use: until then the controller's + // `enableInputs` is left as it is. + let cameraInput = null; + let disposed = false; + const input = () => { + if (!cameraInput && !disposed) cameraInput = acquireCameraInput(scene); + return cameraInput; + }; + + let collision = controller.enableCollisionDetection ? 'both' : 'none'; + // The pitch range as last set, read back while the controller still has + // the tilt limit it was given. + let pitchSet = null; + + const controls = { + // Read without taking over `enableInputs`: once any user holds input, + // the controller's own property counts every hold. + get enabled() { + if (ownController && !cameraInput) + return ownController.enableInputs !== false; + const held = input(); + if (held && !held.enabled) return false; + return !ownController || ownController.enableInputs !== false; + }, + suspend(reason) { + return input()?.suspend(reason) ?? (() => {}); + }, + get enableTilt() { + return Boolean(controller.enableTilt); + }, + set enableTilt(value) { + controller.enableTilt = Boolean(value); + }, + get enableRotate() { + return Boolean(controller.enableRotate); + }, + set enableRotate(value) { + controller.enableRotate = Boolean(value); + }, + get enableZoom() { + return Boolean(controller.enableZoom); + }, + set enableZoom(value) { + controller.enableZoom = Boolean(value); + }, + get minDistance() { + return followZoomBase(controller, 'minimumZoomDistance'); + }, + set minDistance(value) { + setFollowZoomBase(controller, 'minimumZoomDistance', value); + }, + get maxDistance() { + return controller.maximumZoomDistance; + }, + set maxDistance(value) { + controller.maximumZoomDistance = value; + }, + get coast() { + return Object.freeze({ + drag: inertiaToCoast(controller.inertiaSpin), + zoom: inertiaToCoast(followZoomBase(controller, 'inertiaZoom')), + }); + }, + set coast(value) { + for (const key of ['drag', 'zoom']) + if (!(Number.isFinite(value?.[key]) && value[key] >= 0)) + throw new TypeError(`coast.${key} must be seconds, 0 or more`); + const drag = coastToInertia(value.drag); + controller.inertiaSpin = drag; + controller.inertiaTranslate = drag; + setFollowZoomBase(controller, 'inertiaZoom', coastToInertia(value.zoom)); + }, + get collision() { + return controller.enableCollisionDetection + ? collision === 'none' + ? 'both' + : collision + : 'none'; + }, + set collision(value) { + if (!['none', 'terrain', 'tiles', 'both'].includes(value)) + throw new TypeError(`no collision setting '${value}'`); + collision = value; + controller.enableCollisionDetection = value !== 'none'; + }, + get zoomFactor() { + return controller.zoomFactor; + }, + set zoomFactor(value) { + controller.zoomFactor = value; + }, + get pitchRange() { + const tilt = controller.maximumTiltAngle; + if (pitchSet && pitchSet.tilt === tilt) return [...pitchSet.range]; + const max = + tilt === undefined ? HALF_PI : Math.min(HALF_PI, tilt - HALF_PI); + const min = Math.min(pitchSet?.range[0] ?? -HALF_PI, max); + return [Math.max(-HALF_PI, min), Math.max(-HALF_PI, max)]; + }, + set pitchRange(value) { + const [min, max] = Array.isArray(value) ? value : []; + if (!(Number.isFinite(min) && Number.isFinite(max) && min <= max)) + throw new RangeError('pitchRange must be [min, max] radians'); + const range = [ + Math.min(HALF_PI, Math.max(-HALF_PI, min)), + Math.min(HALF_PI, Math.max(-HALF_PI, max)), + ]; + const tilt = range[1] >= HALF_PI ? undefined : range[1] + HALF_PI; + controller.maximumTiltAngle = tilt; + pitchSet = { range, tilt }; + }, + get zoomInputs() { + const names = []; + for (const binding of bindingList(controller.zoomEventTypes)) { + const name = zoomInputName(binding); + if (name && !names.includes(name)) names.push(name); + } + return names; + }, + set zoomInputs(value) { + if (!Array.isArray(value)) + throw new TypeError('zoomInputs must be an array'); + for (const name of value) + if (!Object.hasOwn(ZOOM_BINDINGS, name)) + throw new TypeError(`no zoom input '${name}'`); + const kept = bindingList(controller.zoomEventTypes).filter( + (binding) => zoomInputName(binding) === null, + ); + controller.zoomEventTypes = [ + ...kept, + ...[...new Set(value)].map((name) => { + const binding = ZOOM_BINDINGS[name]; + return typeof binding === 'object' ? { ...binding } : binding; + }), + ]; + }, + }; + + return { + controls, + dispose() { + disposed = true; + cameraInput?.release(); + cameraInput = null; + }, + }; +} diff --git a/src/render/cesium/cameraControls.test.mjs b/src/render/cesium/cameraControls.test.mjs new file mode 100644 index 000000000..cba118031 --- /dev/null +++ b/src/render/cesium/cameraControls.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { acquireCameraInput } from './cameraInput.js'; +import { + coastToInertia, + controllerDefaults, + createCesiumControls, + inertiaToCoast, +} from './cameraControls.js'; +import { holdFollowZoom } from './follow.js'; + +const { WHEEL, PINCH, RIGHT_DRAG, LEFT_DRAG } = Cesium.CameraEventType; +const { CTRL, SHIFT } = Cesium.KeyboardEventModifier; + +/** A controller with Cesium's defaults. */ +const sceneWithController = () => ({ + screenSpaceCameraController: { ...controllerDefaults(), enableInputs: true }, +}); + +test('settings read and write the controller', () => { + const scene = sceneWithController(); + const c = scene.screenSpaceCameraController; + const { controls, dispose } = createCesiumControls({ scene }); + assert.equal(controls.enableTilt, true); + controls.enableTilt = false; + controls.enableRotate = false; + controls.enableZoom = false; + assert.deepEqual( + [c.enableTilt, c.enableRotate, c.enableZoom], + [false, false, false], + ); + assert.equal(controls.minDistance, 1); + assert.equal(controls.maxDistance, Infinity); + controls.minDistance = 20; + controls.maxDistance = 4e7; + assert.deepEqual([c.minimumZoomDistance, c.maximumZoomDistance], [20, 4e7]); + assert.equal(controls.zoomFactor, 5); + controls.zoomFactor = 15; + assert.equal(c.zoomFactor, 15); + dispose(); +}); + +test('coast is Cesium inertia as decay seconds', () => { + const scene = sceneWithController(); + const c = scene.screenSpaceCameraController; + const { controls } = createCesiumControls({ scene }); + assert.ok(Math.abs(controls.coast.drag - 0.4) < 1e-9, 'spin 0.9 is 0.4 s'); + assert.ok(Math.abs(controls.coast.zoom - 0.2) < 1e-9, 'zoom 0.8 is 0.2 s'); + assert.ok(Object.isFrozen(controls.coast)); + controls.coast = { drag: 0.25, zoom: 0 }; + assert.ok(Math.abs(c.inertiaSpin - 0.84) < 1e-12); + assert.equal(c.inertiaTranslate, c.inertiaSpin); + assert.equal(c.inertiaZoom, 1, 'a coast of 0 is no inertia'); + assert.equal(controls.coast.zoom, 0); + assert.throws(() => (controls.coast = { drag: -1, zoom: 0 }), TypeError); + assert.throws(() => (controls.coast = { drag: 1 }), TypeError); + for (const s of [0.01, 0.2, 0.4, 3]) + assert.ok(Math.abs(inertiaToCoast(coastToInertia(s)) - s) < 1e-12); +}); + +test('a follow’s zoom hold composes with the zoom settings', () => { + const scene = sceneWithController(); + const c = scene.screenSpaceCameraController; + const { controls } = createCesiumControls({ scene }); + const owner = {}; + const release = holdFollowZoom(c, owner, 150); + assert.equal(c.minimumZoomDistance, 150); + assert.equal(controls.minDistance, 1, 'reads the controller’s own floor'); + assert.ok(Math.abs(controls.coast.zoom - 0.2) < 1e-9); + controls.minDistance = 500; + assert.equal(c.minimumZoomDistance, 500, 'a higher floor applies at once'); + controls.minDistance = 30; + assert.equal(c.minimumZoomDistance, 150, 'the follow’s floor still holds'); + controls.coast = { drag: 0.4, zoom: 0.5 }; + assert.equal(c.inertiaZoom, 0, 'zoom coasting stays off while following'); + controls.coast = { drag: 0.4, zoom: 0 }; + assert.equal(c.inertiaZoom, 1, 'a coast of 0 is not lengthened by a follow'); + controls.coast = { drag: 0.4, zoom: 0.5 }; + release(); + assert.equal(c.minimumZoomDistance, 30, 'the follow gives back the new one'); + assert.ok(Math.abs(controls.coast.zoom - 0.5) < 1e-9); +}); + +test('collision is the controller’s collision switch', () => { + const scene = sceneWithController(); + const c = scene.screenSpaceCameraController; + const { controls } = createCesiumControls({ scene }); + assert.equal(controls.collision, 'both'); + controls.collision = 'none'; + assert.equal(c.enableCollisionDetection, false); + assert.equal(controls.collision, 'none'); + controls.collision = 'terrain'; + assert.equal(c.enableCollisionDetection, true); + assert.equal(controls.collision, 'terrain'); + c.enableCollisionDetection = false; + assert.equal(controls.collision, 'none', 'follows a direct write'); + assert.throws(() => (controls.collision = 'water'), TypeError); +}); + +test('pitchRange keeps its upper end as the tilt limit', () => { + const scene = sceneWithController(); + const c = scene.screenSpaceCameraController; + const { controls } = createCesiumControls({ scene }); + assert.deepEqual(controls.pitchRange, [-Math.PI / 2, Math.PI / 2]); + controls.pitchRange = [-1.2, -0.1]; + assert.deepEqual(controls.pitchRange, [-1.2, -0.1]); + assert.ok(Math.abs(c.maximumTiltAngle - (Math.PI / 2 - 0.1)) < 1e-12); + controls.pitchRange = [-Math.PI / 2, Math.PI / 2]; + assert.equal(c.maximumTiltAngle, undefined, 'unrestricted again'); + c.maximumTiltAngle = Math.PI / 2; + assert.deepEqual(controls.pitchRange, [-Math.PI / 2, 0], 'a direct write'); + assert.throws(() => (controls.pitchRange = [0.5, 0.1]), RangeError); + assert.throws(() => (controls.pitchRange = [NaN, 0]), RangeError); +}); + +test('zoomInputs name the zoom bindings and keep unnamed ones', () => { + const scene = sceneWithController(); + const c = scene.screenSpaceCameraController; + const { controls } = createCesiumControls({ scene }); + assert.deepEqual(controls.zoomInputs, ['rightDrag', 'wheel', 'pinch']); + const odd = { eventType: LEFT_DRAG, modifier: SHIFT }; + c.zoomEventTypes = [...c.zoomEventTypes, odd]; + controls.zoomInputs = ['wheel', 'ctrlWheel', 'wheel']; + assert.deepEqual(c.zoomEventTypes, [ + odd, + WHEEL, + { eventType: WHEEL, modifier: CTRL }, + ]); + assert.deepEqual(controls.zoomInputs, ['wheel', 'ctrlWheel']); + c.zoomEventTypes = PINCH; + assert.deepEqual(controls.zoomInputs, ['pinch'], 'a single binding'); + c.zoomEventTypes = undefined; + assert.deepEqual(controls.zoomInputs, []); + assert.throws(() => (controls.zoomInputs = ['scroll']), TypeError); +}); + +test('suspend shares the scene’s holds with other users', () => { + const scene = sceneWithController(); + const c = scene.screenSpaceCameraController; + const { controls, dispose } = createCesiumControls({ scene }); + const other = acquireCameraInput(scene); + assert.equal(controls.enabled, true); + const drag = other.suspend('drag'); + assert.equal(controls.enabled, false, 'another user’s hold counts'); + const mine = controls.suspend('cockpit'); + drag(); + assert.equal(controls.enabled, false); + assert.equal(c.enableInputs, false); + mine(); + mine(); + assert.equal(controls.enabled, true); + // Cesium's own flights write enableInputs off and on. + c.enableInputs = false; + assert.equal(controls.enabled, false, 'off while Cesium has input off'); + c.enableInputs = true; + controls.suspend('left out'); + dispose(); + assert.equal(other.enabled, true, 'dispose gives back this port’s holds'); + other.release(); + assert.equal(c.enableInputs, true); +}); + +test('reading enabled leaves the controller’s enableInputs alone', () => { + const scene = sceneWithController(); + const c = scene.screenSpaceCameraController; + const { controls } = createCesiumControls({ scene }); + assert.equal(controls.enabled, true); + assert.equal( + Object.getOwnPropertyDescriptor(c, 'enableInputs').value, + true, + 'still a plain property', + ); + c.enableInputs = false; + assert.equal(controls.enabled, false); +}); + +test('a scene without a controller still has settings', () => { + const { controls } = createCesiumControls({ scene: {} }); + assert.equal(controls.enabled, true); + const release = controls.suspend('x'); + assert.equal(controls.enabled, false); + release(); + controls.zoomFactor = 9; + assert.equal(controls.zoomFactor, 9); + assert.deepEqual(controls.zoomInputs, ['rightDrag', 'wheel', 'pinch']); +}); diff --git a/src/render/cesium/cameraGoldens.js b/src/render/cesium/cameraGoldens.js index a69ffc68f..1197540d9 100644 --- a/src/render/cesium/cameraGoldens.js +++ b/src/render/cesium/cameraGoldens.js @@ -18,6 +18,7 @@ import { } from '../conformance/cameraScripts.js'; import { runCameraScript } from '../conformance/cameraRunner.js'; import { updateCameraChanged } from './internals.js'; +import { controllerDefaults } from './cameraControls.js'; /** * The contract's easings as Cesium's, kept here rather than taken from the @@ -66,8 +67,7 @@ export function headlessCamera() { globe: undefined, ellipsoid: Cesium.Ellipsoid.WGS84, screenSpaceCameraController: { - minimumZoomDistance: 1, - maximumZoomDistance: Number.POSITIVE_INFINITY, + ...controllerDefaults(), enableInputs: true, }, frameState: { mode: Cesium.SceneMode.SCENE3D }, diff --git a/src/render/cesium/cameraPort.test.mjs b/src/render/cesium/cameraPort.test.mjs index efd9f73a9..01e8524d6 100644 --- a/src/render/cesium/cameraPort.test.mjs +++ b/src/render/cesium/cameraPort.test.mjs @@ -36,7 +36,11 @@ const runnable = CAMERA_SCRIPTS.filter((script) => ), ).map((script) => `camera.trace.${script.name}`); -const CHECKS = new Set([...runnable, 'camera.flight.callbacks']); +const CHECKS = new Set([ + ...runnable, + 'camera.flight.callbacks', + 'camera.controls.settings', +]); function headlessHarness(clock) { return { diff --git a/src/render/cesium/ctrlWheelZoom.js b/src/render/cesium/ctrlWheelZoom.js index 54ac3197a..e2927010e 100644 --- a/src/render/cesium/ctrlWheelZoom.js +++ b/src/render/cesium/ctrlWheelZoom.js @@ -5,6 +5,7 @@ * pixel-mode Ctrl+wheel events) relayed at a usable speed. */ import * as Cesium from 'cesium'; +import { bindingList, zoomInputName } from './cameraControls.js'; const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -32,15 +33,9 @@ export function installTrackpadPinchZoom( throw new TypeError('A complete Cesium viewer is required'); const originalZoomEventTypes = controller.zoomEventTypes; - const zoomEventTypes = Array.isArray(originalZoomEventTypes) - ? originalZoomEventTypes - : originalZoomEventTypes === undefined - ? [] - : [originalZoomEventTypes]; + const zoomEventTypes = bindingList(originalZoomEventTypes); const alreadyHandlesControlWheel = zoomEventTypes.some( - (binding) => - binding?.eventType === Cesium.CameraEventType.WHEEL && - binding?.modifier === Cesium.KeyboardEventModifier.CTRL, + (binding) => zoomInputName(binding) === 'ctrlWheel', ); const configuredZoomEventTypes = alreadyHandlesControlWheel ? originalZoomEventTypes diff --git a/src/render/cesium/follow.js b/src/render/cesium/follow.js index e979daf77..7a570b553 100644 --- a/src/render/cesium/follow.js +++ b/src/render/cesium/follow.js @@ -59,19 +59,12 @@ export function holdFollowZoom(controller, owner, minRange) { ZOOM_STATES.set(controller, state); } state.owners.set(owner, minRange); - const apply = (current) => { - controller.inertiaZoom = 0; - controller.minimumZoomDistance = Math.max( - current.minimumZoomDistance, - ...current.owners.values(), - ); - }; - apply(state); + applyFollowZoom(controller, state); return () => { const current = ZOOM_STATES.get(controller); if (!current?.owners.delete(owner)) return; if (current.owners.size > 0) { - apply(current); + applyFollowZoom(controller, current); return; } controller.inertiaZoom = current.inertiaZoom; @@ -80,6 +73,48 @@ export function holdFollowZoom(controller, owner, minRange) { }; } +/** + * The controller's own zoom coast or zoom floor: while a follow holds them, + * the value it gives back on release, not the one in force. + * @param {object} controller A ScreenSpaceCameraController. + * @param {'inertiaZoom'|'minimumZoomDistance'} key + * @returns {number} + */ +export function followZoomBase(controller, key) { + const state = ZOOM_STATES.get(controller); + return state ? state[key] : controller[key]; +} + +/** + * Set the controller's own zoom coast or zoom floor. While a follow holds + * them it is what the follow gives back on release, and a zoom floor above + * the follow's holds takes effect at once. + * @param {object} controller A ScreenSpaceCameraController. + * @param {'inertiaZoom'|'minimumZoomDistance'} key + * @param {number} value + */ +export function setFollowZoomBase(controller, key, value) { + const state = ZOOM_STATES.get(controller); + if (!state) { + controller[key] = value; + return; + } + state[key] = value; + applyFollowZoom(controller, state); +} + +function applyFollowZoom(controller, state) { + // The hold's coast is Cesium's shortest coefficient, 0; a controller of + // its own already coasting less (1 or more is no coast, under 0 is faster + // decay) keeps its own. + const own = state.inertiaZoom; + controller.inertiaZoom = own >= 1 || own < 0 ? own : 0; + controller.minimumZoomDistance = Math.max( + state.minimumZoomDistance, + ...state.owners.values(), + ); +} + /** * Keep a follow-frame eye on the same side of its target and at least * `minimumRangeM` from it, in front and in all. diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 2c50b1b5d..6390c7a62 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -23,6 +23,8 @@ export const CESIUM_CHECKS = new Set([ 'camera.trace.commands inside a lookAt frame', 'camera.trace.changed fires by each listener’s threshold', 'camera.moveEnd', + 'camera.controls.holds', + 'camera.controls.settings', 'camera.follow', 'camera.trace.flyTo with a duration of 0 completes inside the call', 'camera.trace.flyTo where the camera already is completes inside the call', diff --git a/src/render/conformance/checks/camera.js b/src/render/conformance/checks/camera.js index fb71848d2..72f34fcc1 100644 --- a/src/render/conformance/checks/camera.js +++ b/src/render/conformance/checks/camera.js @@ -265,6 +265,53 @@ export function cameraChecks() { r.destroy(); }, }, + { + id: 'camera.controls.settings', + port: 'camera', + title: 'camera control settings read back as they were set', + async run(h) { + const r = await fresh(h); + const c = r.camera.controls; + for (const key of ['enableTilt', 'enableRotate', 'enableZoom']) { + equal(typeof c[key], 'boolean', `${key} is a boolean`); + c[key] = false; + equal(c[key], false, `${key} turns off`); + c[key] = true; + equal(c[key], true, `${key} turns back on`); + } + ok( + 0 <= c.minDistance && c.minDistance <= c.maxDistance, + 'the zoom distances are an ordered range', + ); + c.minDistance = 25; + c.maxDistance = 3e7; + equal(c.minDistance, 25, 'minDistance is settable'); + equal(c.maxDistance, 3e7, 'maxDistance is settable'); + ok(c.zoomFactor > 0, 'zoomFactor is positive'); + c.zoomFactor = 12; + equal(c.zoomFactor, 12, 'zoomFactor is settable'); + ok( + ['none', 'terrain', 'tiles', 'both'].includes(c.collision), + 'collision is a known setting', + ); + for (const setting of ['none', 'terrain', 'tiles', 'both']) { + c.collision = setting; + equal(c.collision, setting, `collision ${setting} reads back`); + } + const inputs = ['wheel', 'pinch', 'rightDrag', 'ctrlWheel']; + ok( + c.zoomInputs.every((name) => inputs.includes(name)), + 'zoomInputs are known bindings', + ); + c.zoomInputs = ['wheel', 'ctrlWheel']; + deepEqual( + [...c.zoomInputs].sort(), + ['ctrlWheel', 'wheel'], + 'zoomInputs are settable', + ); + r.destroy(); + }, + }, { id: 'camera.onCommand', port: 'camera',