feat(globe): picks on input payloads

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.
This commit is contained in:
German Khokhlov committed 2026-10-16 18:25:12 +04:00
1 parent 86aa0bc9ba
commit b0cb8ce881
26 files changed
+1267 -68

No files matched your search

+4 -1
View File
@@ -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"
]
+9
View File
@@ -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);
});
+14 -6
View File
@@ -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,
+20 -11
View File
@@ -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;
+58
View File
@@ -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 });
});
+6
View File
@@ -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) {
+18
View File
@@ -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);
});
+16 -6
View File
@@ -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) {
+24
View File
@@ -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);
});
+21 -11
View File
@@ -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) {
+21
View File
@@ -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);
});
+44 -3
View File
@@ -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',
+9
View File
@@ -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' },
+161
View File
@@ -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;
}
+23 -4
View File
@@ -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();
+31 -12
View File
@@ -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);
+35
View File
@@ -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 }]);
});
+23 -4
View File
@@ -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(),
};
}
+6
View File
@@ -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(),
};
}
+118
View File
@@ -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);
}
},
};
}
+251
View File
@@ -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);
});
+67
View File
@@ -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<void>} 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;
},
};
}
+86
View File
@@ -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' }]);
});
+103 -1
View File
@@ -72,11 +72,14 @@ const along = (origin, direction, distance) =>
* @param {{ waitFrames: (count: number) => Promise<void>, 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<void>, move: (x: number, y: number) => Promise<void> } }} 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<void> }[]}
*/
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 () => {
+54 -9
View File
@@ -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) {
+45
View File
@@ -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,
);
});