From 1f2479d3ab36c69f7df26070110881de52cd0f9c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 18:50:18 -0800 Subject: [PATCH] Give the Cesium renderer the contract's input port src/render/cesium/input.js feeds the shared router from one ScreenSpaceEventHandler: every button's press, release and click, double clicks, moves and pinches, under any one modifier. The DOM event being dispatched gives the full modifiers and pointer type; an action Cesium raises on a timer takes its own modifier. Wheel comes from the canvas in lines and is never cancelled. Clicks are picked once, lazily, through the query port. Cesium ends every held button on any release; those presses end there, but only the button released clicks. The viewer's stock select and follow move off its handler and run first, as they did, but only for a press no lease took and while not suppressed. Camera holds go through the scene's shared holds, ctrlWheelZoom through ctrlWheelZoom.js. The router gains a before-hook and dispose, which stops delivery mid-event. The conformance renderer exposes the port and the harness feeds it; input.lease, input.lease.grab, input.lease.press, input.clickPick and input.behaviours join the Cesium checks. --- scripts/format-scope.json | 2 + src/data/inputOwnership.test.mjs | 8 +- src/render/cesium/harness.js | 10 +- src/render/cesium/input.js | 416 +++++++++++++++++++++++++++++ src/render/cesium/input.test.mjs | 376 ++++++++++++++++++++++++++ src/render/cesium/sceneRenderer.js | 30 ++- src/render/input/router.js | 23 +- src/render/input/router.test.mjs | 17 ++ 8 files changed, 870 insertions(+), 12 deletions(-) create mode 100644 src/render/cesium/input.js create mode 100644 src/render/cesium/input.test.mjs 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); +});