diff --git a/scripts/format-scope.json b/scripts/format-scope.json index a708c6b74..9b0fb6489 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -51,6 +51,8 @@ "src/render/cesium/internals.js", "src/render/cesium/cameraInput.js", "src/render/cesium/ctrlWheelZoom.js", + "src/render/cesium/input.js", + "src/render/cesium/input.test.mjs", "src/render/cesium/cameraInput.test.mjs", "src/render/input/router.js", "src/render/input/router.test.mjs", diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 6f4018b4c..dc9849a9f 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -120,7 +120,8 @@ test('pointer input, camera holds and the stock clicks go through the one dispat [ /new\s+[\w.]*ScreenSpaceEventHandler\(/, 'binds its own ScreenSpaceEventHandler', - [], + // The Cesium renderer's input port, the renderer's one handler. + ['src/render/cesium/input.js'], ], [ /\.enableInputs\s*=(?!=)/, @@ -129,9 +130,10 @@ test('pointer input, camera holds and the stock clicks go through the one dispat ['src/render/cesium/cameraInput.js'], ], [ - /screenSpaceEventHandler\??\.removeInputAction\(/, + /(?:screenSpaceEventHandler|stockHandler)\??\.removeInputAction\(/, 'removes stock clicks instead of suppressStockClicks()', - [], + // It runs the stock clicks itself. + ['src/render/cesium/input.js'], ], ]; const walk = (dir) => diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 71b64f88d..1a0dc4b27 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -10,7 +10,7 @@ */ import { DEFAULT_TOLERANCE } from '../conformance/index.js'; import { toEcef } from '../stub/math.js'; -import { createCesiumSceneRenderer } from './sceneRenderer.js'; +import { createCesiumSceneRenderer, pointerFeedOf } from './sceneRenderer.js'; /** @type {ReadonlySet} */ export const CESIUM_CHECKS = new Set([ @@ -26,6 +26,11 @@ export const CESIUM_CHECKS = new Set([ 'frame.suspend', 'frame.time', 'globe.sky', + 'input.behaviours', + 'input.clickPick', + 'input.lease', + 'input.lease.grab', + 'input.lease.press', 'query.heights', 'query.heights.sets', 'query.projectMany', @@ -94,6 +99,9 @@ export function createCesiumHarness({ name = 'cesium', model } = {}) { return renderer; }, frame: nextFrame, + // Events as the renderer's handler delivers them: the checks send ones + // no real pointer can (a click with no press), so not DOM events. + pointer: (r, type, x, y, extra) => pointerFeedOf(r)(type, x, y, extra), now: () => performance.now(), toEcef, tolerance: DEFAULT_TOLERANCE, diff --git a/src/render/cesium/input.js b/src/render/cesium/input.js new file mode 100644 index 000000000..dcaf265c1 --- /dev/null +++ b/src/render/cesium/input.js @@ -0,0 +1,416 @@ +/** + * @module render/cesium/input + * @description The Cesium renderer's input port + * (`src/render/contract/input.d.ts`): one `ScreenSpaceEventHandler` over the + * canvas turns Cesium's pointer events into the contract's, and the shared + * router (`src/render/input/router.js`) decides who hears each. + * + * - Presses, releases, clicks and moves come from the handler, for every + * button and with any one modifier held (Cesium raises an action only for + * the modifier it was bound with). The DOM event under each one, seen + * first on the document, gives the full modifiers and the pointer type. + * - Wheel comes from the canvas's own `wheel` event, which this never + * cancels: the page keeps its Ctrl+wheel zoom unless `ctrlWheelZoom` is + * on. Pinch comes from the handler's two-finger events. + * - A click is picked lazily and once, through the query port, and shared + * by every subscriber. + * - The viewer's stock select (left click) and follow (double-click) are + * taken off its handler and run from here, ahead of the subscribers as + * they ran on it, only for a press that goes to them (no lease took it) + * and while the behaviour is not suppressed. A widget without them reports them + * disabled. + * - Camera input is held off through the scene's shared holds + * (`cameraInput.js`). + */ +import * as Cesium from 'cesium'; +import { createInputRouter } from '../input/router.js'; +import { acquireCameraInput } from './cameraInput.js'; +import { installTrackpadPinchZoom } from './ctrlWheelZoom.js'; + +const T = Cesium.ScreenSpaceEventType; +const MODIFIERS = [ + undefined, + Cesium.KeyboardEventModifier.SHIFT, + Cesium.KeyboardEventModifier.CTRL, + Cesium.KeyboardEventModifier.ALT, +]; +const BUTTONS = ['LEFT', 'MIDDLE', 'RIGHT']; +/** The stock behaviours, by the handler action they are. */ +const STOCK = { select: T.LEFT_CLICK, dblclickFollow: T.LEFT_DOUBLE_CLICK }; +/** The DOM events Cesium's handler reads, in either input model. */ +const DOM_EVENTS = [ + 'pointerdown', + 'pointermove', + 'pointerup', + 'pointercancel', + 'mousedown', + 'mousemove', + 'mouseup', + 'touchstart', + 'touchmove', + 'touchend', + 'touchcancel', + 'dblclick', +]; +/** A wheel notch scrolls 100 px in Chrome, which it counts as three lines. */ +const PX_PER_LINE = 100 / 3; +/** A drill is run at least this deep and sliced, so asks share one pass. */ +const DRILL_DEPTH = 16; +const NO_MODIFIERS = Object.freeze({ + shift: false, + ctrl: false, + alt: false, + meta: false, +}); +const POINTER_TYPES = new Set(['mouse', 'touch', 'pen']); +/** The one modifier Cesium raised an action for, when no DOM event says. */ +const CESIUM_MODIFIER = { + [Cesium.KeyboardEventModifier.SHIFT]: { shift: true }, + [Cesium.KeyboardEventModifier.CTRL]: { ctrl: true }, + [Cesium.KeyboardEventModifier.ALT]: { alt: true }, +}; + +const xy = (p) => ({ x: p.x, y: p.y }); +const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); + +const modifiersOf = (dom) => + dom + ? { + shift: Boolean(dom.shiftKey), + ctrl: Boolean(dom.ctrlKey), + alt: Boolean(dom.altKey), + meta: Boolean(dom.metaKey), + } + : { ...NO_MODIFIERS }; + +/** A DOM event's pointer type; a `dblclick`, which has none, takes `otherwise`. */ +function pointerTypeOf(dom, otherwise = 'mouse') { + if (POINTER_TYPES.has(dom?.pointerType)) return dom.pointerType; + if (dom?.type?.startsWith('touch')) return 'touch'; + if (dom?.type?.startsWith('mouse')) return 'mouse'; + return otherwise; +} + +/** + * @param {object} widget A Cesium `CesiumWidget` or `Viewer`. + * @param {object} options + * @param {object} options.query The renderer's query port: clicks pick + * through it. + * @param {(error: unknown) => void} options.report Where a listener's or a + * `grab`'s error goes. + * @param {(canvas: object) => object} [options.createHandler] + * @param {() => number} [options.now] Event time, ms. + * @param {object} [options.document] Where the DOM events are watched. + * @param {typeof installTrackpadPinchZoom} [options.ctrlWheelZoom] + * @returns {{port: object, feed: Function, listenerCount: () => number, dispose: () => void}} + * `feed(type, x, y, extra)` delivers one event as the handler would, for + * the conformance harness. + */ +export function createCesiumInput( + widget, + { + query, + report, + createHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), + now = () => performance.now(), + document: dom = globalThis.document, + ctrlWheelZoom = installTrackpadPinchZoom, + }, +) { + const scene = widget.scene; + const canvas = scene.canvas; + let disposed = false; + + // The viewer's stock actions, now this port's to run. + const stockHandler = widget.screenSpaceEventHandler; + const stock = {}; + for (const [name, type] of Object.entries(STOCK)) { + const action = stockHandler?.getInputAction?.(type); + if (!action) continue; + stock[name] = action; + stockHandler.removeInputAction(type); + } + + const cameraInput = acquireCameraInput(scene); + let uninstallCtrlWheel = null; + const router = createInputRouter({ + report, + suspendCamera: (reason) => cameraInput.suspend(reason), + supported: { + select: Boolean(stock.select), + dblclickFollow: Boolean(stock.dblclickFollow), + }, + onCtrlWheelZoom(on) { + if (disposed) return; + if (on && !uninstallCtrlWheel) uninstallCtrlWheel = ctrlWheelZoom(widget); + else if (!on && uninstallCtrlWheel) { + uninstallCtrlWheel(); + uninstallCtrlWheel = null; + } + }, + }); + const { behaviours } = router.port; + + // The DOM event the handler is answering, seen before it on the document, + // for as long as it is being dispatched: an action Cesium raises later, on + // a timer (a touch held for a right click), has none. The pointer type of + // the last one stays for those. + let lastDom = null; + let lastPointerType = 'mouse'; + let clearing = false; + // A task after this one: every event seen before it has been dispatched. + const clear = () => { + clearing = false; + lastDom = null; + }; + const record = (event) => { + lastDom = event; + lastPointerType = pointerTypeOf(event, lastPointerType); + if (clearing) return; + clearing = true; + setTimeout(clear, 0); + }; + for (const type of DOM_EVENTS) + dom?.addEventListener?.(type, record, { capture: true, passive: true }); + + /** Presses under way by button, for each click's gesture. */ + const pressing = new Map(); + /** Each button's last finished press, until its click reads it. */ + const finished = new Map(); + /** The button of the last event, when it was an up. */ + let lastUp = null; + const heldButton = () => { + for (const button of [0, 1, 2]) if (pressing.has(button)) return button; + return 0; + }; + + function track(type, position, endPosition, button) { + const at = now(); + if (type === 'down') { + pressing.set(button, { start: at, last: position, travel: 0 }); + finished.delete(button); + } else if (type === 'move') { + for (const press of pressing.values()) { + press.travel += distance(press.last, endPosition); + press.last = endPosition; + } + } else if (type === 'up') { + const press = pressing.get(button); + if (!press) return; + press.travel += distance(press.last, position); + pressing.delete(button); + finished.set(button, { + travelPx: press.travel, + durationMs: Math.max(0, at - press.start), + }); + } + } + + /** A click's lazy picks, each made at most once. */ + function withPicks(ev) { + const { x, y } = ev.position; + let hit; + let picked = false; + let ground; + let grounded = false; + /** @type {Map} */ + const drills = new Map(); + ev.pick = () => { + if (!picked) { + picked = true; + hit = query.pick(x, y) ?? null; + } + return hit; + }; + ev.drill = (options = {}) => { + const { limit, ...window } = options; + const want = limit ?? Infinity; + const key = `${window.width ?? ''}x${window.height ?? ''}:${window.purpose ?? ''}`; + let pass = drills.get(key); + // A pass that found fewer than it asked for found everything. + if (!pass || (pass.hits.length >= pass.limit && want > pass.limit)) { + const depth = + want === Infinity ? Infinity : Math.max(want, DRILL_DEPTH); + pass = { + limit: depth, + hits: query.drillPick(x, y, { + ...window, + ...(depth === Infinity ? {} : { limit: depth }), + }), + }; + drills.set(key, pass); + } + return want === Infinity ? pass.hits.slice() : pass.hits.slice(0, want); + }; + ev.ground = () => { + if (!grounded) { + grounded = true; + ground = query.pickPosition(x, y) ?? query.pickEllipsoid(x, y); + } + return ground; + }; + return ev; + } + + /** + * One event into the router, shaped as the contract's. + * @param {string} type + * @param {{x: number, y: number}} position + * @param {object} [extra] `button`, `endPosition`, `delta`, and, from the + * feed, `modifiers`, `pointerType` and `gesture`. + * @param {object|null} [source] The DOM event under it. + */ + function deliver(type, position, extra = {}, source = lastDom) { + if (disposed) return; + // Cesium ends every held button on any button's release, each with its + // click: the others' presses end there, as they do for its camera, but + // only the button released clicks. + const released = source && /(?:pointer|mouse)up$/.test(source.type); + if (type === 'click' && released && source.button !== extra.button) { + finished.delete(extra.button); + return; + } + const at = xy(position); + const end = extra.endPosition ? xy(extra.endPosition) : undefined; + const button = extra.button ?? heldButton(); + // A move with no end is where the pointer is now. + track(type, at, end ?? at, button); + const justUp = lastUp; + lastUp = type === 'up' ? button : null; + const ev = { + type, + position: at, + button, + modifiers: extra.modifiers + ? { ...NO_MODIFIERS, ...extra.modifiers } + : source + ? modifiersOf(source) + : { ...NO_MODIFIERS, ...CESIUM_MODIFIER[extra.modifier] }, + timestamp: now(), + pointerType: extra.pointerType ?? pointerTypeOf(source, lastPointerType), + ...(end ? { endPosition: end } : {}), + ...(extra.delta !== undefined ? { delta: extra.delta } : {}), + }; + if (type === 'click' || type === 'dblclick') { + // A click ends the press whose up came straight before it; one + // Cesium raises on a timer (a touch held) ends none. + const press = + type === 'click' && justUp === button + ? finished.get(button) + : undefined; + finished.delete(button); + ev.gesture = extra.gesture ?? press ?? { travelPx: 0, durationMs: 0 }; + withPicks(ev); + } + // The viewer's own select and follow run first, as they did on its + // handler. + router.dispatch(ev, (to) => runStock(ev, to)); + } + + /** The viewer's own select or follow, as it would have run. */ + function runStock(ev, to) { + const name = + ev.type === 'click' + ? 'select' + : ev.type === 'dblclick' + ? 'dblclickFollow' + : null; + if (!name || !stock[name] || ev.button !== 0 || !to.ambient) return; + // Cesium bound them with no modifier: a modified click never ran them. + const { shift, ctrl, alt } = ev.modifiers; + if (shift || ctrl || alt || !behaviours[name].enabled) return; + try { + stock[name]({ + position: new Cesium.Cartesian2(ev.position.x, ev.position.y), + }); + } catch (error) { + report(error); + } + } + + const handler = createHandler(canvas); + const bind = (type, action) => { + for (const modifier of MODIFIERS) + handler.setInputAction( + (event) => action(event, modifier), + type, + modifier, + ); + }; + BUTTONS.forEach((name, button) => { + bind(T[`${name}_DOWN`], (e, modifier) => + deliver('down', e.position, { button, modifier }), + ); + bind(T[`${name}_UP`], (e, modifier) => + deliver('up', e.position, { button, modifier }), + ); + bind(T[`${name}_CLICK`], (e, modifier) => + deliver('click', e.position, { button, modifier }), + ); + }); + bind(T.LEFT_DOUBLE_CLICK, (e, modifier) => + deliver('dblclick', e.position, { button: 0, modifier }), + ); + bind(T.MOUSE_MOVE, (e, modifier) => + deliver('move', e.startPosition, { endPosition: e.endPosition, modifier }), + ); + // Pinch is at the two fingers' midpoint where it started; its delta is + // the scale since the last event. + let pinchAt = null; + bind(T.PINCH_START, (e) => { + pinchAt = { + x: (e.position1.x + e.position2.x) / 2, + y: (e.position1.y + e.position2.y) / 2, + }; + }); + bind(T.PINCH_MOVE, (e) => { + const from = e.distance.startPosition.y; + const to = e.distance.endPosition.y; + if (!(from > 0) || !(to > 0) || !pinchAt) return; + deliver('pinch', pinchAt, { delta: to / from }); + }); + bind(T.PINCH_END, () => { + pinchAt = null; + }); + + const onWheel = (event) => { + const rect = canvas.getBoundingClientRect?.() ?? { left: 0, top: 0 }; + const lines = + event.deltaMode === 1 + ? event.deltaY + : event.deltaMode === 2 + ? (event.deltaY * (canvas.clientHeight || 0)) / PX_PER_LINE + : event.deltaY / PX_PER_LINE; + deliver( + 'wheel', + { x: event.clientX - rect.left, y: event.clientY - rect.top }, + { delta: lines }, + event, + ); + }; + canvas?.addEventListener?.('wheel', onWheel, { passive: true }); + + return { + port: router.port, + feed(type, x, y, extra = {}) { + deliver(type, { x, y }, extra, null); + }, + listenerCount: router.listenerCount, + dispose() { + if (disposed) return; + disposed = true; + router.dispose(); + canvas?.removeEventListener?.('wheel', onWheel, { passive: true }); + for (const type of DOM_EVENTS) + dom?.removeEventListener?.(type, record, { capture: true }); + if (!handler.isDestroyed?.()) handler.destroy?.(); + uninstallCtrlWheel?.(); + uninstallCtrlWheel = null; + cameraInput.release(); + if (stockHandler && !stockHandler.isDestroyed?.()) + for (const [name, type] of Object.entries(STOCK)) + if (stock[name] && !stockHandler.getInputAction(type)) + stockHandler.setInputAction(stock[name], type); + }, + }; +} diff --git a/src/render/cesium/input.test.mjs b/src/render/cesium/input.test.mjs new file mode 100644 index 000000000..499d5917d --- /dev/null +++ b/src/render/cesium/input.test.mjs @@ -0,0 +1,376 @@ +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import test from 'node:test'; +import { createCesiumInput } from './input.js'; + +const T = Cesium.ScreenSpaceEventType; +const { SHIFT } = Cesium.KeyboardEventModifier; + +/** A ScreenSpaceEventHandler that records its actions and fires them. */ +function fakeHandler() { + const actions = new Map(); + const key = (type, modifier) => `${type}:${modifier ?? ''}`; + let destroyed = false; + return { + setInputAction: (fn, type, modifier) => + actions.set(key(type, modifier), fn), + getInputAction: (type, modifier) => actions.get(key(type, modifier)), + removeInputAction: (type, modifier) => actions.delete(key(type, modifier)), + fire: (type, event, modifier) => actions.get(key(type, modifier))?.(event), + isDestroyed: () => destroyed, + destroy: () => { + destroyed = true; + actions.clear(); + }, + }; +} + +function fakeCanvas() { + const listeners = new Map(); + return { + clientHeight: 300, + getBoundingClientRect: () => ({ left: 10, top: 20 }), + addEventListener: (type, fn) => listeners.set(type, fn), + removeEventListener: (type) => listeners.delete(type), + dispatch: (type, event) => listeners.get(type)?.(event), + listeners, + }; +} + +/** A document that hands its capture listeners each event first. */ +function fakeDocument() { + const listeners = new Map(); + return { + addEventListener: (type, fn) => listeners.set(type, fn), + removeEventListener: (type) => listeners.delete(type), + fire: (event) => listeners.get(event.type)?.(event), + listeners, + }; +} + +function setup({ stock = true, document = null } = {}) { + const stockHandler = fakeHandler(); + const selected = []; + const followed = []; + if (stock) { + stockHandler.setInputAction((e) => selected.push(e.position), T.LEFT_CLICK); + stockHandler.setInputAction( + (e) => followed.push(e.position), + T.LEFT_DOUBLE_CLICK, + ); + } + const controller = { enableInputs: true }; + const canvas = fakeCanvas(); + const widget = { + scene: { canvas, screenSpaceCameraController: controller }, + screenSpaceEventHandler: stockHandler, + }; + const passes = { pick: 0, drill: 0 }; + const query = { + pick: () => (passes.pick++, { id: 'hit' }), + drillPick: (x, y, { limit } = {}) => { + passes.drill++; + const all = ['a', 'b', 'c'].map((id) => ({ id })); + return limit === undefined ? all : all.slice(0, limit); + }, + pickPosition: () => null, + pickEllipsoid: () => ({ x: 1, y: 2, z: 3 }), + }; + const handler = fakeHandler(); + const errors = []; + const wheelZoom = []; + let clock = 0; + const input = createCesiumInput(widget, { + query, + report: (error) => errors.push(error), + createHandler: () => handler, + now: () => clock, + document, + ctrlWheelZoom: () => { + wheelZoom.push('on'); + return () => wheelZoom.push('off'); + }, + }); + const at = (x, y) => new Cesium.Cartesian2(x, y); + const press = (x, y, modifier) => { + handler.fire(T.LEFT_DOWN, { position: at(x, y) }, modifier); + handler.fire(T.LEFT_UP, { position: at(x, y) }, modifier); + handler.fire(T.LEFT_CLICK, { position: at(x, y) }, modifier); + }; + return { + input, + port: input.port, + handler, + stockHandler, + selected, + followed, + controller, + canvas, + passes, + errors, + wheelZoom, + press, + at, + tick: (ms) => (clock += ms), + }; +} + +test('the stock select runs ahead of the subscribers, for a click that is theirs', () => { + const s = setup(); + assert.equal( + s.stockHandler.getInputAction(T.LEFT_CLICK), + undefined, + 'taken off the viewer’s handler', + ); + const order = []; + s.port.on('click', () => order.push(`heard ${s.selected.length}`)); + s.press(5, 6); + assert.deepEqual(order, ['heard 1'], 'it has run already'); + assert.equal(s.selected.length, 1); + assert.deepEqual({ x: s.selected[0].x, y: s.selected[0].y }, { x: 5, y: 6 }); + s.handler.fire(T.LEFT_DOUBLE_CLICK, { position: s.at(5, 6) }); + assert.equal(s.followed.length, 1, 'and follow on a double-click'); +}); + +test('a lease, a grab or a suppression keeps the stock select off', () => { + const s = setup(); + const lease = s.port.claimPointer('tool'); + s.press(5, 6); + assert.equal(s.selected.length, 0, 'under a lease'); + lease.release(); + const grabbing = s.port.claimPointer('draw', { + grab: (down) => down.position.x < 100, + }); + s.press(5, 6); + assert.equal(s.selected.length, 0, 'a grabbed press'); + s.press(500, 6); + assert.equal(s.selected.length, 1, 'a press the grab left'); + grabbing.release(); + const off = s.port.behaviours.select.suppress(); + s.press(5, 6); + assert.equal(s.selected.length, 1, 'while suppressed'); + off(); + s.press(5, 6, SHIFT); + assert.equal(s.selected.length, 1, 'a modified click never ran it'); + s.press(5, 6); + assert.equal(s.selected.length, 2, 'back'); + s.handler.fire(T.LEFT_DOUBLE_CLICK, { position: s.at(5, 6) }); + const offFollow = s.port.behaviours.dblclickFollow.suppress(); + s.handler.fire(T.LEFT_DOUBLE_CLICK, { position: s.at(5, 6) }); + assert.equal(s.followed.length, 1, 'follow is suppressed on its own'); + offFollow(); +}); + +test('dispose gives the viewer its stock actions back', () => { + const s = setup(); + s.input.dispose(); + s.stockHandler.fire(T.LEFT_CLICK, { position: s.at(1, 2) }); + s.stockHandler.fire(T.LEFT_DOUBLE_CLICK, { position: s.at(1, 2) }); + assert.equal(s.selected.length, 1); + assert.equal(s.followed.length, 1); + assert.ok(s.handler.isDestroyed()); + assert.equal(s.canvas.listeners.size, 0, 'the wheel listener is gone'); +}); + +test('a widget without stock actions reports them disabled', () => { + const s = setup({ stock: false }); + assert.equal(s.port.behaviours.select.enabled, false); + assert.equal(s.port.behaviours.dblclickFollow.enabled, false); +}); + +test('a click carries its gesture and one pick, drill and ground', () => { + const s = setup(); + const clicks = []; + s.port.on('click', (ev) => clicks.push(ev)); + s.handler.fire(T.LEFT_DOWN, { position: s.at(0, 0) }); + s.tick(80); + s.handler.fire(T.MOUSE_MOVE, { + startPosition: s.at(0, 0), + endPosition: s.at(3, 4), + }); + s.handler.fire(T.LEFT_UP, { position: s.at(3, 4) }); + s.handler.fire(T.LEFT_CLICK, { position: s.at(3, 4) }); + const [ev] = clicks; + assert.deepEqual(ev.gesture, { travelPx: 5, durationMs: 80 }); + assert.equal(ev.pick(), ev.pick(), 'one pick object'); + assert.equal(s.passes.pick, 1); + assert.deepEqual( + ev.drill({ limit: 1 }).map((h) => h.id), + ['a'], + ); + assert.deepEqual( + ev.drill().map((h) => h.id), + ['a', 'b', 'c'], + ); + assert.equal(s.passes.drill, 1, 'a short pass found everything'); + assert.deepEqual(ev.ground(), { x: 1, y: 2, z: 3 }); + s.handler.fire(T.LEFT_CLICK, { position: s.at(3, 4) }); + assert.deepEqual( + clicks[1].gesture, + { travelPx: 0, durationMs: 0 }, + 'a click with no press of its own', + ); +}); + +test('camera holds turn the controller’s input off until the last release', () => { + const s = setup(); + const a = s.port.suspendCameraControls('a'); + const b = s.port.suspendCameraControls('b'); + assert.equal(s.controller.enableInputs, false); + a(); + assert.equal(s.controller.enableInputs, false); + b(); + assert.equal(s.controller.enableInputs, true); + s.port.suspendCameraControls('left out'); + s.input.dispose(); + assert.equal(s.controller.enableInputs, true, 'dispose gives them back'); +}); + +test('ctrlWheelZoom installs the zoom and takes it out again', () => { + const s = setup(); + s.port.ctrlWheelZoom = true; + s.port.ctrlWheelZoom = true; + s.port.ctrlWheelZoom = false; + s.port.ctrlWheelZoom = true; + s.input.dispose(); + assert.deepEqual(s.wheelZoom, ['on', 'off', 'on', 'off']); +}); + +test('wheel is in lines, where the pointer is over the canvas', () => { + const s = setup(); + const wheels = []; + s.port.on('wheel', (ev) => + wheels.push([ev.position, ev.delta, ev.modifiers]), + ); + s.canvas.dispatch('wheel', { + clientX: 110, + clientY: 220, + deltaY: 100, + deltaMode: 0, + ctrlKey: true, + }); + s.canvas.dispatch('wheel', { + clientX: 10, + clientY: 20, + deltaY: -3, + deltaMode: 1, + }); + assert.deepEqual(wheels[0][0], { x: 100, y: 200 }); + assert.equal(wheels[0][1], 3); + assert.equal(wheels[0][2].ctrl, true); + assert.equal(wheels[1][1], -3); +}); + +test('a pinch is the scale since the last event, at its midpoint', () => { + const s = setup(); + const pinches = []; + s.port.on('pinch', (ev) => pinches.push([ev.position, ev.delta])); + s.handler.fire(T.PINCH_START, { + position1: s.at(0, 0), + position2: s.at(10, 20), + }); + s.handler.fire(T.PINCH_MOVE, { + distance: { startPosition: s.at(0, 4), endPosition: s.at(0, 5) }, + }); + assert.deepEqual(pinches, [[{ x: 5, y: 10 }, 1.25]]); +}); + +test('a throwing stock action is reported', () => { + const stockHandler = fakeHandler(); + const boom = new Error('stock'); + stockHandler.setInputAction(() => { + throw boom; + }, T.LEFT_CLICK); + const handler = fakeHandler(); + const errors = []; + createCesiumInput( + { + scene: { canvas: fakeCanvas(), screenSpaceCameraController: {} }, + screenSpaceEventHandler: stockHandler, + }, + { + query: { pick: () => null }, + report: (error) => errors.push(error), + createHandler: () => handler, + document: null, + }, + ); + handler.fire(T.LEFT_CLICK, { position: new Cesium.Cartesian2(1, 1) }); + assert.deepEqual(errors, [boom]); +}); + +test('releasing one button ends the others’ presses without their clicks', () => { + const doc = fakeDocument(); + const s = setup({ document: doc }); + const heard = []; + for (const type of ['up', 'click']) + s.port.on(type, (ev) => heard.push(`${type}:${ev.button}`)); + const mouse = (type, button) => + doc.fire({ type, button, pointerType: 'mouse' }); + mouse('pointerdown', 0); + s.handler.fire(T.LEFT_DOWN, { position: s.at(5, 6) }); + mouse('pointerdown', 2); + s.handler.fire(T.RIGHT_DOWN, { position: s.at(5, 6) }); + // Cesium answers the right release with both buttons' up and click. + mouse('pointerup', 2); + s.handler.fire(T.LEFT_UP, { position: s.at(5, 6) }); + s.handler.fire(T.LEFT_CLICK, { position: s.at(5, 6) }); + s.handler.fire(T.RIGHT_UP, { position: s.at(5, 6) }); + s.handler.fire(T.RIGHT_CLICK, { position: s.at(5, 6) }); + assert.deepEqual(heard, ['up:0', 'up:2', 'click:2']); + assert.equal(s.selected.length, 0, 'no select while left was held'); +}); + +test('an action raised after its DOM event takes Cesium’s modifier', async () => { + const doc = fakeDocument(); + const s = setup({ document: doc }); + const heard = []; + s.port.on('click', (ev) => heard.push([ev.modifiers, ev.pointerType])); + doc.fire({ type: 'pointerdown', button: 0, pointerType: 'touch' }); + doc.fire({ type: 'pointermove', pointerType: 'mouse', ctrlKey: true }); + doc.fire({ type: 'pointerdown', button: 0, pointerType: 'touch' }); + await new Promise((resolve) => setTimeout(resolve, 5)); + // A touch held for a right click, raised on Cesium's timer. + s.handler.fire(T.RIGHT_CLICK, { position: s.at(5, 6) }, SHIFT); + assert.deepEqual(heard, [ + [{ shift: true, ctrl: false, alt: false, meta: false }, 'touch'], + ]); +}); + +test('a subscriber that disposes the port stops the rest of the click', () => { + const s = setup(); + const heard = []; + s.port.on('click', () => { + heard.push('first'); + s.input.dispose(); + }); + s.port.on('click', () => heard.push('second')); + s.press(5, 6); + assert.deepEqual(heard, ['first']); + assert.equal(s.selected.length, 1, 'the stock select had run first'); + s.port.ctrlWheelZoom = true; + assert.deepEqual(s.wheelZoom, [], 'nothing installs after dispose'); +}); + +test('a double-tap is a touch; a click on Cesium’s timer ends no press', () => { + const doc = fakeDocument(); + const s = setup({ document: doc }); + const heard = []; + s.port.on('dblclick', (ev) => heard.push(ev.pointerType)); + s.port.on('click', (ev) => heard.push(ev.gesture)); + doc.fire({ type: 'pointerdown', button: 0, pointerType: 'touch' }); + doc.fire({ type: 'dblclick' }); + s.handler.fire(T.LEFT_DOUBLE_CLICK, { position: s.at(5, 6) }); + // A right drag that ended without a click, then a held touch. + doc.fire({ type: 'pointerdown', button: 2, pointerType: 'mouse' }); + s.handler.fire(T.RIGHT_DOWN, { position: s.at(0, 0) }); + s.tick(50); + doc.fire({ type: 'pointerup', button: 2, pointerType: 'mouse' }); + s.handler.fire(T.RIGHT_UP, { position: s.at(30, 40) }); + // The touch starts a press of its own, held until Cesium's timer. + doc.fire({ type: 'pointerdown', button: 0, pointerType: 'touch' }); + s.handler.fire(T.LEFT_DOWN, { position: s.at(5, 6) }); + doc.listeners.clear(); + s.handler.fire(T.RIGHT_CLICK, { position: s.at(5, 6) }); + assert.deepEqual(heard, ['touch', { travelPx: 0, durationMs: 0 }]); +}); diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index 60e6b2c8f..817c77536 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -3,23 +3,35 @@ * @description The part of the Cesium renderer the reference scenes need, * through the contract's own members: a bare globe and sky, icons, points, * lines, shapes and models, `setView`, picks and projection, the frame - * phases, settling and capture. The `/dev/conformance/` page draws the - * scenes with it to make and check the visual references. + * phases, pointer input, settling and capture. The `/dev/conformance/` page + * draws the scenes with it to make and check the visual references. * * Its ports are `ports.js`'s, which the application's renderer shares; - * this factory adds the conformance widget, its fixed settings and the - * bare-globe and sky members. + * this factory adds the conformance widget, its fixed settings, the input + * port (`input.js`, not yet the application's) and the bare-globe and sky + * members. * * It is not yet the whole Cesium renderer: each later migration adds the * members its layer family needs, until it passes the whole suite. * Members it does not have are absent, never stubbed. */ import * as Cesium from 'cesium'; +import { createCesiumInput } from './input.js'; import { onEngineWorkCompleted, renderPending } from './internals.js'; import { cesiumCapabilities, createCesiumPorts } from './ports.js'; export { toCartesian, toColor, toImage } from './ports.js'; +/** Each renderer's pointer feed, for the conformance harness. */ +const feeds = new WeakMap(); + +/** + * Delivers one pointer event to a renderer this made, as its handler would. + * @param {object} renderer + * @returns {((type: string, x: number, y: number, extra?: object) => void) | null} + */ +export const pointerFeedOf = (renderer) => feeds.get(renderer) ?? null; + const SKY_DEFAULTS = Object.freeze({ atmosphere: false, stars: false, @@ -78,6 +90,10 @@ export async function createCesiumSceneRenderer({ } const ports = createCesiumPorts(widget, { capabilities }); + const input = createCesiumInput(widget, { + query: ports.query, + report: ports.report, + }); const frame = { ...ports.frame, setTime(ms) { @@ -247,7 +263,7 @@ export async function createCesiumSceneRenderer({ } } - return { + const renderer = { id: 'cesium', contractVersion: '0', capabilities, @@ -262,6 +278,7 @@ export async function createCesiumSceneRenderer({ ), camera: ports.camera, query: ports.query, + input: input.port, capture: ports.capture, whenSettled, resize: () => widget.resize(), @@ -269,8 +286,11 @@ export async function createCesiumSceneRenderer({ destroy() { destroyed = true; for (const finish of [...watching]) finish('ended'); + input.dispose(); ports.dispose(); widget.destroy(); }, }; + feeds.set(renderer, input.feed); + return renderer; } diff --git a/src/render/input/router.js b/src/render/input/router.js index c27cf65b2..dce6cd9ef 100644 --- a/src/render/input/router.js +++ b/src/render/input/router.js @@ -214,12 +214,20 @@ export function createInputRouter({ /** * 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 + * @param {(to: {lease: object|null, ambient: boolean}) => void} [before] + * Runs first with who the event goes to: a renderer's stock behaviours, + * which run ahead of the subscribers. Its error is reported. + * @returns {{lease: object|null, ambient: boolean}} Who it went to. A * `move` or `up` always reads as going to both, grabbed press or not. */ - function dispatch(ev) { + function dispatch(ev, before) { const to = recipients(ev); + if (before) + try { + before(to); + } catch (error) { + report(error); + } for (const entry of listeners.slice()) { if (!entry.live || entry.type !== ev.type) continue; if (entry.lease) { @@ -240,5 +248,14 @@ export function createInputRouter({ dispatch, /** Live listeners, for leak checks. */ listenerCount: () => listeners.length, + /** + * Ends delivery: no listener runs again, even for the event under way + * (a listener that destroys the renderer stops the rest). + */ + dispose() { + for (const entry of listeners) entry.live = false; + listeners.length = 0; + currentLease?.release(); + }, }; } diff --git a/src/render/input/router.test.mjs b/src/render/input/router.test.mjs index f9cc23622..efd12e1ea 100644 --- a/src/render/input/router.test.mjs +++ b/src/render/input/router.test.mjs @@ -114,3 +114,20 @@ test('camera holds go to the renderer, reason as a string', () => { port.suspendCameraControls(7); assert.deepEqual(reasons, ['7']); }); + +test('dispose ends delivery, even of the event under way', () => { + const { port, dispatch, dispose, listenerCount } = router(); + const heard = []; + // A lease that leaves presses alone, so the down is everyone else's. + const lease = port.claimPointer('tool', { grab: () => false }); + port.on('down', () => { + heard.push('a'); + dispose(); + }); + port.on('down', () => heard.push('b')); + dispatch(ev('down')); + assert.deepEqual(heard, ['a']); + assert.equal(listenerCount(), 0); + assert.equal(lease.current, false, 'the lease ended'); + assert.equal(port.isPointerFree(), true); +});