mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 12:23:59 +00:00
Share the stub's pointer routing as an engine-neutral input router
The stub's lease and press routing moves to src/render/input/router.js, unchanged, so a real renderer can feed it its own engine's events. The router also keeps the behaviour holds (telling the renderer when one turns off or on, unless it lacks it) and ctrlWheelZoom, and dispatch says who an event went to.
This commit is contained in:
1 parent
3837e453ca
commit
3e48e453d0
5 files changed
+370
-173
No files matched your search
@@ -5178,6 +5178,7 @@
|
||||
"src/render/stub/geodesic.js",
|
||||
"src/render/stub/viewRectangle.js",
|
||||
"src/render/stub/orbits.js",
|
||||
"src/render/input/router.js",
|
||||
"src/render/conformance/goldens/camera.json",
|
||||
"src/render/conformance/checks/camera.js"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @module render/input/router
|
||||
* @description Who hears each pointer event: the contract's input rules
|
||||
* (`src/render/contract/input.d.ts`) for any renderer. A renderer feeds it
|
||||
* the events its engine sees, already shaped as `PointerEv` (its clicks with
|
||||
* their pick); the router decides, from the leases and the presses under
|
||||
* way, which listeners get each one, and keeps the input port's leases and
|
||||
* behaviour holds. Engine-free.
|
||||
*/
|
||||
|
||||
const near = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) <= 4;
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {(error: unknown) => void} options.report Where a listener's or a
|
||||
* `grab`'s error goes.
|
||||
* @param {(reason: string) => () => void} options.suspendCamera The
|
||||
* renderer's counted camera-input hold.
|
||||
* @param {string[]} [options.behaviours] The stock behaviours the renderer
|
||||
* has, beyond `select` and `dblclickFollow` (always listed).
|
||||
* @param {(name: string, enabled: boolean) => void} [options.onBehaviour]
|
||||
* Told when a behaviour turns off (first hold) or back on (last release).
|
||||
* @param {(enabled: boolean) => void} [options.onCtrlWheelZoom] Told when
|
||||
* `ctrlWheelZoom` is set.
|
||||
* @param {Record<string, boolean>} [options.supported] Behaviours the
|
||||
* renderer cannot do, as `false`: listed, but reported disabled.
|
||||
*/
|
||||
export function createInputRouter({
|
||||
report,
|
||||
suspendCamera,
|
||||
behaviours: extra = [],
|
||||
onBehaviour = () => {},
|
||||
onCtrlWheelZoom = () => {},
|
||||
supported = {},
|
||||
}) {
|
||||
const listeners = [];
|
||||
let currentLease = null;
|
||||
|
||||
const holds = {};
|
||||
// The renderer hears a behaviour turn off and on only if it has it; its
|
||||
// error is reported, and the hold stands either way.
|
||||
const tell = (name, enabled) => {
|
||||
if (supported[name] === false) return;
|
||||
try {
|
||||
onBehaviour(name, enabled);
|
||||
} catch (error) {
|
||||
report(error);
|
||||
}
|
||||
};
|
||||
function makeBehaviour(name) {
|
||||
holds[name] = 0;
|
||||
return Object.freeze({
|
||||
get enabled() {
|
||||
return supported[name] !== false && holds[name] === 0;
|
||||
},
|
||||
suppress() {
|
||||
holds[name] += 1;
|
||||
if (holds[name] === 1) tell(name, false);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
holds[name] -= 1;
|
||||
if (holds[name] === 0) tell(name, true);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
const behaviours = Object.freeze(
|
||||
Object.fromEntries(
|
||||
['select', 'dblclickFollow', ...extra].map((name) => [
|
||||
name,
|
||||
makeBehaviour(name),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
/** Each lease's `grab`, or null when it takes every press. */
|
||||
const leaseGrab = new WeakMap();
|
||||
/**
|
||||
* The presses under way, by button, from their `down`: `owner` is the
|
||||
* lease each went to, or null when it went to everyone else. A press's
|
||||
* click and double-click go where its down went (an ended owner's to no
|
||||
* one).
|
||||
*/
|
||||
const presses = new Map();
|
||||
|
||||
const leaseOf = (entry) =>
|
||||
typeof entry.lease === 'function' ? entry.lease() : entry.lease;
|
||||
|
||||
let ctrlWheelZoom = false;
|
||||
const port = {
|
||||
on(type, cb, opts = {}) {
|
||||
const entry = { type, cb, lease: opts.lease ?? null, live: true };
|
||||
listeners.push(entry);
|
||||
return () => {
|
||||
entry.live = false;
|
||||
const i = listeners.indexOf(entry);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
};
|
||||
},
|
||||
claimPointer(owner, opts = {}) {
|
||||
if (currentLease) currentLease.release();
|
||||
let released = false;
|
||||
const grab = typeof opts.grab === 'function' ? opts.grab : null;
|
||||
const lease = {
|
||||
owner: String(owner),
|
||||
get current() {
|
||||
return !released && currentLease === lease;
|
||||
},
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (currentLease === lease) currentLease = null;
|
||||
},
|
||||
};
|
||||
currentLease = lease;
|
||||
leaseGrab.set(lease, grab);
|
||||
return lease;
|
||||
},
|
||||
isPointerFree: () => currentLease === null,
|
||||
suspendCameraControls: (reason) => suspendCamera(String(reason)),
|
||||
behaviours,
|
||||
get ctrlWheelZoom() {
|
||||
return ctrlWheelZoom;
|
||||
},
|
||||
set ctrlWheelZoom(value) {
|
||||
ctrlWheelZoom = Boolean(value);
|
||||
onCtrlWheelZoom(ctrlWheelZoom);
|
||||
},
|
||||
};
|
||||
|
||||
/** Who a new press belongs to, decided from its `down`. */
|
||||
function pressFor(down) {
|
||||
const lease = currentLease;
|
||||
if (!lease) return { owner: null, clicked: false };
|
||||
const grab = leaseGrab.get(lease);
|
||||
let grabbed = !grab;
|
||||
if (grab)
|
||||
try {
|
||||
grabbed = grab(down) === true;
|
||||
} catch (error) {
|
||||
report(error);
|
||||
}
|
||||
return { owner: grabbed ? lease : null, clicked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an event goes: to one lease's listeners (`lease`), to everyone
|
||||
* without a lease (`ambient`), or both (`move` and `up`).
|
||||
*/
|
||||
function recipients(ev) {
|
||||
const { type, button } = ev;
|
||||
// A click comes straight after its press's up, and a double-click
|
||||
// straight after that click, by the same button at the same spot;
|
||||
// anything else after the up ends the press.
|
||||
for (const [b, p] of presses) {
|
||||
if (!p.up) continue;
|
||||
const continues =
|
||||
b === button &&
|
||||
near(p.at, ev.position) &&
|
||||
((type === 'click' && !p.clicked) ||
|
||||
(type === 'dblclick' && p.clicked));
|
||||
if (!continues) presses.delete(b);
|
||||
}
|
||||
if (type === 'up' && presses.has(button)) {
|
||||
const p = presses.get(button);
|
||||
p.up = true;
|
||||
p.at = ev.position;
|
||||
}
|
||||
if (type === 'move' || type === 'up')
|
||||
return { lease: currentLease, ambient: true };
|
||||
if (type === 'wheel' || type === 'pinch') {
|
||||
const exclusive = currentLease && !leaseGrab.get(currentLease);
|
||||
return exclusive
|
||||
? { lease: currentLease, ambient: false }
|
||||
: { lease: null, ambient: true };
|
||||
}
|
||||
// `grab` sees a click with no press of its own as the down it stands
|
||||
// for: no pick, no gesture.
|
||||
const asDown = () => ({
|
||||
...pressFor({
|
||||
type: 'down',
|
||||
position: ev.position,
|
||||
button: ev.button,
|
||||
modifiers: ev.modifiers,
|
||||
timestamp: ev.timestamp,
|
||||
pointerType: ev.pointerType,
|
||||
}),
|
||||
up: true,
|
||||
});
|
||||
let current;
|
||||
if (type === 'down') {
|
||||
current = pressFor(ev);
|
||||
presses.set(button, current);
|
||||
} else {
|
||||
// Only a press the loop above kept, at the right step, continues;
|
||||
// anything else is a press of its own and leaves a held one alone.
|
||||
const held = presses.get(button);
|
||||
const continues =
|
||||
held?.up && (type === 'click' ? !held.clicked : held.clicked);
|
||||
current = continues ? held : asDown();
|
||||
if (type === 'click' && (continues || !held)) {
|
||||
current.clicked = true;
|
||||
presses.set(button, current);
|
||||
} else if (continues) presses.delete(button);
|
||||
}
|
||||
current.at = ev.position;
|
||||
return current.owner
|
||||
? { lease: current.owner, ambient: false }
|
||||
: { lease: null, ambient: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers one event to the listeners it belongs to.
|
||||
* @param {import('../contract/input').PointerEv} ev
|
||||
* @returns {{lease: object|null, ambient: boolean}} Who it went to, so a
|
||||
* renderer can leave a grabbed press out of its stock behaviours. A
|
||||
* `move` or `up` always reads as going to both, grabbed press or not.
|
||||
*/
|
||||
function dispatch(ev) {
|
||||
const to = recipients(ev);
|
||||
for (const entry of listeners.slice()) {
|
||||
if (!entry.live || entry.type !== ev.type) continue;
|
||||
if (entry.lease) {
|
||||
const lease = leaseOf(entry);
|
||||
if (!to.lease || lease !== to.lease || !lease.current) continue;
|
||||
} else if (!to.ambient) continue;
|
||||
try {
|
||||
entry.cb(ev);
|
||||
} catch (error) {
|
||||
report(error);
|
||||
}
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
dispatch,
|
||||
/** Live listeners, for leak checks. */
|
||||
listenerCount: () => listeners.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInputRouter } from './router.js';
|
||||
|
||||
const router = (options = {}) =>
|
||||
createInputRouter({
|
||||
report: (error) => {
|
||||
throw error;
|
||||
},
|
||||
suspendCamera: () => () => {},
|
||||
...options,
|
||||
});
|
||||
|
||||
const ev = (type, x = 10, y = 10, button = 0) => ({
|
||||
type,
|
||||
position: { x, y },
|
||||
button,
|
||||
modifiers: { shift: false, ctrl: false, alt: false, meta: false },
|
||||
timestamp: 0,
|
||||
pointerType: 'mouse',
|
||||
});
|
||||
|
||||
test('a behaviour turns off at its first hold and on at its last release', () => {
|
||||
const told = [];
|
||||
const { port } = router({
|
||||
onBehaviour: (name, enabled) => told.push([name, enabled]),
|
||||
});
|
||||
const a = port.behaviours.select.suppress();
|
||||
const b = port.behaviours.select.suppress();
|
||||
assert.equal(port.behaviours.select.enabled, false);
|
||||
a();
|
||||
a();
|
||||
assert.deepEqual(told, [['select', false]], 'one hold still out');
|
||||
b();
|
||||
assert.equal(port.behaviours.select.enabled, true);
|
||||
assert.deepEqual(told, [
|
||||
['select', false],
|
||||
['select', true],
|
||||
]);
|
||||
assert.equal(port.behaviours.dblclickFollow.enabled, true, 'separate');
|
||||
});
|
||||
|
||||
test('extra behaviours are listed; unsupported ones read disabled', () => {
|
||||
const { port } = router({
|
||||
behaviours: ['dblclickZoom'],
|
||||
supported: { dblclickFollow: false },
|
||||
});
|
||||
assert.deepEqual(Object.keys(port.behaviours), [
|
||||
'select',
|
||||
'dblclickFollow',
|
||||
'dblclickZoom',
|
||||
]);
|
||||
assert.equal(port.behaviours.dblclickFollow.enabled, false);
|
||||
assert.equal(port.behaviours.dblclickZoom.enabled, true);
|
||||
});
|
||||
|
||||
test('an unsupported behaviour is never told; a throwing hook is reported', () => {
|
||||
const told = [];
|
||||
const errors = [];
|
||||
const { port } = router({
|
||||
report: (error) => errors.push(error),
|
||||
supported: { dblclickFollow: false },
|
||||
onBehaviour: (name, enabled) => {
|
||||
told.push([name, enabled]);
|
||||
if (name === 'select') throw new Error('hook');
|
||||
},
|
||||
});
|
||||
port.behaviours.dblclickFollow.suppress()();
|
||||
assert.deepEqual(told, []);
|
||||
const release = port.behaviours.select.suppress();
|
||||
assert.equal(typeof release, 'function', 'the hold is still handed out');
|
||||
assert.equal(port.behaviours.select.enabled, false);
|
||||
release();
|
||||
assert.equal(port.behaviours.select.enabled, true);
|
||||
assert.equal(errors.length, 2);
|
||||
});
|
||||
|
||||
test('ctrlWheelZoom is told to the renderer', () => {
|
||||
const told = [];
|
||||
const { port } = router({ onCtrlWheelZoom: (on) => told.push(on) });
|
||||
assert.equal(port.ctrlWheelZoom, false);
|
||||
port.ctrlWheelZoom = 1;
|
||||
assert.equal(port.ctrlWheelZoom, true);
|
||||
assert.deepEqual(told, [true]);
|
||||
});
|
||||
|
||||
test('dispatch says who an event went to', () => {
|
||||
const { port, dispatch, listenerCount } = router();
|
||||
const off = port.on('down', () => {});
|
||||
assert.equal(listenerCount(), 1);
|
||||
assert.deepEqual(dispatch(ev('down')), { lease: null, ambient: true });
|
||||
dispatch(ev('up'));
|
||||
const lease = port.claimPointer('tool', {
|
||||
grab: (down) => down.button === 2,
|
||||
});
|
||||
assert.deepEqual(dispatch(ev('down')), { lease: null, ambient: true });
|
||||
dispatch(ev('up'));
|
||||
assert.deepEqual(dispatch(ev('down', 10, 10, 2)), {
|
||||
lease,
|
||||
ambient: false,
|
||||
});
|
||||
off();
|
||||
assert.equal(listenerCount(), 0);
|
||||
});
|
||||
|
||||
test('camera holds go to the renderer, reason as a string', () => {
|
||||
const reasons = [];
|
||||
const { port } = router({
|
||||
suspendCamera: (reason) => {
|
||||
reasons.push(reason);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
port.suspendCameraControls(7);
|
||||
assert.deepEqual(reasons, ['7']);
|
||||
});
|
||||
@@ -264,10 +264,10 @@ test('no runtime file is in a language the ratchet cannot read', () => {
|
||||
assert.deepEqual(unread, [], 'Teach the ratchet to read these first.');
|
||||
});
|
||||
|
||||
test('the renderer contract, stub and conformance suite stay Cesium-free', () => {
|
||||
test('the renderer contract, stub, input router and conformance suite stay Cesium-free', () => {
|
||||
const engineFree = tracked.filter(
|
||||
(file) =>
|
||||
/^src\/render\/(?:contract|stub|conformance)\//.test(file) &&
|
||||
/^src\/render\/(?:contract|stub|input|conformance)\//.test(file) &&
|
||||
/\.[cm]?js$/.test(file) &&
|
||||
!/\.test\.[cm]?js$/.test(file),
|
||||
);
|
||||
|
||||
+7
-171
@@ -9,6 +9,7 @@
|
||||
* Nothing runs on its own: `testing.tick()` and `testing.advance()` drive the
|
||||
* loop on a virtual clock, one frame at a time, in the contract's order.
|
||||
*/
|
||||
import { createInputRouter } from '../input/router.js';
|
||||
import { CAPABILITIES, CONTRACT_VERSION } from '../contract/index.js';
|
||||
import { createStubCamera } from './camera.js';
|
||||
import { motionPosition } from './motion.js';
|
||||
@@ -715,164 +716,11 @@ export function createStubRenderer(options = {}) {
|
||||
|
||||
// --- Input -----------------------------------------------------------------
|
||||
|
||||
const pointerListeners = [];
|
||||
let currentLease = null;
|
||||
const cameraHolds = new Map();
|
||||
const behaviourHolds = { select: 0, dblclickFollow: 0 };
|
||||
|
||||
function makeBehaviour(name) {
|
||||
return {
|
||||
get enabled() {
|
||||
return behaviourHolds[name] === 0;
|
||||
},
|
||||
suppress() {
|
||||
behaviourHolds[name] += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
behaviourHolds[name] -= 1;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Each lease's `grab`, or null when it takes every press. */
|
||||
const leaseGrab = new WeakMap();
|
||||
/**
|
||||
* The presses under way, by button, from their `down`: `owner` is the
|
||||
* lease each went to, or null when it went to everyone else. A press's
|
||||
* click and double-click go where its down went (an ended owner's to no
|
||||
* one).
|
||||
*/
|
||||
const presses = new Map();
|
||||
|
||||
const leaseOf = (entry) =>
|
||||
typeof entry.lease === 'function' ? entry.lease() : entry.lease;
|
||||
|
||||
const input = {
|
||||
on(type, cb, opts = {}) {
|
||||
const entry = { type, cb, lease: opts.lease ?? null, live: true };
|
||||
pointerListeners.push(entry);
|
||||
return () => {
|
||||
entry.live = false;
|
||||
const i = pointerListeners.indexOf(entry);
|
||||
if (i >= 0) pointerListeners.splice(i, 1);
|
||||
};
|
||||
},
|
||||
claimPointer(owner, opts = {}) {
|
||||
if (currentLease) currentLease.release();
|
||||
let released = false;
|
||||
const grab = typeof opts.grab === 'function' ? opts.grab : null;
|
||||
const lease = {
|
||||
owner: String(owner),
|
||||
get current() {
|
||||
return !released && currentLease === lease;
|
||||
},
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (currentLease === lease) currentLease = null;
|
||||
},
|
||||
};
|
||||
currentLease = lease;
|
||||
leaseGrab.set(lease, grab);
|
||||
return lease;
|
||||
},
|
||||
isPointerFree: () => currentLease === null,
|
||||
suspendCameraControls(reason) {
|
||||
return camera.port.controls.suspend(String(reason));
|
||||
},
|
||||
behaviours: {
|
||||
select: makeBehaviour('select'),
|
||||
dblclickFollow: makeBehaviour('dblclickFollow'),
|
||||
},
|
||||
ctrlWheelZoom: false,
|
||||
};
|
||||
|
||||
/** Who a new press belongs to, decided from its `down`. */
|
||||
function pressFor(down) {
|
||||
const lease = currentLease;
|
||||
if (!lease) return { owner: null, clicked: false };
|
||||
const grab = leaseGrab.get(lease);
|
||||
let grabbed = !grab;
|
||||
if (grab)
|
||||
try {
|
||||
grabbed = grab(down) === true;
|
||||
} catch (error) {
|
||||
report(error);
|
||||
}
|
||||
return { owner: grabbed ? lease : null, clicked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an event goes: to one lease's listeners (`lease`), to everyone
|
||||
* without a lease (`ambient`), or both (`move` and `up`).
|
||||
*/
|
||||
function recipients(type, ev) {
|
||||
const button = ev.button;
|
||||
// A click comes straight after its press's up, and a double-click
|
||||
// straight after that click, by the same button at the same spot;
|
||||
// anything else after the up ends the press.
|
||||
for (const [b, p] of presses) {
|
||||
if (!p.up) continue;
|
||||
const continues =
|
||||
b === button &&
|
||||
((type === 'click' &&
|
||||
!p.clicked &&
|
||||
Math.hypot(p.at.x - ev.position.x, p.at.y - ev.position.y) <= 4) ||
|
||||
(type === 'dblclick' &&
|
||||
p.clicked &&
|
||||
Math.hypot(p.at.x - ev.position.x, p.at.y - ev.position.y) <= 4));
|
||||
if (!continues) presses.delete(b);
|
||||
}
|
||||
if (type === 'up' && presses.has(button)) {
|
||||
const p = presses.get(button);
|
||||
p.up = true;
|
||||
p.at = ev.position;
|
||||
}
|
||||
if (type === 'move' || type === 'up')
|
||||
return { lease: currentLease, ambient: true };
|
||||
if (type === 'wheel' || type === 'pinch') {
|
||||
const exclusive = currentLease && !leaseGrab.get(currentLease);
|
||||
return exclusive
|
||||
? { lease: currentLease, ambient: false }
|
||||
: { lease: null, ambient: true };
|
||||
}
|
||||
// `grab` sees a click with no press of its own as the down it stands
|
||||
// for: no pick, no gesture.
|
||||
const asDown = () => ({
|
||||
...pressFor({
|
||||
type: 'down',
|
||||
position: ev.position,
|
||||
button: ev.button,
|
||||
modifiers: ev.modifiers,
|
||||
timestamp: ev.timestamp,
|
||||
pointerType: ev.pointerType,
|
||||
}),
|
||||
up: true,
|
||||
});
|
||||
let current;
|
||||
if (type === 'down') {
|
||||
current = pressFor(ev);
|
||||
presses.set(button, current);
|
||||
} else {
|
||||
// Only a press the loop above kept, at the right step, continues;
|
||||
// anything else is a press of its own and leaves a held one alone.
|
||||
const held = presses.get(button);
|
||||
const continues =
|
||||
held?.up && (type === 'click' ? !held.clicked : held.clicked);
|
||||
current = continues ? held : asDown();
|
||||
if (type === 'click' && (continues || !held)) {
|
||||
current.clicked = true;
|
||||
presses.set(button, current);
|
||||
} else if (continues) presses.delete(button);
|
||||
}
|
||||
current.at = ev.position;
|
||||
return current.owner
|
||||
? { lease: current.owner, ambient: false }
|
||||
: { lease: null, ambient: true };
|
||||
}
|
||||
const router = createInputRouter({
|
||||
report,
|
||||
suspendCamera: (reason) => camera.port.controls.suspend(reason),
|
||||
});
|
||||
const input = router.port;
|
||||
|
||||
function dispatchPointer(type, x, y, extra = {}) {
|
||||
let picked;
|
||||
@@ -900,19 +748,7 @@ export function createStubRenderer(options = {}) {
|
||||
opts.limit === undefined ? hits().slice() : hits().slice(0, opts.limit);
|
||||
ev.ground = () => rayEllipsoid(pickRay(x, y));
|
||||
}
|
||||
const to = recipients(type, ev);
|
||||
for (const entry of pointerListeners.slice()) {
|
||||
if (!entry.live || entry.type !== type) continue;
|
||||
if (entry.lease) {
|
||||
const lease = leaseOf(entry);
|
||||
if (!to.lease || lease !== to.lease || !lease.current) continue;
|
||||
} else if (!to.ambient) continue;
|
||||
try {
|
||||
entry.cb(ev);
|
||||
} catch (error) {
|
||||
report(error);
|
||||
}
|
||||
}
|
||||
router.dispatch(ev);
|
||||
requestRender('input');
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user