mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
86aa0bc9ba
commit
b0cb8ce881
26 files changed
+1267
-68
No files matched your search
@@ -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"
|
||||
]
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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' }]);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user