Share a scene's camera-input holds beyond the dispatcher

The take-over of the camera controller's enableInputs moves, unchanged,
to src/render/cesium/cameraInput.js. acquireCameraInput(scene) gives every
user one set of counted holds per scene, so the renderer's input and
camera ports can hold camera input alongside the dispatcher; the
controller gets its own property back when the last user releases. The
dispatcher acquires its share once its handler is bound. The input guard
names the one module allowed to write the flag, and the new render files
join the format scope.
This commit is contained in:
Sameh Khamis committed 2026-11-05 18:29:38 -08:00
1 parent 468590485b
commit 60a3a2808e
7 files changed
+237 -77

No files matched your search

+4
View File
@@ -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",
+38 -13
View File
@@ -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"]
+1
View File
@@ -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',
+8 -2
View File
@@ -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, []);
});
+11 -62
View File
@@ -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<object>} 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();
+119
View File
@@ -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<object, {holds: Set<object>, 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<object>} 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);
},
};
}
+56
View File
@@ -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();
});