diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 49c2353a2..4b5ae815f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -49,6 +49,10 @@ "src/input/dispatcher.js", "src/queries/heights.js", "src/render/cesium/internals.js", + "src/render/cesium/cameraInput.js", + "src/render/cesium/cameraInput.test.mjs", + "src/render/input/router.js", + "src/render/input/router.test.mjs", "src/render/cesium/internals.test.mjs", "src/render/cesium/tileRequests.js", "src/render/cesium/tileRequests.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f70201771..8077a3ceb 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -13,6 +13,7 @@ "src/data/pickRegistry.js", "src/data/scenePick.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/input/sensorPick.js", "src/data/infrastructureOverlayEntry.js", "src/sources/infrastructureData.js", @@ -882,7 +883,8 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/sensorPick.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium"] }, @@ -1140,7 +1142,8 @@ "src/data/inputOwnership.js", "src/data/pickRegistry.js", "src/input/sensorPick.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1278,7 +1281,8 @@ "src/input/sensorPick.js", "src/data/scenePick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -1371,6 +1375,7 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/render/cesium/internals.js" ], "external": ["cesium"] @@ -1442,7 +1447,8 @@ "src/data/scenePick.js", "src/input/sensorPick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium"] }, @@ -1526,7 +1532,8 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/sensorPick.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium"] }, @@ -1612,7 +1619,8 @@ "src/input/sensorPick.js", "src/data/scenePick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium"] }, @@ -1656,7 +1664,8 @@ "src/input/sensorPick.js", "src/data/scenePick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium", "satellite.js"] }, @@ -1700,7 +1709,8 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/sensorPick.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium"] }, @@ -1757,7 +1767,8 @@ "src/input/sensorPick.js", "src/data/scenePick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1904,6 +1915,7 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/queries/heights.js" ], "external": ["cesium"] @@ -1973,7 +1985,8 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/sensorPick.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["hls.js", "cesium"] }, @@ -2051,6 +2064,7 @@ "src/input/sensorPick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/queries/heights.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] @@ -2095,6 +2109,7 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/queries/heights.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] @@ -2148,6 +2163,7 @@ "src/input/sensorPick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/render/cesium/internals.js" ], "external": ["cesium"] @@ -2184,7 +2200,8 @@ "src/data/scenePick.js", "src/input/sensorPick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium"] }, @@ -3344,6 +3361,7 @@ "src/data/entityRecords.js", "src/input/sensorPick.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/queries/heights.js", "src/render/cesium/internals.js", "src/render/cesium/tileRequests.js" @@ -3972,6 +3990,7 @@ "src/data/entityRecords.js", "src/input/sensorPick.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/queries/heights.js", "src/render/cesium/tileRequests.js", "src/render/cesium/internals.js" @@ -4083,6 +4102,7 @@ "exports": ["./input"], "modules": [ "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/input/sensorPick.js", "src/data/inputOwnership.js", "src/data/pickRegistry.js", @@ -4340,7 +4360,8 @@ "src/data/scenePick.js", "src/input/sensorPick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium"] }, @@ -4672,6 +4693,7 @@ "src/i18n/locales/zh-Hant.json", "src/input/sensorPick.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/queries/heights.js", "src/render/cesium/internals.js" ], @@ -4730,6 +4752,7 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/queries/heights.js", "src/render/cesium/internals.js" ], @@ -4797,7 +4820,8 @@ "src/input/sensorPick.js", "src/data/scenePick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js" ], "external": ["cesium"] }, @@ -4898,6 +4922,7 @@ "src/input/sensorPick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", + "src/render/cesium/cameraInput.js", "src/queries/heights.js" ], "external": ["cesium"] diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index fa5e9c283..21f93b9a4 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -186,6 +186,7 @@ test('consumer build includes only infrastructure code and resolves assets under const entry = output.find((item) => item.type === 'chunk' && item.isEntry); const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js')); assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [ + 'cameraInput.js', 'dispatcher.js', 'heights.js', 'infrastructure.js', diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 4c75ac7f8..6f4018b4c 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -115,18 +115,23 @@ test('pointer input, camera holds and the stock clicks go through the one dispat // edit each layer. A layer that binds its own handler, writes the camera // flag or takes the stock clicks itself steps around that contract. const DISPATCHER = 'src/input/dispatcher.js'; + // Each rule names the one other module it belongs in, if any. const rules = [ [ /new\s+[\w.]*ScreenSpaceEventHandler\(/, 'binds its own ScreenSpaceEventHandler', + [], ], [ /\.enableInputs\s*=(?!=)/, 'writes enableInputs instead of suspendCamera()', + // The scene's camera-input holds, which own the flag. + ['src/render/cesium/cameraInput.js'], ], [ /screenSpaceEventHandler\??\.removeInputAction\(/, 'removes stock clicks instead of suppressStockClicks()', + [], ], ]; const walk = (dir) => @@ -141,8 +146,9 @@ test('pointer input, camera holds and the stock clicks go through the one dispat for (const file of walk('src')) { if (file === DISPATCHER) continue; const source = read(file); - for (const [pattern, what] of rules) - if (pattern.test(source)) offenders.push(`${file} ${what}`); + for (const [pattern, what, allowed] of rules) + if (!allowed.includes(file) && pattern.test(source)) + offenders.push(`${file} ${what}`); } assert.deepEqual(offenders, []); }); diff --git a/src/input/dispatcher.js b/src/input/dispatcher.js index 39a908f6c..0c3c2df82 100644 --- a/src/input/dispatcher.js +++ b/src/input/dispatcher.js @@ -22,18 +22,17 @@ * free still ends (and gives its camera hold back). * - Tool subscribers pass their lease and run only while it is current. * - * Camera input is suspended by holds, not by a flag: every suspender gets its - * own release, and input comes back when the last one releases. The - * dispatcher owns the controller's `enableInputs`: what others write to it - * (Cesium's own camera flights turn it off and back on) is kept, and it reads - * true only while that is true and no hold is out, so a flight that ends can - * no longer re-enable input under a drag or the cockpit. + * Camera input is suspended by the scene's counted holds + * (src/render/cesium/cameraInput.js), which own the controller's + * `enableInputs`, so a camera flight that ends cannot re-enable input under + * a drag or the cockpit. * * A dispatcher is made per scene on first use ({@link inputFor}) and lives * until {@link InputDispatcher.destroy}; the application scene owns it. */ import * as Cesium from 'cesium'; import { isLeaseCurrent, isPointerFree } from '../data/inputOwnership.js'; +import { acquireCameraInput } from '../render/cesium/cameraInput.js'; import { findPickOwner, pickContact, @@ -129,49 +128,6 @@ export function inputFor(viewer, options = {}) { return dispatcher; } -/** - * Take over the controller's `enableInputs`: what others write to it is kept - * (through an accessor already there, if any), and it reads true only while - * that is true and no hold is out. - * @param {object|undefined} controller - * @param {Set} holds - * @returns {() => void} Gives the controller its own property back. - */ -function ownCameraInput(controller, holds) { - if (!controller) return () => {}; - const own = Object.getOwnPropertyDescriptor(controller, 'enableInputs'); - if (own && !own.configurable) { - console.warn('Camera input holds are unavailable: enableInputs is fixed'); - return () => {}; - } - // A class may define it on its prototype; the hold shadows that for a while. - let original = own; - for (let proto = Object.getPrototypeOf(controller); !original && proto;) { - original = Object.getOwnPropertyDescriptor(proto, 'enableInputs'); - proto = Object.getPrototypeOf(proto); - } - original ??= { value: true, writable: true, enumerable: true }; - let written = Boolean(original.get ? true : original.value); - const read = () => - original.get ? Boolean(original.get.call(controller)) : written; - Object.defineProperty(controller, 'enableInputs', { - configurable: true, - enumerable: original.enumerable ?? true, - get: () => read() && holds.size === 0, - set: (value) => { - written = Boolean(value); - original.set?.call(controller, value); - }, - }); - return () => { - delete controller.enableInputs; - if (own?.get || own?.set) - Object.defineProperty(controller, 'enableInputs', own); - else if (own || !(original.get || original.set)) - controller.enableInputs = written; - }; -} - function createDispatcher( viewer, { @@ -181,12 +137,6 @@ function createDispatcher( }, ) { const scene = viewer.scene; - const cameraHolds = new Set(); - // Before the handler, so a controller that cannot be owned leaks nothing. - const releaseCameraInput = ownCameraInput( - scene.screenSpaceCameraController, - cameraHolds, - ); const handler = createHandler(scene.canvas); /** @type {Array<{fn: Function}>} */ const clickSubscribers = []; @@ -276,6 +226,10 @@ function createDispatcher( run(pointerSubscribers.dblclick, isPointerFree(), event); }, eventTypes.LEFT_DOUBLE_CLICK); + // Once the handler is bound, so a setup that throws holds no share of the + // scene's camera input. + const cameraInput = acquireCameraInput(scene); + const subscribe = (list, entry) => { if (destroyed) return () => {}; list.push(entry); @@ -314,11 +268,7 @@ function createDispatcher( suspendCamera(reason) { if (destroyed) return () => {}; - const hold = { reason }; - cameraHolds.add(hold); - return () => { - cameraHolds.delete(hold); - }; + return cameraInput.suspend(reason); }, suppressStockClicks() { @@ -356,8 +306,7 @@ function createDispatcher( dispatchers.delete(scene); clickSubscribers.length = 0; for (const type of POINTER_TYPES) pointerSubscribers[type].length = 0; - cameraHolds.clear(); - releaseCameraInput(); + cameraInput.release(); if (stockHolds.size) { stockHolds.clear(); restoreStock(); diff --git a/src/render/cesium/cameraInput.js b/src/render/cesium/cameraInput.js new file mode 100644 index 000000000..ef70c1e91 --- /dev/null +++ b/src/render/cesium/cameraInput.js @@ -0,0 +1,119 @@ +/** + * @module render/cesium/cameraInput + * @description User camera input on a Cesium scene, suspended by counted + * holds. Every suspender gets its own release, and input comes back when the + * last one releases. + * + * The holds own the controller's `enableInputs`: what others write to it + * (Cesium's own camera flights turn it off and back on) is kept, and it reads + * true only while that is true and no hold is out, so a flight that ends can + * no longer re-enable input under a drag or the cockpit. + * + * One set of holds per scene, shared by everyone who acquires it (the input + * port, the camera port, the application's dispatcher); the controller gets + * its own property back when the last of them releases. + */ + +/** @type {WeakMap, users: number, restore: () => void}>} */ +const shared = new WeakMap(); + +/** + * Take over the controller's `enableInputs`: what others write to it is kept + * (through an accessor already there, if any), and it reads true only while + * that is true and no hold is out. + * @param {object|undefined} controller + * @param {Set} holds + * @returns {() => void} Gives the controller its own property back. + */ +function ownCameraInput(controller, holds) { + if (!controller) return () => {}; + const own = Object.getOwnPropertyDescriptor(controller, 'enableInputs'); + if (own && !own.configurable) { + console.warn('Camera input holds are unavailable: enableInputs is fixed'); + return () => {}; + } + // A class may define it on its prototype; the hold shadows that for a while. + let original = own; + for (let proto = Object.getPrototypeOf(controller); !original && proto;) { + original = Object.getOwnPropertyDescriptor(proto, 'enableInputs'); + proto = Object.getPrototypeOf(proto); + } + original ??= { value: true, writable: true, enumerable: true }; + let written = Boolean(original.get ? true : original.value); + const read = () => + original.get ? Boolean(original.get.call(controller)) : written; + Object.defineProperty(controller, 'enableInputs', { + configurable: true, + enumerable: original.enumerable ?? true, + get: () => read() && holds.size === 0, + set: (value) => { + written = Boolean(value); + original.set?.call(controller, value); + }, + }); + return () => { + delete controller.enableInputs; + if (own?.get || own?.set) + Object.defineProperty(controller, 'enableInputs', own); + else if (own || !(original.get || original.set)) + controller.enableInputs = written; + }; +} + +/** + * A user of the scene's camera-input holds. + * @typedef {object} CameraInput + * @property {(reason: string) => () => void} suspend Hold camera input off; + * returns this hold's release (a second call does nothing). + * @property {boolean} enabled False while any hold, anyone's, is out. + * @property {() => string[]} reasons The reasons of every hold out. + * @property {() => void} release Give back this user's holds, and the + * controller's property once no user is left. + */ + +/** + * @param {{screenSpaceCameraController?: object}} scene + * @returns {CameraInput} + */ +export function acquireCameraInput(scene) { + let state = shared.get(scene); + if (!state) { + const holds = new Set(); + state = { + holds, + users: 0, + restore: ownCameraInput(scene.screenSpaceCameraController, holds), + }; + shared.set(scene, state); + } + state.users += 1; + const s = state; + const mine = new Set(); + let released = false; + return { + suspend(reason) { + if (released) return () => {}; + const hold = { reason: String(reason) }; + s.holds.add(hold); + mine.add(hold); + return () => { + if (!mine.delete(hold)) return; + s.holds.delete(hold); + }; + }, + get enabled() { + return s.holds.size === 0; + }, + reasons: () => [...s.holds].map((hold) => hold.reason), + release() { + if (released) return; + released = true; + for (const hold of mine) s.holds.delete(hold); + mine.clear(); + s.users -= 1; + if (s.users > 0) return; + s.restore(); + if (shared.get(scene) === s) shared.delete(scene); + }, + }; +} diff --git a/src/render/cesium/cameraInput.test.mjs b/src/render/cesium/cameraInput.test.mjs new file mode 100644 index 000000000..1a31939eb --- /dev/null +++ b/src/render/cesium/cameraInput.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { acquireCameraInput } from './cameraInput.js'; + +test('users of one scene share its holds', () => { + const controller = { enableInputs: true }; + const scene = { screenSpaceCameraController: controller }; + const a = acquireCameraInput(scene); + const b = acquireCameraInput(scene); + const drag = a.suspend('drag'); + assert.equal(controller.enableInputs, false); + assert.equal(b.enabled, false, 'b sees a’s hold'); + assert.deepEqual(b.reasons(), ['drag']); + const flight = b.suspend(7); + assert.deepEqual(a.reasons(), ['drag', '7']); + drag(); + assert.equal(controller.enableInputs, false, 'b’s hold is still out'); + a.release(); + assert.equal(controller.enableInputs, false, 'b still holds'); + flight(); + assert.equal(controller.enableInputs, true); +}); + +test('releasing a user gives back its holds, and the property with the last', () => { + const controller = { enableInputs: true }; + const scene = { screenSpaceCameraController: controller }; + const a = acquireCameraInput(scene); + const b = acquireCameraInput(scene); + a.suspend('drag'); + b.suspend('cockpit'); + a.release(); + assert.deepEqual(b.reasons(), ['cockpit']); + assert.equal( + typeof Object.getOwnPropertyDescriptor(controller, 'enableInputs').get, + 'function', + 'still owned while b is a user', + ); + b.release(); + assert.deepEqual( + Object.getOwnPropertyDescriptor(controller, 'enableInputs'), + { + value: true, + writable: true, + enumerable: true, + configurable: true, + }, + ); + assert.deepEqual( + a.suspend('late')(), + undefined, + 'a released user holds nothing', + ); + const c = acquireCameraInput(scene); + assert.equal(c.enabled, true, 'a new user starts afresh'); + c.release(); +});