fix(globe): Cesium picks fall through filtered results; no picks behind the horizon

Review of Task 4:
- a Cesium pick whose top result is filtered (a pickable: false set, the
  follow camera's proxy entity) took nothing; it now drills and answers the
  first result under it with an id (an object), else the first kept one
  (map tiles);
- billboards and points drawn without a depth test reach Cesium's GPU pick
  through the globe: mover instances behind the horizon (projector
  horizonVisible on the instance position) are left out of picks and
  drills, as Navara's CPU drill already did;
- the follow proxy entity (1 px point, cesium/follow.js) carries
  gevFollowProxy and is never a pick (review item 6).
This commit is contained in:
German Khokhlov committed 2026-10-16 20:59:10 +04:00
1 parent 4e195dac6b
commit a4e753d550
6 files changed
+148 -11

No files matched your search

+8 -1
View File
@@ -45,14 +45,21 @@ export function createCesiumEngine(viewer, options = {}) {
// below; the picker asks them when it picks).
let lines = null;
let movers = null;
let projectorPart = null;
const picker = createCesiumPicker(viewer, {
identify: (picked) =>
movers?.identify(picked) ?? lines?.identify(picked) ?? null,
// A mover instance behind the globe is not picked (billboards drawn
// without a depth test reach Cesium's GPU pick through it).
visible: ({ owner, instance }) => {
const position = movers?.instancePosition(owner, instance);
return position ? projectorPart.projector.horizonVisible(position) : true;
},
});
const parts = {
camera: createCesiumCamera(viewer),
picker,
projector: createCesiumProjector(viewer),
projector: (projectorPart = createCesiumProjector(viewer)),
frames: createCesiumFrames(viewer),
clock: createCesiumClock(viewer, options),
lines: (lines = createCesiumLines(viewer, {
@@ -156,3 +156,47 @@ test('the engine clock is the app clock given, latched on the viewer clock tick'
engine.destroy();
assert.equal(viewer.clock.onTick.numberOfListeners, 0);
});
test('picks leave out mover instances behind the horizon and the follow proxy', async () => {
const geo = await import('../../geo/index.js');
const viewer = createFakeViewer();
const engine = createCesiumEngine(viewer, { requestRender: () => {} });
try {
const set = engine.movers.createSet({
id: 'horizon-points',
pickable: true,
kind: 'point',
capacity: 2,
point: { sizePx: 8 },
});
// The fake camera is at x = 7e6 (over 0°, 0°): 0°E is in front, 180° behind.
set.set(0, { position: geo.cartesian3.fromDegrees(0, 1, 0) });
set.set(1, { position: geo.cartesian3.fromDegrees(180, 1, 0) });
set.setCount(2);
set.commit();
const [front, back] = viewer.primitiveList.flatMap((root) =>
collectPoints(root),
);
viewer.scene.drillPick = () => [
{ primitive: back, id: 'back' },
{ primitive: front, id: 'front' },
];
assert.deepEqual(
engine.picker.drillPick({ x: 1, y: 1 }).map(({ id }) => id),
['front'],
);
} finally {
engine.destroy();
}
});
// The PointPrimitives under a primitive collection tree.
function collectPoints(collection) {
const out = [];
for (let i = 0; i < (collection.length ?? 0); i++) {
const item = collection.get(i);
if (item instanceof Cesium.PointPrimitive) out.push(item);
else if (item?.length !== undefined) out.push(...collectPoints(item));
}
return out;
}
+2
View File
@@ -270,6 +270,8 @@ export function createCesiumFollow(viewer, { commanded }) {
// one (an entity without graphics is framed once, then left).
point: { pixelSize: 1, color: Cesium.Color.TRANSPARENT },
});
// Picks skip it (picker.js).
entity.gevFollowProxy = true;
}
state.entity = entity;
state.handle.native = options.native;
+8
View File
@@ -322,6 +322,14 @@ export function createCesiumMovers(
});
return {
movers: registry.movers,
/** Where an instance of a set is (a geo Cartesian3), or null. */
instancePosition(owner, instance) {
const entry = registry.sets.get(owner);
if (!entry || !(instance < entry.store.count)) return null;
const view = entry.store.positionsView();
const at = 3 * instance;
return { x: view[at], y: view[at + 1], z: view[at + 2] };
},
/** The primitive drawing a live mover set of this engine, or null. */
primitiveOf(set) {
const entry = set?.id ? registry.sets.get(set.id) : null;
+32 -9
View File
@@ -6,17 +6,31 @@ import { toCesium2, toGeo3 } from './convert.js';
/**
* @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.
* @param {{
* identify?: (picked: object) => ({ owner: string, instance?: number, pickable?: boolean } | null),
* visible?: (known: { owner: string, instance?: number }) => boolean,
* }} [options]
* `identify` names the mover set or line (and instance) a pick belongs to;
* `visible` says whether that instance is in front of the horizon
* (Cesium's GPU pick sees billboards drawn without a depth test through
* the globe).
* @returns {{ picker: object, destroy: () => void }}
*/
export function createCesiumPicker(viewer, { identify = () => null } = {}) {
export function createCesiumPicker(
viewer,
{ identify = () => null, visible = () => true } = {},
) {
const normalise = (picked) => {
if (picked == null) return undefined;
// The follow camera's proxy entity (follow.js) is not an object.
if (picked.id?.gevFollowProxy) return undefined;
const known = identify(picked);
// A mover set created with pickable: false is not picked (Cesium's GPU
// pick sees every primitive; Navara's only the pickable ones).
// pick sees every primitive; Navara's only the pickable ones), nor an
// instance behind the horizon.
if (known?.pickable === false) return undefined;
if (known && known.instance !== undefined && !visible(known))
return undefined;
return {
id: picked.id,
primitive: picked.primitive,
@@ -33,15 +47,24 @@ export function createCesiumPicker(viewer, { identify = () => null } = {}) {
};
};
const { scene } = viewer;
const drill = (screen, limit, width, height) =>
scene
.drillPick(toCesium2(screen), limit, width, height)
.map(normalise)
.filter(Boolean);
const picker = {
pick(screen, width, height) {
return normalise(scene.pick(toCesium2(screen), width, height));
const top = scene.pick(toCesium2(screen), width, height);
if (top == null) return undefined;
const picked = normalise(top);
if (picked) return picked;
// The top result is filtered: what lies under it, the first with an
// id (an object), else the first kept (map tiles).
const under = drill(screen, undefined, width, height);
return under.find((entry) => entry.id !== undefined) ?? under[0];
},
drillPick(screen, limit, width, height) {
return scene
.drillPick(toCesium2(screen), limit, width, height)
.map(normalise)
.filter(Boolean);
return drill(screen, limit, width, height);
},
pickPosition(screen) {
if (!scene.pickPositionSupported) return undefined;
+54 -1
View File
@@ -149,9 +149,62 @@ test('picks of a mover set made with pickable: false are dropped', () => {
? { owner: 'quiet', instance: 0, pickable: false }
: null,
});
assert.equal(picker.pick({ x: 1, y: 2 }), undefined);
// The pick falls through to what is under the quiet set.
assert.equal(picker.pick({ x: 1, y: 2 }).id, 'other');
assert.deepEqual(
picker.drillPick({ x: 1, y: 2 }, 5).map(({ id }) => id),
['other'],
);
});
test('a filtered top pick falls through to the first pick under it with an id', () => {
const viewer = createFakeViewer();
const quiet = { primitive: 'quiet-billboard', id: 'q' };
const proxy = { primitive: 'proxy-point', id: { gevFollowProxy: true } };
const tile = { primitive: 'tileset', content: {} };
const flight = { primitive: 'flight-billboard', id: 'ae01ce' };
const { picker } = createCesiumPicker(viewer, {
identify: (picked) =>
picked === quiet
? { owner: 'quiet', instance: 0, pickable: false }
: picked === flight
? { owner: 'flights', instance: 3, pickable: true }
: null,
});
viewer.scene.pick = () => quiet;
viewer.scene.drillPick = () => [quiet, proxy, tile, flight];
const picked = picker.pick(geo.cartesian2.create(1, 1));
assert.equal(picked.id, 'ae01ce');
assert.equal(picked.owner, 'flights');
assert.deepEqual(
picker.drillPick(geo.cartesian2.create(1, 1)).map((p) => p.primitive),
['tileset', 'flight-billboard'],
'the follow proxy and pickable: false sets are never picked',
);
// Nothing under it with an id: the first kept result (a tile).
viewer.scene.drillPick = () => [quiet, tile];
assert.equal(picker.pick(geo.cartesian2.create(1, 1)).primitive, 'tileset');
viewer.scene.pick = () => proxy;
viewer.scene.drillPick = () => [proxy];
assert.equal(picker.pick(geo.cartesian2.create(1, 1)), undefined);
});
test('drills leave out mover instances behind the horizon', () => {
const viewer = createFakeViewer();
const near = { primitive: 'near', id: 'n' };
const far = { primitive: 'far', id: 'f' };
const { picker } = createCesiumPicker(viewer, {
identify: (picked) => ({
owner: 'flights',
instance: picked === near ? 0 : 1,
}),
visible: (known) => known.instance === 0,
});
viewer.scene.drillPick = () => [far, near];
assert.deepEqual(
picker.drillPick(geo.cartesian2.create(1, 1)).map((p) => p.id),
['n'],
);
viewer.scene.pick = () => far;
assert.equal(picker.pick(geo.cartesian2.create(1, 1)).id, 'n');
});