From b0cb8ce881194d08e18db36c94154cc34ea4687a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 18:25:12 +0400 Subject: [PATCH] feat(globe): picks on input payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit input.on('click'|'doubleClick'|'move', fn, { pick, drill }) delivers { …event, picked, drilled }: PickResult { id, owner, instance, primitive (Cesium) } names the mover set (and instance) or line hit. One pick (and one drill, as deep as the deepest listener) per event, shared by its listeners; moves with picks are coalesced to one delivery per frame with the latest position (src/globe/pickDelivery.js). Option checks in the contract (normalizeInputOptions). - Cesium: scene.pick / drillPick, and the picker names the owner: movers map billboards, points and models to their set and instance, lines their primitives to the line. - Navara: a click with pick takes Navara's GPU pick of the same gesture (featureClick; its pick helper answers on the same pointerup, usually before the engine's click, so a fresh answer waits for it; otherwise the click waits up to 250 ms, then the CPU pick). Navara 0.2.1 answers clicks in quick succession with the first click's pick: a GPU answer whose instance is not drawn near the click is stale and the CPU pick answers. Hover, drill and the synchronous picker.pick/drillPick are CPU picks (src/globe/cpuPicks.js) of pickable mover sets and pickable lines (within half the width + 3 px), behind the globe excluded, nearest first; lines are picked on the CPU after an empty GPU answer. picker.clickStats() (Navara only) counts the answers and latencies. - The fake engine picks its own movers and lines on the CPU unless scripted. - The contract suite clicks with real pointer events (puppeteer) on both engines: a sprite is picked with its owner and instance, the map picks nothing, two sprites in one place drill both, a line is picked, a hover picks the sprite under it. Navara, 20 clicks: payload p50 2.8 ms, p95 4.1 ms after the click (puppeteer dispatch included), 20/20 GPU answers. The Cesium harness keeps the first-run card closed. --- scripts/format-scope.json | 5 +- src/data/pickRegistry.test.mjs | 9 + src/globe/cesium/createCesiumEngine.js | 20 +- src/globe/cesium/input.js | 31 +-- src/globe/cesium/input.test.mjs | 58 ++++++ src/globe/cesium/lines.js | 6 + src/globe/cesium/lines.test.mjs | 18 ++ src/globe/cesium/movers.js | 22 ++- src/globe/cesium/movers.test.mjs | 24 +++ src/globe/cesium/picker.js | 32 ++-- src/globe/cesium/picker.test.mjs | 21 +++ src/globe/contract.js | 47 ++++- src/globe/contract.test.mjs | 9 + src/globe/cpuPicks.js | 161 ++++++++++++++++ src/globe/navara/createNavaraEngine.js | 27 ++- src/globe/navara/input.js | 43 +++-- src/globe/navara/input.test.mjs | 35 ++++ src/globe/navara/lines/index.js | 27 ++- src/globe/navara/movers/index.js | 6 + src/globe/navara/picking.js | 118 ++++++++++++ src/globe/navara/picking.test.mjs | 251 +++++++++++++++++++++++++ src/globe/pickDelivery.js | 67 +++++++ src/globe/pickDelivery.test.mjs | 86 +++++++++ src/globe/testing/contractSuite.mjs | 104 +++++++++- src/globe/testing/fakeEngine.js | 63 ++++++- src/globe/testing/fakeEngine.test.mjs | 45 +++++ 26 files changed, 1267 insertions(+), 68 deletions(-) create mode 100644 src/globe/cpuPicks.js create mode 100644 src/globe/navara/picking.js create mode 100644 src/globe/navara/picking.test.mjs create mode 100644 src/globe/pickDelivery.js create mode 100644 src/globe/pickDelivery.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 80901f747..61149f281 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1077,5 +1077,8 @@ "src/bench/syntheticFlights.test.mjs", "src/globe/lines.test.mjs", "src/globe/cesium/lines.test.mjs", - "src/globe/navara/lines/lineMesh.test.mjs" + "src/globe/navara/lines/lineMesh.test.mjs", + "src/globe/pickDelivery.test.mjs", + "src/globe/navara/picking.test.mjs", + "src/data/selectionDispatcher.test.mjs" ] diff --git a/src/data/pickRegistry.test.mjs b/src/data/pickRegistry.test.mjs index f522ec072..5de6d41dc 100644 --- a/src/data/pickRegistry.test.mjs +++ b/src/data/pickRegistry.test.mjs @@ -97,3 +97,12 @@ test('ownership: unregister removes the predicate', () => { unregisterPickOwner('bikeshare'); assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false); }); + +test('engine pick results (input payloads) resolve like scene picks', () => { + assert.equal( + resolvePickId({ id: 'ae01ce', owner: 'flights-fleet', instance: 3 }), + 'ae01ce', + ); + assert.equal(resolvePickId({ id: 25544, owner: 'satellites' }), '25544'); + assert.equal(resolvePickId({ id: undefined, owner: 'tiles' }), null); +}); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index bac207d27..4e5bce215 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -41,23 +41,31 @@ export function createCesiumEngine(viewer, options = {}) { const canvas = viewer.canvas ?? viewer.scene.canvas; const render = createCesiumRender(viewer, options); const credits = createCesiumCredits(viewer); + // Picks name the mover set or line they hit (movers and lines exist + // below; the picker asks them when it picks). + let lines = null; + let movers = null; + const picker = createCesiumPicker(viewer, { + identify: (picked) => + movers?.identify(picked) ?? lines?.identify(picked) ?? null, + }); const parts = { camera: createCesiumCamera(viewer), - picker: createCesiumPicker(viewer), + picker, projector: createCesiumProjector(viewer), frames: createCesiumFrames(viewer), clock: createCesiumClock(viewer, options), - lines: createCesiumLines(viewer, { + lines: (lines = createCesiumLines(viewer, { requestRender: (reason) => render.render.request(reason), ...(options.lineFactories ? { factories: options.lineFactories } : {}), - }), - movers: createCesiumMovers(viewer, { + })), + movers: (movers = createCesiumMovers(viewer, { requestRender: (reason) => render.render.request(reason), ...(options.moverFactories ? { factories: options.moverFactories } : {}), - }), + })), render, surface: createCesiumSurface(viewer), - input: createCesiumInput(viewer, options), + input: createCesiumInput(viewer, { ...options, picker: picker.picker }), credits, maps: createCesiumMaps(viewer, { ...options, diff --git a/src/globe/cesium/input.js b/src/globe/cesium/input.js index 6519ccbb7..7783e82f1 100644 --- a/src/globe/cesium/input.js +++ b/src/globe/cesium/input.js @@ -2,7 +2,8 @@ // one action per (type, modifier); the engine fans it out to its listeners. import * as Cesium from 'cesium'; -import { INPUT_TYPES, MODIFIERS } from '../contract.js'; +import { normalizeInputOptions } from '../contract.js'; +import { createPickDelivery } from '../pickDelivery.js'; import { toGeo2 } from './convert.js'; const TYPES = { @@ -28,26 +29,34 @@ const toEvent = (event) => { /** * @param {Cesium.Viewer} viewer - * @param {{ createEventHandler?: (canvas: HTMLCanvasElement) => Cesium.ScreenSpaceEventHandler }} [options] + * @param {{ createEventHandler?: (canvas: HTMLCanvasElement) => Cesium.ScreenSpaceEventHandler, + * picker?: { pick: Function, drillPick: Function }, + * scheduleFrame?: (run: () => void) => void }} [options] `picker` answers + * listeners with { pick, drill } (the engine's picker); moves with picks + * wait for `scheduleFrame` (requestAnimationFrame). * @returns {{ input: object, destroy: () => void }} */ export function createCesiumInput( viewer, { createEventHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), + picker = null, + scheduleFrame = (run) => requestAnimationFrame(run), } = {}, ) { const { scene } = viewer; let handler = null; let destroyed = false; const groups = new Map(); + const delivery = createPickDelivery({ + pick: (position) => picker?.pick(position), + drill: (position, limit) => picker?.drillPick(position, limit) ?? [], + schedule: (run) => scheduleFrame(() => !destroyed && run()), + }); const input = { - on(type, listener, { modifier } = {}) { - if (!INPUT_TYPES.includes(type)) - throw new TypeError(`Unknown input type: ${type}`); - if (modifier !== undefined && !MODIFIERS.includes(modifier)) - throw new TypeError(`Unknown input modifier: ${modifier}`); + on(type, listener, options = {}) { + const { modifier, pick, drill } = normalizeInputOptions(type, options); if (destroyed) return () => {}; handler ??= createEventHandler(scene.canvas); const key = `${type}:${modifier}`; @@ -57,15 +66,15 @@ export function createCesiumInput( groups.set(key, listeners); handler.setInputAction( (event) => { - const converted = toEvent(event); - for (const entry of [...listeners]) - if (listeners.has(entry)) entry.listener(converted); + delivery.deliver(type, toEvent(event), [...listeners], { + isLive: (entry) => listeners.has(entry), + }); }, TYPES[type], KEYS[modifier], ); } - const entry = { listener }; + const entry = { listener, pick, drill }; listeners.add(entry); return () => { if (!listeners.delete(entry) || listeners.size) return; diff --git a/src/globe/cesium/input.test.mjs b/src/globe/cesium/input.test.mjs index 842e0e2eb..8b67096c3 100644 --- a/src/globe/cesium/input.test.mjs +++ b/src/globe/cesium/input.test.mjs @@ -136,3 +136,61 @@ test('on() after destroy() creates no handler', () => { destroy(); assert.equal(handlers.length, 0); }); + +test('click listeners with pick and drill get the picker answers once per click', () => { + const viewer = createFakeViewer(); + const handlers = createFakeEventHandlers(); + const calls = []; + const picker = { + pick: (screen) => ( + calls.push(['pick', screen]), + { id: 'p', owner: 'flights', instance: 2 } + ), + drillPick: (screen, limit) => ( + calls.push(['drill', limit]), + [{ id: 'p' }, { id: 'q' }, { id: 'r' }].slice(0, limit) + ), + }; + const frames = []; + const { input } = createCesiumInput(viewer, { + createEventHandler: handlers.create, + picker, + scheduleFrame: (run) => frames.push(run), + }); + const seen = []; + input.on('click', (event) => seen.push(['plain', event])); + input.on('click', (event) => seen.push(['picked', event]), { + pick: true, + drill: 2, + }); + input.on('move', (event) => seen.push(['hover', event]), { pick: true }); + const [handler] = handlers.handlers; + handler.fire(Type.LEFT_CLICK, { position: new Cesium.Cartesian2(4, 5) }); + assert.deepEqual( + calls.map(([name, value]) => [ + name, + typeof value === 'object' ? { ...value } : value, + ]), + [ + ['pick', { x: 4, y: 5 }], + ['drill', 2], + ], + ); + assert.equal(seen[0][1].picked, undefined); + assert.deepEqual(seen[1][1].picked, { + id: 'p', + owner: 'flights', + instance: 2, + }); + assert.deepEqual(seen[1][1].drilled, [{ id: 'p' }, { id: 'q' }]); + for (const x of [1, 2]) + handler.fire(Type.MOUSE_MOVE, { + startPosition: new Cesium.Cartesian2(0, 0), + endPosition: new Cesium.Cartesian2(x, 0), + }); + assert.equal(frames.length, 1, 'moves coalesce into one frame'); + frames[0](); + const hovers = seen.filter(([name]) => name === 'hover'); + assert.equal(hovers.length, 1); + assert.deepEqual({ ...calls.at(-1)[1] }, { x: 2, y: 0 }); +}); diff --git a/src/globe/cesium/lines.js b/src/globe/cesium/lines.js index b97b832a2..159e81ec2 100644 --- a/src/globe/cesium/lines.js +++ b/src/globe/cesium/lines.js @@ -171,6 +171,7 @@ export function createCesiumLines( show: shown, asynchronous: false, }); + primitive.gevLine = options.id; root.add(primitive); requestRender('lines'); }, @@ -203,6 +204,11 @@ export function createCesiumLines( }); return { lines: registry.lines, + /** The line a Cesium pick result belongs to, or null. */ + identify(picked) { + const owner = picked?.primitive?.gevLine; + return owner !== undefined && registry.live.has(owner) ? { owner } : null; + }, destroy() { registry.destroyAll(); if (root) { diff --git a/src/globe/cesium/lines.test.mjs b/src/globe/cesium/lines.test.mjs index ff7723a49..786ea7d67 100644 --- a/src/globe/cesium/lines.test.mjs +++ b/src/globe/cesium/lines.test.mjs @@ -125,3 +125,21 @@ test('dash kinds pick their materials', () => { assert.equal(made[2].appearance.material.type, 'Color'); assert.equal(made[2].depthFailAppearance, undefined); }); + +test('a pick of a line primitive names the line', () => { + const viewer = createFakeViewer(); + const { factories, made } = fakePrimitives(); + const { lines, identify } = createCesiumLines(viewer, { factories }); + lines + .createLine({ + id: 'orbit-25544', + widthPx: 2, + color: [1, 1, 1, 1], + pickId: 'o', + }) + .setPositions(points); + assert.deepEqual(identify({ primitive: made[0], id: 'o' }), { + owner: 'orbit-25544', + }); + assert.equal(identify({ primitive: {}, id: 'o' }), null); +}); diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index 88e89fdfc..2cd5d3e5f 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -85,7 +85,7 @@ function readColor(store, index) { return scratchColor; } -function spritePart(scene, options, store, factories, requestRender) { +function spritePart(scene, options, store, factories, requestRender, owners) { const collection = factories.billboards(scene); const { sprite, depthTest, id } = options; const style = { @@ -108,6 +108,7 @@ function spritePart(scene, options, store, factories, requestRender) { if (!billboard) { billboard = collection.add({ ...style }); billboards[index] = billboard; + owners.set(billboard, { owner: id, instance: index }); shownCell[index] = -1; } billboard.position = readPosition(store, index); @@ -146,7 +147,7 @@ function spritePart(scene, options, store, factories, requestRender) { }; } -function pointPart(options, store, factories, requestRender) { +function pointPart(options, store, factories, requestRender, owners) { const collection = factories.points(); const { point, depthTest } = options; const style = { @@ -169,6 +170,7 @@ function pointPart(options, store, factories, requestRender) { if (!item) { item = collection.add({ ...style }); points[index] = item; + owners.set(item, { owner: options.id, instance: index }); } item.position = readPosition(store, index); item.pixelSize = point.sizePx * store.scales[index]; @@ -182,7 +184,7 @@ function pointPart(options, store, factories, requestRender) { }; } -function modelPart(options, store, factories, requestRender) { +function modelPart(options, store, factories, requestRender, owners) { const collection = factories.models(); const { model } = options; const blendMode = @@ -235,6 +237,7 @@ function modelPart(options, store, factories, requestRender) { return; } models[index] = item; + owners.set(item, { owner: options.id, instance: index }); place(item, index); collection.add(item); requestRender('movers'); @@ -273,6 +276,8 @@ export function createCesiumMovers( const { scene } = viewer; let root = null; const ordered = []; + // Billboard, point or model → its set and instance, for picks. + const owners = new WeakMap(); const rootCollection = () => { if (!root) { root = new Cesium.PrimitiveCollection({ destroyPrimitives: true }); @@ -283,10 +288,10 @@ export function createCesiumMovers( const registry = createMoverRegistry((options, store) => { const part = options.kind === 'sprite' - ? spritePart(scene, options, store, make, requestRender) + ? spritePart(scene, options, store, make, requestRender, owners) : options.kind === 'point' - ? pointPart(options, store, make, requestRender) - : modelPart(options, store, make, requestRender); + ? pointPart(options, store, make, requestRender, owners) + : modelPart(options, store, make, requestRender, owners); const { primitive } = part; primitive.gevMoverSet = options.id; // Later (higher) order draws later: after every set of lower or equal order. @@ -308,6 +313,11 @@ export function createCesiumMovers( }); return { movers: registry.movers, + /** The set and instance a Cesium pick result belongs to, or null. */ + identify(picked) { + const known = picked?.primitive ? owners.get(picked.primitive) : null; + return known && registry.sets.has(known.owner) ? { ...known } : null; + }, destroy() { registry.destroyAll(); if (root) { diff --git a/src/globe/cesium/movers.test.mjs b/src/globe/cesium/movers.test.mjs index a6ef03ec5..72599a2d7 100644 --- a/src/globe/cesium/movers.test.mjs +++ b/src/globe/cesium/movers.test.mjs @@ -221,3 +221,27 @@ test('models load per instance and follow position and orientation', async () => set.destroy(); assert.equal(models.destroyed, true); }); + +test('a pick of a billboard or point names its set and instance', async () => { + const viewer = createFakeViewer(); + const { factories, made } = fakeFactories(); + const { movers, identify } = createCesiumMovers(viewer, { factories }); + const set = movers.createSet({ + id: 'sats', + kind: 'point', + capacity: 3, + point: { sizePx: 4 }, + }); + set.set(0, { position: at(1) }); + set.set(1, { position: at(2), pickId: 25544 }); + set.setCount(2); + set.commit(); + const [, second] = made.find((c) => c.kind === 'points').items; + assert.deepEqual(identify({ primitive: second, id: 25544 }), { + owner: 'sats', + instance: 1, + }); + assert.equal(identify({ primitive: {}, id: 'x' }), null); + set.destroy(); + assert.equal(identify({ primitive: second, id: 25544 }), null); +}); diff --git a/src/globe/cesium/picker.js b/src/globe/cesium/picker.js index 25d9082db..8aa8fe970 100644 --- a/src/globe/cesium/picker.js +++ b/src/globe/cesium/picker.js @@ -4,21 +4,31 @@ import { toCesium2, toGeo3 } from './convert.js'; -const normalise = (picked) => - picked == null - ? undefined - : { - id: picked.id, - primitive: picked.primitive, - content: picked.content, - object: picked, - }; - /** * @param {Cesium.Viewer} viewer + * @param {{ identify?: (picked: object) => ({ owner: string, instance?: number } | null) }} [options] + * `identify` names the mover set or line (and instance) a pick belongs to. * @returns {{ picker: object, destroy: () => void }} */ -export function createCesiumPicker(viewer) { +export function createCesiumPicker(viewer, { identify = () => null } = {}) { + const normalise = (picked) => { + if (picked == null) return undefined; + const known = identify(picked); + return { + id: picked.id, + primitive: picked.primitive, + content: picked.content, + object: picked, + ...(known + ? { + owner: known.owner, + ...(known.instance !== undefined + ? { instance: known.instance } + : {}), + } + : {}), + }; + }; const { scene } = viewer; const picker = { pick(screen, width, height) { diff --git a/src/globe/cesium/picker.test.mjs b/src/globe/cesium/picker.test.mjs index 8d5eff284..dc546f5d1 100644 --- a/src/globe/cesium/picker.test.mjs +++ b/src/globe/cesium/picker.test.mjs @@ -113,3 +113,24 @@ test('pickGlobe asks the globe along the camera pick ray and returns geo', () => scene.globe = undefined; assert.equal(picker.pickGlobe(geo.cartesian2.create(1, 1)), undefined); }); + +test('picks of movers and lines name their owner and instance', () => { + const viewer = createFakeViewer(); + const billboard = { id: 'ae01ce' }; + viewer.scene.pick = () => ({ primitive: billboard, id: 'ae01ce' }); + viewer.scene.drillPick = () => [ + { primitive: billboard, id: 'ae01ce' }, + { primitive: {}, id: 'other' }, + ]; + const { picker } = createCesiumPicker(viewer, { + identify: (raw) => + raw.primitive === billboard ? { owner: 'flights', instance: 7 } : null, + }); + const picked = picker.pick({ x: 1, y: 2 }); + assert.equal(picked.id, 'ae01ce'); + assert.equal(picked.owner, 'flights'); + assert.equal(picked.instance, 7); + const drilled = picker.drillPick({ x: 1, y: 2 }, 5); + assert.equal(drilled[0].owner, 'flights'); + assert.equal(drilled[1].owner, undefined); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index 47be16fcf..d36d9796f 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -128,7 +128,23 @@ * * @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType * @typedef {'ctrl'|'shift'|'alt'} InputModifier - * @typedef {{ position?: Cartesian2, startPosition?: Cartesian2, endPosition?: Cartesian2 }} InputEvent + * @typedef {object} InputEvent + * @property {Cartesian2} [position] click, doubleClick, down, up. + * @property {Cartesian2} [startPosition] move. + * @property {Cartesian2} [endPosition] move. + * @property {PickResult} [picked] With `pick`: what is drawn under the + * position (move: the end position), undefined for the map or nothing. + * Cesium picks at once; Navara answers a click from its GPU pick of the + * same gesture (a frame or two later; undefined after a timeout), a move + * from a CPU pick of movers and lines, at most once per frame. + * @property {PickResult[]} [drilled] With `drill`: up to that many objects + * under the position, nearest first. + * + * @typedef {object} InputOptions + * @property {InputModifier} [modifier] + * @property {boolean} [pick] click, doubleClick and move only. A move + * listener with pick gets at most one payload per frame (the latest). + * @property {number} [drill] A positive integer (click and doubleClick). * * @typedef {object} GlobeProjector * @property {(position: Cartesian3, result?: Cartesian2) => (Cartesian2 | undefined)} worldToWindow @@ -173,8 +189,11 @@ * The sharpening last set (a copy). * * @typedef {object} PickResult - * @property {any} id The picked entity or object id; undefined for map tiles. - * @property {any} primitive + * @property {any} id The picked entity or object id (a mover's or line's + * pickId); undefined for map tiles. + * @property {string} [owner] The mover set or line the pick belongs to. + * @property {number} [instance] The mover instance index. + * @property {any} [primitive] Cesium only, for compatibility. * @property {any} [content] 3D tile content when map tiles were picked (what * a surface-pick check reads), else undefined. * @property {any} [object] The engine's own pick result, for engine-specific code. @@ -362,6 +381,28 @@ export const INPUT_TYPES = Object.freeze([ 'up', ]); export const MODIFIERS = Object.freeze(['ctrl', 'shift', 'alt']); +/** Input types whose listeners may ask for picks. */ +export const PICK_INPUT_TYPES = Object.freeze(['click', 'doubleClick', 'move']); + +/** + * input.on options checked and defaulted: { modifier, pick, drill } (drill + * 0 when not asked), or a TypeError. + */ +export function normalizeInputOptions(type, options = {}) { + const { modifier, pick = false, drill = 0 } = options ?? {}; + if (!INPUT_TYPES.includes(type)) + throw new TypeError(`Unknown input type: ${type}`); + if (modifier !== undefined && !MODIFIERS.includes(modifier)) + throw new TypeError(`Unknown input modifier: ${modifier}`); + if (typeof pick !== 'boolean') + throw new TypeError(`Input pick must be a boolean: ${pick}`); + if (!(Number.isInteger(drill) && drill >= 0)) + throw new TypeError(`Input drill must be a whole number: ${drill}`); + if ((pick || drill) && !PICK_INPUT_TYPES.includes(type)) + throw new TypeError(`Input ${type} cannot pick`); + if (drill && type === 'move') throw new TypeError('A move cannot drill'); + return { modifier, pick: pick || drill > 0, drill }; +} export const FLIGHT_EASINGS = Object.freeze([ 'cubicInOut', 'sinusoidalInOut', diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index f64f4616a..6bd1f0ac1 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -228,6 +228,15 @@ test('the fake engine passes the engine contract suite', async (t) => { const engine = createFakeEngine(); const checks = createContractSuite(engine, { waitFrames: async (count) => engine.fake.frames(count), + // The fake's input, as a real pointer would drive it. + pointer: { + click: async (x, y) => engine.fake.input('click', { position: { x, y } }), + move: async (x, y) => + engine.fake.input('move', { + startPosition: { x: x - 1, y }, + endPosition: { x, y }, + }), + }, mapSources: { imagery: [{ kind: 'osm' }, { kind: 'esri-imagery' }], photoreal: { kind: 'photoreal' }, diff --git a/src/globe/cpuPicks.js b/src/globe/cpuPicks.js new file mode 100644 index 000000000..6c89bfe2e --- /dev/null +++ b/src/globe/cpuPicks.js @@ -0,0 +1,161 @@ +// CPU picks of movers and lines for every engine that needs them (Navara's +// hover, drill and synchronous pick, the fake engine): projected positions +// within the drawn size (+3 px, Navara's PICK_RADIUS), lines within half +// their width (+3 px), hidden behind the globe excluded, nearest to the +// camera first. Pure: src/geo and the engine's projector only. + +import * as geo from '../geo/index.js'; + +const scratch = geo.cartesian3.create(); +const scratchB = geo.cartesian3.create(); +const scratchScreen = { x: 0, y: 0 }; +const scratchScreenB = { x: 0, y: 0 }; + +export const PICK_RADIUS_PX = 3; + +/** Distance from point p to segment ab (2D). */ +export function distanceToSegment(p, a, b) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSquared = dx * dx + dy * dy; + const t = + lengthSquared > 0 + ? Math.max( + 0, + Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared), + ) + : 0; + return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); +} + +const segmentT = (p, a, b) => { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSquared = dx * dx + dy * dy; + return lengthSquared > 0 + ? Math.max( + 0, + Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared), + ) + : 0; +}; + +function moverRadius(options, scale) { + const size = + options.kind === 'sprite' + ? options.sprite.sizePx + : options.kind === 'point' + ? options.point.sizePx + : Math.max(options.model.minimumPixelSize, 24); + return Math.max((size * scale) / 2, PICK_RADIUS_PX) + PICK_RADIUS_PX; +} + +function transformed(matrix, flat, index, result) { + const x = flat[index * 3]; + const y = flat[index * 3 + 1]; + const z = flat[index * 3 + 2]; + if (!matrix) return geo.cartesian3.fromElements(x, y, z, result); + return geo.cartesian3.fromElements( + matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12], + matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13], + matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14], + result, + ); +} + +/** + * @param {{ projector: { worldToWindow: Function, horizonVisible: Function }, + * camera: { livePosition: object }, + * movers: () => { options: object, store: object }[], + * lines: () => { options: object, flat: Float64Array, modelMatrix: number[] | null }[] }} ports + * @returns {(at: { x: number, y: number }, limit?: number, options?: { includeMovers?: boolean }) => object[]} + * Pick results ({ id, owner, instance? }) under a window position, nearest first. + */ +export function createCpuPicker({ projector, camera, movers, lines }) { + const depthOf = (position) => + geo.cartesian3.distance(position, camera.livePosition); + + function moverHits(at, hits) { + for (const { options, store } of movers()) { + if (!options.pickable) continue; + const { positions, shows, scales, pickIds } = store; + for (let index = 0; index < store.count; index++) { + if (shows[index] !== 1) continue; + geo.cartesian3.fromElements( + positions[index * 3], + positions[index * 3 + 1], + positions[index * 3 + 2], + scratch, + ); + const screen = projector.worldToWindow(scratch, scratchScreen); + if (!screen || !projector.horizonVisible(scratch)) continue; + const distance = Math.hypot(screen.x - at.x, screen.y - at.y); + if (distance > moverRadius(options, scales[index])) continue; + hits.push({ + depth: depthOf(scratch), + result: { id: pickIds[index], owner: options.id, instance: index }, + }); + } + } + } + + function lineHits(at, hits) { + for (const { options, flat, modelMatrix } of lines()) { + if (options.pickId === undefined) continue; + const limit = options.widthPx / 2 + PICK_RADIUS_PX; + let best = null; + for (let index = 0; index + 1 < flat.length / 3; index++) { + const a = transformed(modelMatrix, flat, index, scratch); + const b = transformed(modelMatrix, flat, index + 1, scratchB); + if (!projector.horizonVisible(a) && !projector.horizonVisible(b)) + continue; + const sa = projector.worldToWindow(a, scratchScreen); + const sb = sa && projector.worldToWindow(b, scratchScreenB); + if (!sa || !sb) continue; + if (distanceToSegment(at, sa, sb) > limit) continue; + const t = segmentT(at, sa, sb); + const depth = depthOf( + geo.cartesian3.lerp(a, b, t, geo.cartesian3.create()), + ); + if (best === null || depth < best) best = depth; + } + if (best !== null) + hits.push({ + depth: best, + result: { id: options.pickId, owner: options.id }, + }); + } + } + + const drill = (at, limit = Infinity, { includeMovers = true } = {}) => { + const hits = []; + if (includeMovers) moverHits(at, hits); + lineHits(at, hits); + hits.sort((a, b) => a.depth - b.depth); + return hits.slice(0, limit).map(({ result }) => result); + }; + /** + * Whether a mover instance is drawn near a window position: within its + * pick radius times `slack` (a check of a GPU answer). Unknown sets and + * models (whose drawn size the CPU does not know) count as near. + */ + drill.near = (owner, instance, at, slack = 2) => { + const found = movers().find(({ options }) => options.id === owner); + if (!found || found.options.kind === 'model') return true; + const { options, store } = found; + if (instance >= store.count) return false; + geo.cartesian3.fromElements( + store.positions[instance * 3], + store.positions[instance * 3 + 1], + store.positions[instance * 3 + 2], + scratch, + ); + const screen = projector.worldToWindow(scratch, scratchScreen); + if (!screen) return false; + return ( + Math.hypot(screen.x - at.x, screen.y - at.y) <= + moverRadius(options, store.scales[instance]) * slack + ); + }; + return drill; +} diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 4d792f38b..2de679362 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -17,6 +17,7 @@ import { createCameraModel } from './cameraModel.js'; import { createNavaraClock } from './clock.js'; import { createNavaraCredits } from './credits.js'; import { createNavaraInput } from './input.js'; +import { createNavaraPicking } from './picking.js'; import { createNavaraMaps } from './maps.js'; import { createNavaraSurface } from './surface.js'; import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js'; @@ -163,9 +164,24 @@ export function createNavaraEngine( for (const [name, handler] of Object.entries(handlers)) view.on(name, handler); + // Picks over the movers and lines (picking.js); the input answers + // listeners with { pick, drill } from it. + const picking = createNavaraPicking({ + view, + projector: { + worldToWindow: (position, result) => + model.worldToWindow(position, result), + horizonVisible: (position) => model.horizonVisible(position), + }, + camera: model.camera, + movers: () => moversPort?.pickables?.() ?? [], + lines: () => linesPort?.pickables?.() ?? [], + resolveBatch: (batchId) => moversPort?.resolvePick?.(batchId) ?? null, + }); const input = createNavaraInput(view, { // A gesture is the user, not a new camera owner: no camera.commanded. onCameraInput: () => model.stopFlight(), + picker: picking, }); const credits = createNavaraCredits(view); const maps = createNavaraMaps(view, fetch ? { fetch } : {}); @@ -182,10 +198,12 @@ export function createNavaraEngine( }); const picker = { - // Navara picks its own meshes only on click gestures (featureClick); there - // is no synchronous pick, and 3D tiles carry no ids. No layers yet. - pick: () => undefined, - drillPick: () => [], + // The CPU pick of movers and lines (picking.js); 3D tiles carry no ids. + // Clicks with { pick } get Navara's GPU pick of the gesture instead. + pick: (screen) => picking.pick(screen), + drillPick: (screen, limit) => picking.drillPick(screen, limit), + // Navara-only diagnostics: how clicks with { pick } were answered. + clickStats: () => picking.clickStats(), pickPosition(screen) { const rect = canvas.getBoundingClientRect(); const hit = view.pickDepthPosition( @@ -262,6 +280,7 @@ export function createNavaraEngine( view.off(name, handler); model.destroy(); input.destroy(); + picking.destroy(); credits.destroy(); maps.destroy(); sharpenPort?.destroy(); diff --git a/src/globe/navara/input.js b/src/globe/navara/input.js index 92b1d4e38..515de19ec 100644 --- a/src/globe/navara/input.js +++ b/src/globe/navara/input.js @@ -8,7 +8,8 @@ // Navara 0.2.1 lets a flyTo run on under user input, where Cesium cancels it; // `onCameraInput` reports user camera input so the engine can do the same. -import { INPUT_TYPES, MODIFIERS } from '../contract.js'; +import { INPUT_TYPES, normalizeInputOptions } from '../contract.js'; +import { createPickDelivery } from '../pickDelivery.js'; const CLICK_PIXEL_TOLERANCE = 5; // Cesium's _clickPixelTolerance const SCREEN_EVENTS = ['pointerdown', 'pointerup', 'pointermove', 'dblclick']; @@ -23,10 +24,21 @@ function modifierOf(event) { /** * @param {{ canvas: HTMLCanvasElement, camera: { options: object } }} view - * @param {{ onCameraInput?: (type: string) => void }} [options] + * @param {{ onCameraInput?: (type: string) => void, + * picker?: { clickPick: Function, pick: Function, drillPick: Function }, + * scheduleFrame?: (run: () => void) => void }} [options] `picker` + * answers listeners with { pick, drill } (picking.js): clicks from the + * GPU pick of the gesture, moves from the CPU pick once a frame. * @returns {{ input: object, destroy: () => void }} */ -export function createNavaraInput(view, { onCameraInput = () => {} } = {}) { +export function createNavaraInput( + view, + { + onCameraInput = () => {}, + picker = null, + scheduleFrame = (run) => requestAnimationFrame(run), + } = {}, +) { const { canvas } = view; const listeners = new Map(INPUT_TYPES.map((type) => [type, new Set()])); let attached = false; @@ -36,15 +48,25 @@ export function createNavaraInput(view, { onCameraInput = () => {} } = {}) { let downAt = null; let last = null; + const delivery = createPickDelivery({ + pick: (position, type) => + type === 'move' ? picker?.pick(position) : picker?.clickPick(position), + drill: (position, limit) => picker?.drillPick(position, limit) ?? [], + schedule: (run) => scheduleFrame(() => !destroyed && run()), + }); const positionOf = (event) => { const rect = canvas.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; }; const emit = (type, event, payload) => { const modifier = modifierOf(event); - for (const entry of [...listeners.get(type)]) - if (entry.modifier === modifier && listeners.get(type).has(entry)) - entry.listener(payload); + const group = listeners.get(type); + delivery.deliver( + type, + payload, + [...group].filter((entry) => entry.modifier === modifier), + { isLive: (entry) => group.has(entry) }, + ); }; const onScreenEvent = (event) => { @@ -90,18 +112,15 @@ export function createNavaraInput(view, { onCameraInput = () => {} } = {}) { }); const input = { - on(type, listener, { modifier } = {}) { - if (!INPUT_TYPES.includes(type)) - throw new TypeError(`Unknown input type: ${type}`); - if (modifier !== undefined && !MODIFIERS.includes(modifier)) - throw new TypeError(`Unknown input modifier: ${modifier}`); + on(type, listener, options = {}) { + const { modifier, pick, drill } = normalizeInputOptions(type, options); if (destroyed) return () => {}; if (!attached) { attached = true; for (const name of SCREEN_EVENTS) canvas.addEventListener(name, onScreenEvent); } - const entry = { listener, modifier }; + const entry = { listener, modifier, pick, drill }; listeners.get(type).add(entry); return () => { listeners.get(type).delete(entry); diff --git a/src/globe/navara/input.test.mjs b/src/globe/navara/input.test.mjs index f6f1789a0..9c23f0e0a 100644 --- a/src/globe/navara/input.test.mjs +++ b/src/globe/navara/input.test.mjs @@ -165,3 +165,38 @@ test('setCameraInputs merges into the camera options set through the engine', () enableTilt: false, }); }); + +test('clicks with pick wait for the click pick; moves with pick use the CPU pick once a frame', async () => { + const canvas = fakeCanvas(); + const frames = []; + const calls = []; + const view = { canvas, camera: { set options(value) {} } }; + const { input } = createNavaraInput(view, { + picker: { + clickPick: async (at) => (calls.push(['click', at]), { id: 'gpu' }), + pick: (at) => (calls.push(['cpu', at]), { id: 'cpu' }), + drillPick: (at, limit) => ( + calls.push(['drill', limit]), + [{ id: 'a' }, { id: 'b' }] + ), + }, + scheduleFrame: (run) => frames.push(run), + }); + const seen = []; + input.on('click', (event) => seen.push(['click', event]), { + pick: true, + drill: 1, + }); + input.on('move', (event) => seen.push(['move', event]), { pick: true }); + canvas.dispatchEvent(pointer('pointerdown', 110, 60)); + canvas.dispatchEvent(pointer('pointerup', 110, 60)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(seen[0][1].picked, { id: 'gpu' }); + assert.deepEqual(seen[0][1].drilled, [{ id: 'a' }]); + canvas.dispatchEvent(pointer('pointermove', 120, 70)); + canvas.dispatchEvent(pointer('pointermove', 121, 71)); + assert.equal(frames.length, 1); + frames[0](); + assert.deepEqual(seen.at(-1)[1].picked, { id: 'cpu' }); + assert.deepEqual(calls.at(-1), ['cpu', { x: 21, y: 21 }]); +}); diff --git a/src/globe/navara/lines/index.js b/src/globe/navara/lines/index.js index 07b171fc3..b92872206 100644 --- a/src/globe/navara/lines/index.js +++ b/src/globe/navara/lines/index.js @@ -47,16 +47,24 @@ export function createNavaraLines(view) { registered.add(view); } const request = () => view.forceUpdate(); + // What CPU picks need of each line: its drawn positions and matrix. + const drawn = new Map(); const registry = createLineRegistry((options) => { const line = createLineMesh(options); + const state = { + options, + flat: new Float64Array(0), + modelMatrix: options.modelMatrix, + shown: true, + }; + drawn.set(options.id, state); const canvas = view.canvas; if (canvas) line.update(canvas.clientWidth || 1, canvas.clientHeight || 1); const handle = view.addMesh({ lit: false, [DESC_NAME]: { line } }); return { setPositions(flat) { - line.setPositions( - options.arc === 'geodesic' ? densifyGeodesic(flat) : flat, - ); + state.flat = options.arc === 'geodesic' ? densifyGeodesic(flat) : flat; + line.setPositions(state.flat); request(); }, setColor(rgba) { @@ -64,18 +72,29 @@ export function createNavaraLines(view) { request(); }, setModelMatrix(matrix) { + state.modelMatrix = matrix; line.setModelMatrix(matrix); request(); }, show(visible) { + state.shown = visible; line.show(visible); request(); }, destroy() { + drawn.delete(options.id); handle.delete(); request(); }, }; }); - return { lines: registry.lines, destroy: () => registry.destroyAll() }; + return { + lines: registry.lines, + /** Shown lines with a pickId: options, drawn positions, matrix. */ + pickables: () => + [...drawn.values()].filter( + (state) => state.shown && state.options.pickId !== undefined, + ), + destroy: () => registry.destroyAll(), + }; } diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index 54bc871c3..b73126996 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -138,6 +138,12 @@ export function createNavaraMovers(view) { return { movers: registry.movers, resolvePick: (batchId) => pickIndex.resolve(batchId), + /** The live sets' options and stores, for CPU picks. */ + pickables: () => + [...registry.sets.values()].map(({ options, store }) => ({ + options, + store, + })), destroy: () => registry.destroyAll(), }; } diff --git a/src/globe/navara/picking.js b/src/globe/navara/picking.js new file mode 100644 index 000000000..7f7a14cf4 --- /dev/null +++ b/src/globe/navara/picking.js @@ -0,0 +1,118 @@ +// Picks on Navara. A click is answered by Navara's own GPU pick of the same +// gesture (`featureClick`, a frame or two after the click, null over empty +// space): its batch id resolves to the mover set and instance (pickIds.js). +// Lines are not in the GPU pick, so an empty GPU answer still checks them +// on the CPU; no answer within the timeout falls back to the CPU pick. +// Hover (move with pick), drill and the synchronous picker.pick are CPU +// picks (src/globe/cpuPicks.js). No three. + +import { createCpuPicker } from '../cpuPicks.js'; + +export { distanceToSegment } from '../cpuPicks.js'; + +const CLICK_TIMEOUT_MS = 250; +// A GPU answer this recent belongs to the click being delivered. +const EARLY_ANSWER_MS = 100; + +/** + * @param {{ + * view: { on: Function, off: Function }, + * projector: { worldToWindow: Function, horizonVisible: Function }, + * camera: { livePosition: object }, + * movers: () => { options: object, store: object }[], + * lines: () => { options: object, flat: Float64Array, modelMatrix: number[] | null }[], + * resolveBatch: (batchId: number) => ({ set: string, instance: number, pickId: any } | null), + * setTimeout?: Function, clearTimeout?: Function, timeoutMs?: number, + * }} ports + */ +export function createNavaraPicking({ + view, + projector, + camera, + movers, + lines, + resolveBatch, + setTimeout: later = globalThis.setTimeout, + clearTimeout: cancel = globalThis.clearTimeout, + timeoutMs = CLICK_TIMEOUT_MS, + now = () => performance.now(), +}) { + const drillPick = createCpuPicker({ projector, camera, movers, lines }); + + // Clicks waiting for Navara's GPU answer, oldest first. + const waiting = []; + // Diagnostics: how clicks were answered and how long it took (ms). + const stats = { gpu: 0, timeout: 0, stale: 0, latencies: [] }; + const answered = (entry, how) => { + stats[how]++; + stats.latencies.push(now() - entry.startedAt); + if (stats.latencies.length > 200) stats.latencies.shift(); + }; + // Navara 0.2.1 answers clicks in quick succession (within a frame or so) + // with the first click's pick: a GPU answer whose instance is not drawn + // near the click is stale, and the CPU pick answers instead. + const answerFrom = (info, at) => { + const hit = info ? resolveBatch(info.batchId) : null; + if (hit && !drillPick.near(hit.set, hit.instance, at)) { + stats.stale++; + return drillPick(at, 1)[0]; + } + return hit + ? { id: hit.pickId, owner: hit.set, instance: hit.instance } + : drillPick(at, 1, { includeMovers: false })[0]; + }; + // Navara's pick helper listens to the same pointerup and, registered + // first, usually answers before the engine's click: that answer waits + // here, for the click of the same gesture. + let early = null; + const onFeatureClick = (info) => { + const next = waiting.shift(); + if (!next) { + early = { info, at: now() }; + return; + } + cancel(next.timer); + answered(next, 'gpu'); + next.resolve(answerFrom(info, next.at)); + }; + view.on('featureClick', onFeatureClick); + + return { + pick: (at) => drillPick(at, 1)[0], + drillPick: (at, limit) => drillPick(at, limit ?? Infinity), + /** The answer for a click at `at`: the GPU pick of its gesture. */ + clickPick(at) { + const gesture = early; + early = null; + if (gesture && now() - gesture.at <= EARLY_ANSWER_MS) { + const entry = { startedAt: gesture.at }; + answered(entry, 'gpu'); + return Promise.resolve(answerFrom(gesture.info, at)); + } + return new Promise((resolve) => { + const entry = { + at: { x: at.x, y: at.y }, + resolve, + timer: null, + startedAt: now(), + }; + entry.timer = later(() => { + const index = waiting.indexOf(entry); + if (index >= 0) waiting.splice(index, 1); + answered(entry, 'timeout'); + resolve(drillPick(entry.at, 1)[0]); + }, timeoutMs); + waiting.push(entry); + }); + }, + /** How clicks were answered: GPU picks, timeouts, recent latencies (ms). */ + clickStats: () => ({ ...stats, latencies: [...stats.latencies] }), + destroy() { + view.off('featureClick', onFeatureClick); + for (const entry of waiting.splice(0)) { + cancel(entry.timer); + entry.resolve(undefined); + } + }, + }; +} diff --git a/src/globe/navara/picking.test.mjs b/src/globe/navara/picking.test.mjs new file mode 100644 index 000000000..b36fda6eb --- /dev/null +++ b/src/globe/navara/picking.test.mjs @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMoverStore, normalizeMoverSetOptions } from '../movers.js'; +import { normalizeLineOptions } from '../lines.js'; +import { createNavaraPicking, distanceToSegment } from './picking.js'; + +// A projector that maps x → screen x and y → screen y, z is the depth (the +// distance from the camera at the origin along +z). +const projector = { + worldToWindow: (p) => ({ x: p.x, y: p.y }), + horizonVisible: (p) => p.z > 0, +}; +const camera = { livePosition: { x: 0, y: 0, z: 0 } }; + +function moverSet(id, rows, { pickable = true, sizePx = 20 } = {}) { + const options = normalizeMoverSetOptions({ + id, + kind: 'point', + capacity: rows.length, + pickable, + point: { sizePx }, + }); + const store = createMoverStore(options); + rows.forEach(([x, y, z, pickId], index) => + store.set(index, { position: { x, y, z }, pickId }), + ); + store.setCount(rows.length); + return { options, store }; +} + +function fakeView() { + const handlers = new Map(); + return { + on(name, fn) { + handlers.set(name, fn); + }, + off(name) { + handlers.delete(name); + }, + emit(name, value) { + handlers.get(name)?.(value); + }, + handlers, + }; +} + +const timers = () => { + const pending = []; + return { + pending, + setTimeout: (fn, ms) => { + const timer = { fn, ms }; + pending.push(timer); + return timer; + }, + clearTimeout: (timer) => { + const index = pending.indexOf(timer); + if (index >= 0) pending.splice(index, 1); + }, + }; +}; + +test('the distance from a point to a segment', () => { + assert.equal( + distanceToSegment({ x: 5, y: 3 }, { x: 0, y: 0 }, { x: 10, y: 0 }), + 3, + ); + assert.equal( + distanceToSegment({ x: -4, y: 3 }, { x: 0, y: 0 }, { x: 10, y: 0 }), + 5, + ); +}); + +test('CPU picks: pickable sets and lines under a point, nearest first', () => { + const sets = [ + moverSet('flights', [ + [100, 100, 500, 'far'], + [104, 100, 50, 'near'], + [300, 300, 10, 'elsewhere'], + ]), + moverSet('hidden', [[100, 100, -5, 'behind-the-globe']]), + moverSet('quiet', [[100, 100, 20, 'not-pickable']], { pickable: false }), + ]; + const line = { + options: normalizeLineOptions({ + id: 'orbit', + widthPx: 4, + color: [1, 1, 1, 1], + pickId: 'orbit-1', + }), + flat: new Float64Array([90, 103, 200, 110, 103, 200]), + modelMatrix: null, + }; + const picking = createNavaraPicking({ + view: fakeView(), + projector, + camera, + movers: () => sets, + lines: () => [line], + resolveBatch: () => null, + }); + const drilled = picking.drillPick({ x: 100, y: 100 }, 8); + assert.deepEqual( + drilled.map(({ id, owner }) => [id, owner]), + [ + ['near', 'flights'], + ['orbit-1', 'orbit'], + ['far', 'flights'], + ], + ); + assert.equal(drilled[0].instance, 1); + assert.deepEqual(picking.pick({ x: 100, y: 100 }).id, 'near'); + assert.equal(picking.pick({ x: 600, y: 600 }), undefined); + assert.equal(picking.drillPick({ x: 100, y: 100 }, 1).length, 1); +}); + +test('a click takes the GPU pick of its gesture, a batch id resolved to the instance', async () => { + const view = fakeView(); + const clock = timers(); + const picking = createNavaraPicking({ + view, + projector, + camera, + movers: () => [], + lines: () => [], + resolveBatch: (batchId) => + batchId === 42 ? { set: 'flights', instance: 3, pickId: 'ae01ce' } : null, + ...clock, + }); + const answer = picking.clickPick({ x: 1, y: 1 }); + view.emit('featureClick', { batchId: 42 }); + assert.deepEqual(await answer, { + id: 'ae01ce', + owner: 'flights', + instance: 3, + }); + assert.equal(clock.pending.length, 0, 'the timeout is cleared'); + // Empty space: Navara reports null; a CPU line pick could still answer. + const empty = picking.clickPick({ x: 1, y: 1 }); + view.emit('featureClick', null); + assert.equal(await empty, undefined); +}); + +test('without a GPU answer in time the click falls back to the CPU pick', async () => { + const view = fakeView(); + const clock = timers(); + const sets = [moverSet('flights', [[10, 10, 5, 'cpu']])]; + const picking = createNavaraPicking({ + view, + projector, + camera, + movers: () => sets, + lines: () => [], + resolveBatch: () => null, + ...clock, + }); + const answer = picking.clickPick({ x: 10, y: 10 }); + assert.equal(clock.pending.length, 1); + clock.pending[0].fn(); + assert.deepEqual(await answer, { id: 'cpu', owner: 'flights', instance: 0 }); + picking.destroy(); + assert.equal(view.handlers.size, 0); +}); + +test('click stats count GPU answers and timeouts with their latency', async () => { + const view = fakeView(); + const clock = timers(); + let time = 0; + const picking = createNavaraPicking({ + view, + projector, + camera, + movers: () => [], + lines: () => [], + resolveBatch: () => null, + now: () => time, + ...clock, + }); + const first = picking.clickPick({ x: 0, y: 0 }); + time = 30; + view.emit('featureClick', null); + await first; + const second = picking.clickPick({ x: 0, y: 0 }); + time = 280; + clock.pending[0].fn(); + await second; + assert.deepEqual(picking.clickStats(), { + gpu: 1, + timeout: 1, + stale: 0, + latencies: [30, 250], + }); +}); + +test("Navara's answer may come before the engine's click of the same gesture", async () => { + const view = fakeView(); + const clock = timers(); + let time = 1000; + const picking = createNavaraPicking({ + view, + projector, + camera, + movers: () => [], + lines: () => [], + resolveBatch: (batchId) => + batchId === 7 ? { set: 'sats', instance: 0, pickId: 25544 } : null, + now: () => time, + ...clock, + }); + view.emit('featureClick', { batchId: 7 }); + time += 2; + assert.deepEqual(await picking.clickPick({ x: 0, y: 0 }), { + id: 25544, + owner: 'sats', + instance: 0, + }); + assert.equal(clock.pending.length, 0); + // A stale answer (a click no listener took) is not reused. + view.emit('featureClick', { batchId: 7 }); + time += 500; + const late = picking.clickPick({ x: 0, y: 0 }); + assert.equal(clock.pending.length, 1); + view.emit('featureClick', null); + assert.equal(await late, undefined); +}); + +test('a GPU answer naming an instance drawn far from the click is stale', async () => { + const view = fakeView(); + const sets = [ + moverSet('flights', [ + [10, 10, 5, 'drawn-here'], + [400, 400, 5, 'far-away'], + ]), + ]; + const picking = createNavaraPicking({ + view, + projector, + camera, + movers: () => sets, + lines: () => [], + resolveBatch: (batchId) => + batchId === 2 + ? { set: 'flights', instance: 1, pickId: 'far-away' } + : null, + ...timers(), + }); + const answer = picking.clickPick({ x: 10, y: 10 }); + view.emit('featureClick', { batchId: 2 }); + assert.equal((await answer).id, 'drawn-here'); + assert.equal(picking.clickStats().stale, 1); +}); diff --git a/src/globe/pickDelivery.js b/src/globe/pickDelivery.js new file mode 100644 index 000000000..7282e0028 --- /dev/null +++ b/src/globe/pickDelivery.js @@ -0,0 +1,67 @@ +// Delivers input events to listeners that asked for picks (input.on with +// { pick, drill }), for every engine: one pick (and one drill, as deep as +// the deepest listener asked) per event, shared by its listeners; moves are +// coalesced to one delivery per frame with the latest position. Picks may +// be asynchronous (Navara answers clicks from its GPU pick of the gesture): +// listeners still registered when the answer arrives get it. + +const settle = (value, then) => + value && typeof value.then === 'function' ? value.then(then) : then(value); + +/** + * @param {{ pick: (position: object, type: string) => any, drill: (position: object, limit: number) => any, + * schedule: (run: () => void) => void }} ports `schedule` runs a callback + * once on the next frame (requestAnimationFrame in a browser). + */ +export function createPickDelivery({ pick, drill, schedule }) { + let pendingMove = null; + + function run(type, event, entries, isLive) { + const at = event.position ?? event.endPosition; + const depth = entries.reduce( + (most, entry) => Math.max(most, entry.drill), + 0, + ); + return settle(pick(at, type), (picked) => + settle(depth ? drill(at, depth) : undefined, (drilled) => { + for (const entry of entries) { + if (!isLive(entry)) continue; + entry.listener({ + ...event, + picked, + ...(entry.drill + ? { drilled: (drilled ?? []).slice(0, entry.drill) } + : {}), + }); + } + }), + ); + } + + return { + /** + * @param {string} type + * @param {object} event the contract event, without picks + * @param {{ listener: Function, pick: boolean, drill: number }[]} entries + * @param {{ isLive?: (entry: object) => boolean }} [options] + * @returns {void | Promise} settled once the pick listeners ran + */ + deliver(type, event, entries, { isLive = () => true } = {}) { + const pickers = []; + for (const entry of entries) + if (entry.pick) pickers.push(entry); + else if (isLive(entry)) entry.listener(event); + if (!pickers.length) return undefined; + if (type !== 'move') return run(type, event, pickers, isLive); + const scheduled = pendingMove !== null; + pendingMove = { event, pickers, isLive }; + if (!scheduled) + schedule(() => { + const latest = pendingMove; + pendingMove = null; + run('move', latest.event, latest.pickers, latest.isLive); + }); + return undefined; + }, + }; +} diff --git a/src/globe/pickDelivery.test.mjs b/src/globe/pickDelivery.test.mjs new file mode 100644 index 000000000..88f2b70e1 --- /dev/null +++ b/src/globe/pickDelivery.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createPickDelivery } from './pickDelivery.js'; + +const entry = (seen, name, options = {}) => ({ + pick: false, + drill: 0, + ...options, + listener: (event) => seen.push([name, event]), +}); + +test('plain listeners get the event; pick listeners the pick and their drill', () => { + const seen = []; + const picks = []; + const delivery = createPickDelivery({ + pick: (at) => (picks.push(at), { id: 'x' }), + drill: (at, limit) => + [{ id: 'x' }, { id: 'y' }, { id: 'z' }].slice(0, limit), + schedule: () => assert.fail('clicks are not deferred'), + }); + const event = { position: { x: 1, y: 2 } }; + delivery.deliver('click', event, [ + entry(seen, 'plain'), + entry(seen, 'pick', { pick: true }), + entry(seen, 'drill', { pick: true, drill: 2 }), + ]); + assert.deepEqual(picks, [{ x: 1, y: 2 }], 'one pick for every listener'); + assert.deepEqual(seen[0], ['plain', event]); + assert.deepEqual(seen[1][1], { ...event, picked: { id: 'x' } }); + assert.deepEqual(seen[2][1].drilled, [{ id: 'x' }, { id: 'y' }]); +}); + +test('moves with picks are delivered once per frame, with the latest position', () => { + const seen = []; + let frame = null; + const delivery = createPickDelivery({ + pick: (at) => ({ id: `at-${at.x}` }), + drill: () => [], + schedule: (run) => { + frame = run; + }, + }); + const listeners = [ + entry(seen, 'plain'), + entry(seen, 'hover', { pick: true }), + ]; + for (const x of [1, 2, 3]) + delivery.deliver( + 'move', + { startPosition: { x: x - 1, y: 0 }, endPosition: { x, y: 0 } }, + listeners, + ); + assert.equal(seen.filter(([name]) => name === 'plain').length, 3); + assert.equal(seen.filter(([name]) => name === 'hover').length, 0); + frame(); + const hovers = seen.filter(([name]) => name === 'hover'); + assert.equal(hovers.length, 1); + assert.equal(hovers[0][1].picked.id, 'at-3'); +}); + +test('asynchronous picks resolve before delivery; removed listeners get nothing', async () => { + const seen = []; + const delivery = createPickDelivery({ + pick: async () => ({ id: 'later' }), + drill: async () => [{ id: 'later' }], + schedule: (run) => run(), + }); + const kept = entry(seen, 'kept', { pick: true, drill: 1 }); + const removed = entry(seen, 'removed', { pick: true }); + const live = new Set([kept, removed]); + const done = delivery.deliver( + 'click', + { position: { x: 0, y: 0 } }, + [...live], + { + isLive: (candidate) => live.has(candidate), + }, + ); + live.delete(removed); + await done; + assert.deepEqual( + seen.map(([name]) => name), + ['kept'], + ); + assert.deepEqual(seen[0][1].drilled, [{ id: 'later' }]); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 8dc4588ab..f6232feaa 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -72,11 +72,14 @@ const along = (origin, direction, distance) => * @param {{ waitFrames: (count: number) => Promise, maxWaitFrames?: number, * mapSources?: { imagery: object[], photoreal: object }, * readRegion?: (x: number, y: number, width: number, height: number) => Uint8ClampedArray, - * makeCanvas?: (width: number, height: number) => object }} options + * makeCanvas?: (width: number, height: number) => object, + * pointer?: { click: (x: number, y: number) => Promise, move: (x: number, y: number) => Promise } }} options * `mapSources` (two imagery sources and a photoreal one the engine can * show) adds the maps checks; they leave photoreal tiles hidden. * `readRegion` (RGBA bytes of the last drawn frame in a CSS-pixel box) and * `makeCanvas` (a 2D canvas) add the pixel checks of drawn objects. + * `pointer` (a real left click and pointer move at a canvas position) + * adds the pick checks. * @returns {{ name: string, run: () => Promise }[]} */ export function createContractSuite( @@ -87,6 +90,7 @@ export function createContractSuite( mapSources = null, readRegion = null, makeCanvas = null, + pointer = null, }, ) { if (typeof waitFrames !== 'function') @@ -701,6 +705,104 @@ export function createContractSuite( ); }, ], + [ + 'input: picks on click and move payloads, drill, lines', + async () => { + await nadirView(1e6); + const at = (lon, lat, height = 20_000) => + cartesian3.fromDegrees(lon, lat, height); + const atlasCanvas = makeCanvas + ? makeCanvas(16, 16) + : { width: 16, height: 16 }; + if (makeCanvas) { + const context = atlasCanvas.getContext('2d'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, 16, 16); + } + const set = engine.movers.createSet({ + id: 'contract-picks', + kind: 'sprite', + capacity: 3, + order: 100, + depthTest: 'always', + pickable: true, + sprite: { + atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, + sizePx: 24, + }, + }); + const line = engine.lines.createLine({ + id: 'contract-pick-line', + widthPx: 8, + color: [1, 1, 0, 1], + pickId: 'line-1', + }); + const clicks = []; + const moves = []; + const removers = [ + engine.input.on('click', (event) => clicks.push(event), { + pick: true, + drill: 8, + }), + engine.input.on('move', (event) => moves.push(event), { + pick: true, + }), + ]; + try { + const a = at(20, 10); + const b = at(21, 10.5); + set.set(0, { position: a, pickId: 'a', color: [1, 0, 1, 1] }); + set.set(1, { position: b, pickId: 'b', color: [1, 0, 1, 1] }); + set.set(2, { position: b, pickId: 'c', color: [1, 0, 1, 1] }); + set.setCount(3); + set.commit(); + line.setPositions([at(19, 9), at(21, 9)]); + await settle(4); + if (!pointer) return; + const screen = (position) => engine.projector.worldToWindow(position); + const clickAt = async (position) => { + const before = clicks.length; + const { x, y } = screen(position); + await pointer.click(x, y); + await until(() => clicks.length > before, 'a click payload'); + return clicks[clicks.length - 1]; + }; + const onA = await clickAt(a); + ensure( + onA.picked?.id === 'a' && onA.picked?.owner === 'contract-picks', + `a click on sprite a picked ${JSON.stringify(onA.picked?.id)} of ${onA.picked?.owner}`, + ); + ensure(onA.picked.instance === 0, `instance ${onA.picked.instance}`); + const empty = await clickAt(at(18, 12)); + ensure( + empty.picked === undefined || empty.picked.id === undefined, + `a click on the map picked ${JSON.stringify(empty.picked?.id)}`, + ); + const onB = await clickAt(b); + const drilled = (onB.drilled ?? []).map(({ id }) => id); + ensure( + drilled.includes('b') && drilled.includes('c'), + `two sprites in one place drilled ${JSON.stringify(drilled)}`, + ); + const onLine = await clickAt(at(20, 9)); + ensure( + onLine.picked?.id === 'line-1', + `a click on the line picked ${JSON.stringify(onLine.picked?.id)}`, + ); + const target = screen(a); + const movesBefore = moves.length; + await pointer.move(target.x + 1, target.y); + await until( + () => moves.length > movesBefore && moves.at(-1).picked?.id === 'a', + 'a hover payload over sprite a', + ); + } finally { + for (const remove of removers) remove(); + set.destroy(); + line.destroy(); + } + }, + ], [ 'render: continuous mode toggles', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index 1b1bad131..6921ed985 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -21,6 +21,7 @@ import * as geo from '../../geo/index.js'; import { INPUT_TYPES, MODIFIERS, + normalizeInputOptions, SHARPEN_INITIAL, createEngineEvent, mergeSharpen, @@ -28,6 +29,7 @@ import { import { createAppClock } from '../../time/clock.js'; import { createLineRegistry } from '../lines.js'; import { createMoverRegistry } from '../movers.js'; +import { createCpuPicker } from '../cpuPicks.js'; const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; const WGS84 = ellipsoid.WGS84; @@ -804,6 +806,33 @@ export function createFakeEngine(options = {}) { size: () => ({ width, height }), }; + // Unscripted picks find the fake's own movers and lines, as Navara's CPU + // pick does (src/globe/cpuPicks.js). + const cpuPick = createCpuPicker({ + projector: { + worldToWindow: (position, result) => + engine.projector.worldToWindow(position, result), + horizonVisible: (position) => engine.projector.horizonVisible(position), + }, + camera: { + get livePosition() { + return engine.camera.livePosition; + }, + }, + movers: () => + [...moverRegistry.sets.values()].map(({ options, store }) => ({ + options, + store, + })), + lines: () => + [...fakeLines.values()] + .filter((state) => state.shown) + .map((state) => ({ + options: state.options, + flat: state.positions, + modelMatrix: state.modelMatrix, + })), + }); const engine = { kind: options.kind ?? 'cesium', host, @@ -811,11 +840,13 @@ export function createFakeEngine(options = {}) { picker: { pick(screen, w, h) { record('picker.pick', [screen, w, h]); - return scripted('pick', [screen, w, h], () => undefined); + return scripted('pick', [screen, w, h], () => cpuPick(screen, 1)[0]); }, drillPick(screen, limit, w, h) { record('picker.drillPick', [screen, limit, w, h]); - return scripted('drillPick', [screen, limit, w, h], () => []); + return scripted('drillPick', [screen, limit, w, h], () => + cpuPick(screen, limit ?? Infinity), + ); }, pickPosition(screen) { record('picker.pickPosition', [screen]); @@ -892,12 +923,12 @@ export function createFakeEngine(options = {}) { input: { on(type, listener, inputOptions = {}) { record('input.on', [type, inputOptions]); + const { modifier, pick, drill } = normalizeInputOptions( + type, + inputOptions, + ); const listeners = inputs.get(type); - if (!listeners) throw new TypeError(`Unknown input type: ${type}`); - const modifier = inputOptions.modifier; - if (modifier !== undefined && !MODIFIERS.includes(modifier)) - throw new TypeError(`Unknown input modifier: ${modifier}`); - const entry = { listener, modifier }; + const entry = { listener, modifier, pick, drill }; listeners.add(entry); return () => listeners.delete(entry); }, @@ -1008,8 +1039,22 @@ export function createFakeEngine(options = {}) { input(type, event, modifier) { const listeners = inputs.get(type); if (!listeners) throw new TypeError(`Unknown input type: ${type}`); - for (const entry of [...listeners]) - if (entry.modifier === modifier) entry.listener(event); + for (const entry of [...listeners]) { + if (entry.modifier !== modifier) continue; + if (!entry.pick) { + entry.listener(event); + continue; + } + // As the engines: the pick under the position (a move's end). + const at = event.position ?? event.endPosition; + entry.listener({ + ...event, + picked: engine.picker.pick(at), + ...(entry.drill + ? { drilled: engine.picker.drillPick(at, entry.drill) } + : {}), + }); + } }, /** Replaces a result with a value or a function of the call's arguments. */ script(name, valueOrFunction) { diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index e28ce982b..f84a0f2d1 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -497,3 +497,48 @@ test('fake lines keep their positions, colour, matrix and visibility', () => { line.destroy(); assert.equal(engine.fake.line('trail'), undefined); }); + +test('input listeners asking for picks get the pick and the drill in the payload', () => { + const engine = createFakeEngine(); + engine.fake.script('pick', (screen) => + screen.x === 10 + ? { id: 'ae01ce', owner: 'flights', instance: 3 } + : undefined, + ); + engine.fake.script('drillPick', (screen, limit) => + [{ id: 'a' }, { id: 'b' }, { id: 'c' }].slice(0, limit), + ); + const seen = []; + engine.input.on('click', (event) => seen.push(['plain', event])); + engine.input.on('click', (event) => seen.push(['pick', event]), { + pick: true, + drill: 2, + }); + engine.input.on('move', (event) => seen.push(['hover', event]), { + pick: true, + }); + engine.fake.input('click', { position: { x: 10, y: 5 } }); + engine.fake.input('move', { + startPosition: { x: 0, y: 0 }, + endPosition: { x: 10, y: 5 }, + }); + engine.fake.input('click', { position: { x: 99, y: 5 } }); + assert.deepEqual(seen[0], ['plain', { position: { x: 10, y: 5 } }]); + assert.deepEqual(seen[1][1].picked, { + id: 'ae01ce', + owner: 'flights', + instance: 3, + }); + assert.deepEqual(seen[1][1].drilled, [{ id: 'a' }, { id: 'b' }]); + assert.deepEqual(seen[2][1].picked.id, 'ae01ce'); + assert.equal(seen[2][1].drilled, undefined, 'no drill unless asked'); + assert.equal(seen[4][1].picked, undefined); + assert.throws( + () => engine.input.on('click', () => {}, { drill: -1 }), + TypeError, + ); + assert.throws( + () => engine.input.on('down', () => {}, { pick: true }), + TypeError, + ); +});