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',