From a4e753d55012a8b046345bd704dfb7d27e2ffe34 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 20:58:36 +0400 Subject: [PATCH] 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). --- src/globe/cesium/createCesiumEngine.js | 9 +++- src/globe/cesium/createCesiumEngine.test.mjs | 44 ++++++++++++++++ src/globe/cesium/follow.js | 2 + src/globe/cesium/movers.js | 8 +++ src/globe/cesium/picker.js | 41 +++++++++++---- src/globe/cesium/picker.test.mjs | 55 +++++++++++++++++++- 6 files changed, 148 insertions(+), 11 deletions(-) diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index ae27c56fd..3aaf2445e 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -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, { diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index 7869a9336..a98426081 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -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; +} diff --git a/src/globe/cesium/follow.js b/src/globe/cesium/follow.js index bb8391601..5e6c983fc 100644 --- a/src/globe/cesium/follow.js +++ b/src/globe/cesium/follow.js @@ -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; diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index 8e56b5d67..eef7b9972 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -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; diff --git a/src/globe/cesium/picker.js b/src/globe/cesium/picker.js index 0d2bc4a77..20ee5a95c 100644 --- a/src/globe/cesium/picker.js +++ b/src/globe/cesium/picker.js @@ -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; diff --git a/src/globe/cesium/picker.test.mjs b/src/globe/cesium/picker.test.mjs index 3b6e31bd8..ca10efc44 100644 --- a/src/globe/cesium/picker.test.mjs +++ b/src/globe/cesium/picker.test.mjs @@ -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'); +});