mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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/bench/syntheticFlights.test.mjs",
|
||||||
"src/globe/lines.test.mjs",
|
"src/globe/lines.test.mjs",
|
||||||
"src/globe/cesium/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');
|
unregisterPickOwner('bikeshare');
|
||||||
assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false);
|
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 canvas = viewer.canvas ?? viewer.scene.canvas;
|
||||||
const render = createCesiumRender(viewer, options);
|
const render = createCesiumRender(viewer, options);
|
||||||
const credits = createCesiumCredits(viewer);
|
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 = {
|
const parts = {
|
||||||
camera: createCesiumCamera(viewer),
|
camera: createCesiumCamera(viewer),
|
||||||
picker: createCesiumPicker(viewer),
|
picker,
|
||||||
projector: createCesiumProjector(viewer),
|
projector: createCesiumProjector(viewer),
|
||||||
frames: createCesiumFrames(viewer),
|
frames: createCesiumFrames(viewer),
|
||||||
clock: createCesiumClock(viewer, options),
|
clock: createCesiumClock(viewer, options),
|
||||||
lines: createCesiumLines(viewer, {
|
lines: (lines = createCesiumLines(viewer, {
|
||||||
requestRender: (reason) => render.render.request(reason),
|
requestRender: (reason) => render.render.request(reason),
|
||||||
...(options.lineFactories ? { factories: options.lineFactories } : {}),
|
...(options.lineFactories ? { factories: options.lineFactories } : {}),
|
||||||
}),
|
})),
|
||||||
movers: createCesiumMovers(viewer, {
|
movers: (movers = createCesiumMovers(viewer, {
|
||||||
requestRender: (reason) => render.render.request(reason),
|
requestRender: (reason) => render.render.request(reason),
|
||||||
...(options.moverFactories ? { factories: options.moverFactories } : {}),
|
...(options.moverFactories ? { factories: options.moverFactories } : {}),
|
||||||
}),
|
})),
|
||||||
render,
|
render,
|
||||||
surface: createCesiumSurface(viewer),
|
surface: createCesiumSurface(viewer),
|
||||||
input: createCesiumInput(viewer, options),
|
input: createCesiumInput(viewer, { ...options, picker: picker.picker }),
|
||||||
credits,
|
credits,
|
||||||
maps: createCesiumMaps(viewer, {
|
maps: createCesiumMaps(viewer, {
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
+20
-11
@@ -2,7 +2,8 @@
|
|||||||
// one action per (type, modifier); the engine fans it out to its listeners.
|
// one action per (type, modifier); the engine fans it out to its listeners.
|
||||||
|
|
||||||
import * as Cesium from 'cesium';
|
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';
|
import { toGeo2 } from './convert.js';
|
||||||
|
|
||||||
const TYPES = {
|
const TYPES = {
|
||||||
@@ -28,26 +29,34 @@ const toEvent = (event) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Cesium.Viewer} viewer
|
* @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 }}
|
* @returns {{ input: object, destroy: () => void }}
|
||||||
*/
|
*/
|
||||||
export function createCesiumInput(
|
export function createCesiumInput(
|
||||||
viewer,
|
viewer,
|
||||||
{
|
{
|
||||||
createEventHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
|
createEventHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
|
||||||
|
picker = null,
|
||||||
|
scheduleFrame = (run) => requestAnimationFrame(run),
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const { scene } = viewer;
|
const { scene } = viewer;
|
||||||
let handler = null;
|
let handler = null;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
const groups = new Map();
|
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 = {
|
const input = {
|
||||||
on(type, listener, { modifier } = {}) {
|
on(type, listener, options = {}) {
|
||||||
if (!INPUT_TYPES.includes(type))
|
const { modifier, pick, drill } = normalizeInputOptions(type, options);
|
||||||
throw new TypeError(`Unknown input type: ${type}`);
|
|
||||||
if (modifier !== undefined && !MODIFIERS.includes(modifier))
|
|
||||||
throw new TypeError(`Unknown input modifier: ${modifier}`);
|
|
||||||
if (destroyed) return () => {};
|
if (destroyed) return () => {};
|
||||||
handler ??= createEventHandler(scene.canvas);
|
handler ??= createEventHandler(scene.canvas);
|
||||||
const key = `${type}:${modifier}`;
|
const key = `${type}:${modifier}`;
|
||||||
@@ -57,15 +66,15 @@ export function createCesiumInput(
|
|||||||
groups.set(key, listeners);
|
groups.set(key, listeners);
|
||||||
handler.setInputAction(
|
handler.setInputAction(
|
||||||
(event) => {
|
(event) => {
|
||||||
const converted = toEvent(event);
|
delivery.deliver(type, toEvent(event), [...listeners], {
|
||||||
for (const entry of [...listeners])
|
isLive: (entry) => listeners.has(entry),
|
||||||
if (listeners.has(entry)) entry.listener(converted);
|
});
|
||||||
},
|
},
|
||||||
TYPES[type],
|
TYPES[type],
|
||||||
KEYS[modifier],
|
KEYS[modifier],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const entry = { listener };
|
const entry = { listener, pick, drill };
|
||||||
listeners.add(entry);
|
listeners.add(entry);
|
||||||
return () => {
|
return () => {
|
||||||
if (!listeners.delete(entry) || listeners.size) return;
|
if (!listeners.delete(entry) || listeners.size) return;
|
||||||
|
|||||||
@@ -136,3 +136,61 @@ test('on() after destroy() creates no handler', () => {
|
|||||||
destroy();
|
destroy();
|
||||||
assert.equal(handlers.length, 0);
|
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,
|
show: shown,
|
||||||
asynchronous: false,
|
asynchronous: false,
|
||||||
});
|
});
|
||||||
|
primitive.gevLine = options.id;
|
||||||
root.add(primitive);
|
root.add(primitive);
|
||||||
requestRender('lines');
|
requestRender('lines');
|
||||||
},
|
},
|
||||||
@@ -203,6 +204,11 @@ export function createCesiumLines(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
lines: registry.lines,
|
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() {
|
destroy() {
|
||||||
registry.destroyAll();
|
registry.destroyAll();
|
||||||
if (root) {
|
if (root) {
|
||||||
|
|||||||
@@ -125,3 +125,21 @@ test('dash kinds pick their materials', () => {
|
|||||||
assert.equal(made[2].appearance.material.type, 'Color');
|
assert.equal(made[2].appearance.material.type, 'Color');
|
||||||
assert.equal(made[2].depthFailAppearance, undefined);
|
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;
|
return scratchColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
function spritePart(scene, options, store, factories, requestRender) {
|
function spritePart(scene, options, store, factories, requestRender, owners) {
|
||||||
const collection = factories.billboards(scene);
|
const collection = factories.billboards(scene);
|
||||||
const { sprite, depthTest, id } = options;
|
const { sprite, depthTest, id } = options;
|
||||||
const style = {
|
const style = {
|
||||||
@@ -108,6 +108,7 @@ function spritePart(scene, options, store, factories, requestRender) {
|
|||||||
if (!billboard) {
|
if (!billboard) {
|
||||||
billboard = collection.add({ ...style });
|
billboard = collection.add({ ...style });
|
||||||
billboards[index] = billboard;
|
billboards[index] = billboard;
|
||||||
|
owners.set(billboard, { owner: id, instance: index });
|
||||||
shownCell[index] = -1;
|
shownCell[index] = -1;
|
||||||
}
|
}
|
||||||
billboard.position = readPosition(store, index);
|
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 collection = factories.points();
|
||||||
const { point, depthTest } = options;
|
const { point, depthTest } = options;
|
||||||
const style = {
|
const style = {
|
||||||
@@ -169,6 +170,7 @@ function pointPart(options, store, factories, requestRender) {
|
|||||||
if (!item) {
|
if (!item) {
|
||||||
item = collection.add({ ...style });
|
item = collection.add({ ...style });
|
||||||
points[index] = item;
|
points[index] = item;
|
||||||
|
owners.set(item, { owner: options.id, instance: index });
|
||||||
}
|
}
|
||||||
item.position = readPosition(store, index);
|
item.position = readPosition(store, index);
|
||||||
item.pixelSize = point.sizePx * store.scales[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 collection = factories.models();
|
||||||
const { model } = options;
|
const { model } = options;
|
||||||
const blendMode =
|
const blendMode =
|
||||||
@@ -235,6 +237,7 @@ function modelPart(options, store, factories, requestRender) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
models[index] = item;
|
models[index] = item;
|
||||||
|
owners.set(item, { owner: options.id, instance: index });
|
||||||
place(item, index);
|
place(item, index);
|
||||||
collection.add(item);
|
collection.add(item);
|
||||||
requestRender('movers');
|
requestRender('movers');
|
||||||
@@ -273,6 +276,8 @@ export function createCesiumMovers(
|
|||||||
const { scene } = viewer;
|
const { scene } = viewer;
|
||||||
let root = null;
|
let root = null;
|
||||||
const ordered = [];
|
const ordered = [];
|
||||||
|
// Billboard, point or model → its set and instance, for picks.
|
||||||
|
const owners = new WeakMap();
|
||||||
const rootCollection = () => {
|
const rootCollection = () => {
|
||||||
if (!root) {
|
if (!root) {
|
||||||
root = new Cesium.PrimitiveCollection({ destroyPrimitives: true });
|
root = new Cesium.PrimitiveCollection({ destroyPrimitives: true });
|
||||||
@@ -283,10 +288,10 @@ export function createCesiumMovers(
|
|||||||
const registry = createMoverRegistry((options, store) => {
|
const registry = createMoverRegistry((options, store) => {
|
||||||
const part =
|
const part =
|
||||||
options.kind === 'sprite'
|
options.kind === 'sprite'
|
||||||
? spritePart(scene, options, store, make, requestRender)
|
? spritePart(scene, options, store, make, requestRender, owners)
|
||||||
: options.kind === 'point'
|
: options.kind === 'point'
|
||||||
? pointPart(options, store, make, requestRender)
|
? pointPart(options, store, make, requestRender, owners)
|
||||||
: modelPart(options, store, make, requestRender);
|
: modelPart(options, store, make, requestRender, owners);
|
||||||
const { primitive } = part;
|
const { primitive } = part;
|
||||||
primitive.gevMoverSet = options.id;
|
primitive.gevMoverSet = options.id;
|
||||||
// Later (higher) order draws later: after every set of lower or equal order.
|
// Later (higher) order draws later: after every set of lower or equal order.
|
||||||
@@ -308,6 +313,11 @@ export function createCesiumMovers(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
movers: registry.movers,
|
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() {
|
destroy() {
|
||||||
registry.destroyAll();
|
registry.destroyAll();
|
||||||
if (root) {
|
if (root) {
|
||||||
|
|||||||
@@ -221,3 +221,27 @@ test('models load per instance and follow position and orientation', async () =>
|
|||||||
set.destroy();
|
set.destroy();
|
||||||
assert.equal(models.destroyed, true);
|
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';
|
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 {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 }}
|
* @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 { scene } = viewer;
|
||||||
const picker = {
|
const picker = {
|
||||||
pick(screen, width, height) {
|
pick(screen, width, height) {
|
||||||
|
|||||||
@@ -113,3 +113,24 @@ test('pickGlobe asks the globe along the camera pick ray and returns geo', () =>
|
|||||||
scene.globe = undefined;
|
scene.globe = undefined;
|
||||||
assert.equal(picker.pickGlobe(geo.cartesian2.create(1, 1)), 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 {'click'|'doubleClick'|'move'|'down'|'up'} InputType
|
||||||
* @typedef {'ctrl'|'shift'|'alt'} InputModifier
|
* @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
|
* @typedef {object} GlobeProjector
|
||||||
* @property {(position: Cartesian3, result?: Cartesian2) => (Cartesian2 | undefined)} worldToWindow
|
* @property {(position: Cartesian3, result?: Cartesian2) => (Cartesian2 | undefined)} worldToWindow
|
||||||
@@ -173,8 +189,11 @@
|
|||||||
* The sharpening last set (a copy).
|
* The sharpening last set (a copy).
|
||||||
*
|
*
|
||||||
* @typedef {object} PickResult
|
* @typedef {object} PickResult
|
||||||
* @property {any} id The picked entity or object id; undefined for map tiles.
|
* @property {any} id The picked entity or object id (a mover's or line's
|
||||||
* @property {any} primitive
|
* 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
|
* @property {any} [content] 3D tile content when map tiles were picked (what
|
||||||
* a surface-pick check reads), else undefined.
|
* a surface-pick check reads), else undefined.
|
||||||
* @property {any} [object] The engine's own pick result, for engine-specific code.
|
* @property {any} [object] The engine's own pick result, for engine-specific code.
|
||||||
@@ -362,6 +381,28 @@ export const INPUT_TYPES = Object.freeze([
|
|||||||
'up',
|
'up',
|
||||||
]);
|
]);
|
||||||
export const MODIFIERS = Object.freeze(['ctrl', 'shift', 'alt']);
|
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([
|
export const FLIGHT_EASINGS = Object.freeze([
|
||||||
'cubicInOut',
|
'cubicInOut',
|
||||||
'sinusoidalInOut',
|
'sinusoidalInOut',
|
||||||
|
|||||||
@@ -228,6 +228,15 @@ test('the fake engine passes the engine contract suite', async (t) => {
|
|||||||
const engine = createFakeEngine();
|
const engine = createFakeEngine();
|
||||||
const checks = createContractSuite(engine, {
|
const checks = createContractSuite(engine, {
|
||||||
waitFrames: async (count) => engine.fake.frames(count),
|
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: {
|
mapSources: {
|
||||||
imagery: [{ kind: 'osm' }, { kind: 'esri-imagery' }],
|
imagery: [{ kind: 'osm' }, { kind: 'esri-imagery' }],
|
||||||
photoreal: { kind: 'photoreal' },
|
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 { createNavaraClock } from './clock.js';
|
||||||
import { createNavaraCredits } from './credits.js';
|
import { createNavaraCredits } from './credits.js';
|
||||||
import { createNavaraInput } from './input.js';
|
import { createNavaraInput } from './input.js';
|
||||||
|
import { createNavaraPicking } from './picking.js';
|
||||||
import { createNavaraMaps } from './maps.js';
|
import { createNavaraMaps } from './maps.js';
|
||||||
import { createNavaraSurface } from './surface.js';
|
import { createNavaraSurface } from './surface.js';
|
||||||
import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js';
|
import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js';
|
||||||
@@ -163,9 +164,24 @@ export function createNavaraEngine(
|
|||||||
for (const [name, handler] of Object.entries(handlers))
|
for (const [name, handler] of Object.entries(handlers))
|
||||||
view.on(name, handler);
|
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, {
|
const input = createNavaraInput(view, {
|
||||||
// A gesture is the user, not a new camera owner: no camera.commanded.
|
// A gesture is the user, not a new camera owner: no camera.commanded.
|
||||||
onCameraInput: () => model.stopFlight(),
|
onCameraInput: () => model.stopFlight(),
|
||||||
|
picker: picking,
|
||||||
});
|
});
|
||||||
const credits = createNavaraCredits(view);
|
const credits = createNavaraCredits(view);
|
||||||
const maps = createNavaraMaps(view, fetch ? { fetch } : {});
|
const maps = createNavaraMaps(view, fetch ? { fetch } : {});
|
||||||
@@ -182,10 +198,12 @@ export function createNavaraEngine(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const picker = {
|
const picker = {
|
||||||
// Navara picks its own meshes only on click gestures (featureClick); there
|
// The CPU pick of movers and lines (picking.js); 3D tiles carry no ids.
|
||||||
// is no synchronous pick, and 3D tiles carry no ids. No layers yet.
|
// Clicks with { pick } get Navara's GPU pick of the gesture instead.
|
||||||
pick: () => undefined,
|
pick: (screen) => picking.pick(screen),
|
||||||
drillPick: () => [],
|
drillPick: (screen, limit) => picking.drillPick(screen, limit),
|
||||||
|
// Navara-only diagnostics: how clicks with { pick } were answered.
|
||||||
|
clickStats: () => picking.clickStats(),
|
||||||
pickPosition(screen) {
|
pickPosition(screen) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const hit = view.pickDepthPosition(
|
const hit = view.pickDepthPosition(
|
||||||
@@ -262,6 +280,7 @@ export function createNavaraEngine(
|
|||||||
view.off(name, handler);
|
view.off(name, handler);
|
||||||
model.destroy();
|
model.destroy();
|
||||||
input.destroy();
|
input.destroy();
|
||||||
|
picking.destroy();
|
||||||
credits.destroy();
|
credits.destroy();
|
||||||
maps.destroy();
|
maps.destroy();
|
||||||
sharpenPort?.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;
|
// 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.
|
// `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 CLICK_PIXEL_TOLERANCE = 5; // Cesium's _clickPixelTolerance
|
||||||
const SCREEN_EVENTS = ['pointerdown', 'pointerup', 'pointermove', 'dblclick'];
|
const SCREEN_EVENTS = ['pointerdown', 'pointerup', 'pointermove', 'dblclick'];
|
||||||
@@ -23,10 +24,21 @@ function modifierOf(event) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ canvas: HTMLCanvasElement, camera: { options: object } }} view
|
* @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 }}
|
* @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 { canvas } = view;
|
||||||
const listeners = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
|
const listeners = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
|
||||||
let attached = false;
|
let attached = false;
|
||||||
@@ -36,15 +48,25 @@ export function createNavaraInput(view, { onCameraInput = () => {} } = {}) {
|
|||||||
let downAt = null;
|
let downAt = null;
|
||||||
let last = 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 positionOf = (event) => {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||||
};
|
};
|
||||||
const emit = (type, event, payload) => {
|
const emit = (type, event, payload) => {
|
||||||
const modifier = modifierOf(event);
|
const modifier = modifierOf(event);
|
||||||
for (const entry of [...listeners.get(type)])
|
const group = listeners.get(type);
|
||||||
if (entry.modifier === modifier && listeners.get(type).has(entry))
|
delivery.deliver(
|
||||||
entry.listener(payload);
|
type,
|
||||||
|
payload,
|
||||||
|
[...group].filter((entry) => entry.modifier === modifier),
|
||||||
|
{ isLive: (entry) => group.has(entry) },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onScreenEvent = (event) => {
|
const onScreenEvent = (event) => {
|
||||||
@@ -90,18 +112,15 @@ export function createNavaraInput(view, { onCameraInput = () => {} } = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
on(type, listener, { modifier } = {}) {
|
on(type, listener, options = {}) {
|
||||||
if (!INPUT_TYPES.includes(type))
|
const { modifier, pick, drill } = normalizeInputOptions(type, options);
|
||||||
throw new TypeError(`Unknown input type: ${type}`);
|
|
||||||
if (modifier !== undefined && !MODIFIERS.includes(modifier))
|
|
||||||
throw new TypeError(`Unknown input modifier: ${modifier}`);
|
|
||||||
if (destroyed) return () => {};
|
if (destroyed) return () => {};
|
||||||
if (!attached) {
|
if (!attached) {
|
||||||
attached = true;
|
attached = true;
|
||||||
for (const name of SCREEN_EVENTS)
|
for (const name of SCREEN_EVENTS)
|
||||||
canvas.addEventListener(name, onScreenEvent);
|
canvas.addEventListener(name, onScreenEvent);
|
||||||
}
|
}
|
||||||
const entry = { listener, modifier };
|
const entry = { listener, modifier, pick, drill };
|
||||||
listeners.get(type).add(entry);
|
listeners.get(type).add(entry);
|
||||||
return () => {
|
return () => {
|
||||||
listeners.get(type).delete(entry);
|
listeners.get(type).delete(entry);
|
||||||
|
|||||||
@@ -165,3 +165,38 @@ test('setCameraInputs merges into the camera options set through the engine', ()
|
|||||||
enableTilt: false,
|
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);
|
registered.add(view);
|
||||||
}
|
}
|
||||||
const request = () => view.forceUpdate();
|
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 registry = createLineRegistry((options) => {
|
||||||
const line = createLineMesh(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;
|
const canvas = view.canvas;
|
||||||
if (canvas) line.update(canvas.clientWidth || 1, canvas.clientHeight || 1);
|
if (canvas) line.update(canvas.clientWidth || 1, canvas.clientHeight || 1);
|
||||||
const handle = view.addMesh({ lit: false, [DESC_NAME]: { line } });
|
const handle = view.addMesh({ lit: false, [DESC_NAME]: { line } });
|
||||||
return {
|
return {
|
||||||
setPositions(flat) {
|
setPositions(flat) {
|
||||||
line.setPositions(
|
state.flat = options.arc === 'geodesic' ? densifyGeodesic(flat) : flat;
|
||||||
options.arc === 'geodesic' ? densifyGeodesic(flat) : flat,
|
line.setPositions(state.flat);
|
||||||
);
|
|
||||||
request();
|
request();
|
||||||
},
|
},
|
||||||
setColor(rgba) {
|
setColor(rgba) {
|
||||||
@@ -64,18 +72,29 @@ export function createNavaraLines(view) {
|
|||||||
request();
|
request();
|
||||||
},
|
},
|
||||||
setModelMatrix(matrix) {
|
setModelMatrix(matrix) {
|
||||||
|
state.modelMatrix = matrix;
|
||||||
line.setModelMatrix(matrix);
|
line.setModelMatrix(matrix);
|
||||||
request();
|
request();
|
||||||
},
|
},
|
||||||
show(visible) {
|
show(visible) {
|
||||||
|
state.shown = visible;
|
||||||
line.show(visible);
|
line.show(visible);
|
||||||
request();
|
request();
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
|
drawn.delete(options.id);
|
||||||
handle.delete();
|
handle.delete();
|
||||||
request();
|
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 {
|
return {
|
||||||
movers: registry.movers,
|
movers: registry.movers,
|
||||||
resolvePick: (batchId) => pickIndex.resolve(batchId),
|
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(),
|
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,
|
* @param {{ waitFrames: (count: number) => Promise<void>, maxWaitFrames?: number,
|
||||||
* mapSources?: { imagery: object[], photoreal: object },
|
* mapSources?: { imagery: object[], photoreal: object },
|
||||||
* readRegion?: (x: number, y: number, width: number, height: number) => Uint8ClampedArray,
|
* 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
|
* `mapSources` (two imagery sources and a photoreal one the engine can
|
||||||
* show) adds the maps checks; they leave photoreal tiles hidden.
|
* show) adds the maps checks; they leave photoreal tiles hidden.
|
||||||
* `readRegion` (RGBA bytes of the last drawn frame in a CSS-pixel box) and
|
* `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.
|
* `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> }[]}
|
* @returns {{ name: string, run: () => Promise<void> }[]}
|
||||||
*/
|
*/
|
||||||
export function createContractSuite(
|
export function createContractSuite(
|
||||||
@@ -87,6 +90,7 @@ export function createContractSuite(
|
|||||||
mapSources = null,
|
mapSources = null,
|
||||||
readRegion = null,
|
readRegion = null,
|
||||||
makeCanvas = null,
|
makeCanvas = null,
|
||||||
|
pointer = null,
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (typeof waitFrames !== 'function')
|
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',
|
'render: continuous mode toggles',
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import * as geo from '../../geo/index.js';
|
|||||||
import {
|
import {
|
||||||
INPUT_TYPES,
|
INPUT_TYPES,
|
||||||
MODIFIERS,
|
MODIFIERS,
|
||||||
|
normalizeInputOptions,
|
||||||
SHARPEN_INITIAL,
|
SHARPEN_INITIAL,
|
||||||
createEngineEvent,
|
createEngineEvent,
|
||||||
mergeSharpen,
|
mergeSharpen,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
import { createAppClock } from '../../time/clock.js';
|
import { createAppClock } from '../../time/clock.js';
|
||||||
import { createLineRegistry } from '../lines.js';
|
import { createLineRegistry } from '../lines.js';
|
||||||
import { createMoverRegistry } from '../movers.js';
|
import { createMoverRegistry } from '../movers.js';
|
||||||
|
import { createCpuPicker } from '../cpuPicks.js';
|
||||||
|
|
||||||
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
|
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
|
||||||
const WGS84 = ellipsoid.WGS84;
|
const WGS84 = ellipsoid.WGS84;
|
||||||
@@ -804,6 +806,33 @@ export function createFakeEngine(options = {}) {
|
|||||||
size: () => ({ width, height }),
|
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 = {
|
const engine = {
|
||||||
kind: options.kind ?? 'cesium',
|
kind: options.kind ?? 'cesium',
|
||||||
host,
|
host,
|
||||||
@@ -811,11 +840,13 @@ export function createFakeEngine(options = {}) {
|
|||||||
picker: {
|
picker: {
|
||||||
pick(screen, w, h) {
|
pick(screen, w, h) {
|
||||||
record('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) {
|
drillPick(screen, limit, w, h) {
|
||||||
record('picker.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) {
|
pickPosition(screen) {
|
||||||
record('picker.pickPosition', [screen]);
|
record('picker.pickPosition', [screen]);
|
||||||
@@ -892,12 +923,12 @@ export function createFakeEngine(options = {}) {
|
|||||||
input: {
|
input: {
|
||||||
on(type, listener, inputOptions = {}) {
|
on(type, listener, inputOptions = {}) {
|
||||||
record('input.on', [type, inputOptions]);
|
record('input.on', [type, inputOptions]);
|
||||||
|
const { modifier, pick, drill } = normalizeInputOptions(
|
||||||
|
type,
|
||||||
|
inputOptions,
|
||||||
|
);
|
||||||
const listeners = inputs.get(type);
|
const listeners = inputs.get(type);
|
||||||
if (!listeners) throw new TypeError(`Unknown input type: ${type}`);
|
const entry = { listener, modifier, pick, drill };
|
||||||
const modifier = inputOptions.modifier;
|
|
||||||
if (modifier !== undefined && !MODIFIERS.includes(modifier))
|
|
||||||
throw new TypeError(`Unknown input modifier: ${modifier}`);
|
|
||||||
const entry = { listener, modifier };
|
|
||||||
listeners.add(entry);
|
listeners.add(entry);
|
||||||
return () => listeners.delete(entry);
|
return () => listeners.delete(entry);
|
||||||
},
|
},
|
||||||
@@ -1008,8 +1039,22 @@ export function createFakeEngine(options = {}) {
|
|||||||
input(type, event, modifier) {
|
input(type, event, modifier) {
|
||||||
const listeners = inputs.get(type);
|
const listeners = inputs.get(type);
|
||||||
if (!listeners) throw new TypeError(`Unknown input type: ${type}`);
|
if (!listeners) throw new TypeError(`Unknown input type: ${type}`);
|
||||||
for (const entry of [...listeners])
|
for (const entry of [...listeners]) {
|
||||||
if (entry.modifier === modifier) entry.listener(event);
|
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. */
|
/** Replaces a result with a value or a function of the call's arguments. */
|
||||||
script(name, valueOrFunction) {
|
script(name, valueOrFunction) {
|
||||||
|
|||||||
@@ -497,3 +497,48 @@ test('fake lines keep their positions, colour, matrix and visibility', () => {
|
|||||||
line.destroy();
|
line.destroy();
|
||||||
assert.equal(engine.fake.line('trail'), undefined);
|
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