diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index 2cd5d3e5f..2411b7c85 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -316,7 +316,8 @@ export function createCesiumMovers( /** 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; + const entry = known && registry.sets.get(known.owner); + return entry ? { ...known, pickable: entry.options.pickable } : null; }, destroy() { registry.destroyAll(); diff --git a/src/globe/cesium/movers.test.mjs b/src/globe/cesium/movers.test.mjs index 72599a2d7..edbf6991c 100644 --- a/src/globe/cesium/movers.test.mjs +++ b/src/globe/cesium/movers.test.mjs @@ -240,6 +240,7 @@ test('a pick of a billboard or point names its set and instance', async () => { assert.deepEqual(identify({ primitive: second, id: 25544 }), { owner: 'sats', instance: 1, + pickable: false, }); assert.equal(identify({ primitive: {}, id: 'x' }), null); set.destroy(); diff --git a/src/globe/cesium/picker.js b/src/globe/cesium/picker.js index 8aa8fe970..0d2bc4a77 100644 --- a/src/globe/cesium/picker.js +++ b/src/globe/cesium/picker.js @@ -14,6 +14,9 @@ export function createCesiumPicker(viewer, { identify = () => null } = {}) { const normalise = (picked) => { if (picked == null) 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). + if (known?.pickable === false) return undefined; return { id: picked.id, primitive: picked.primitive, @@ -37,7 +40,8 @@ export function createCesiumPicker(viewer, { identify = () => null } = {}) { drillPick(screen, limit, width, height) { return scene .drillPick(toCesium2(screen), limit, width, height) - .map(normalise); + .map(normalise) + .filter(Boolean); }, pickPosition(screen) { if (!scene.pickPositionSupported) return undefined; diff --git a/src/globe/cesium/picker.test.mjs b/src/globe/cesium/picker.test.mjs index dc546f5d1..3b6e31bd8 100644 --- a/src/globe/cesium/picker.test.mjs +++ b/src/globe/cesium/picker.test.mjs @@ -134,3 +134,24 @@ test('picks of movers and lines name their owner and instance', () => { assert.equal(drilled[0].owner, 'flights'); assert.equal(drilled[1].owner, undefined); }); + +test('picks of a mover set made with pickable: false are dropped', () => { + const viewer = createFakeViewer(); + const quiet = { id: 'q' }; + viewer.scene.pick = () => ({ primitive: quiet, id: 'q' }); + viewer.scene.drillPick = () => [ + { primitive: quiet, id: 'q' }, + { primitive: {}, id: 'other' }, + ]; + const { picker } = createCesiumPicker(viewer, { + identify: (raw) => + raw.primitive === quiet + ? { owner: 'quiet', instance: 0, pickable: false } + : null, + }); + assert.equal(picker.pick({ x: 1, y: 2 }), undefined); + assert.deepEqual( + picker.drillPick({ x: 1, y: 2 }, 5).map(({ id }) => id), + ['other'], + ); +}); diff --git a/src/globe/navara/picking.js b/src/globe/navara/picking.js index 7f7a14cf4..f84aa0d36 100644 --- a/src/globe/navara/picking.js +++ b/src/globe/navara/picking.js @@ -51,8 +51,11 @@ export function createNavaraPicking({ // 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; + // `hit` is the batch id resolved when Navara answered (the pick's own + // frame): a set that changes its instances after it still names the + // instance that was under the pointer. + const resolveInfo = (info) => (info ? resolveBatch(info.batchId) : null); + const answerFrom = (hit, at) => { if (hit && !drillPick.near(hit.set, hit.instance, at)) { stats.stale++; return drillPick(at, 1)[0]; @@ -68,12 +71,12 @@ export function createNavaraPicking({ const onFeatureClick = (info) => { const next = waiting.shift(); if (!next) { - early = { info, at: now() }; + early = { hit: resolveInfo(info), at: now() }; return; } cancel(next.timer); answered(next, 'gpu'); - next.resolve(answerFrom(info, next.at)); + next.resolve(answerFrom(resolveInfo(info), next.at)); }; view.on('featureClick', onFeatureClick); @@ -87,7 +90,7 @@ export function createNavaraPicking({ if (gesture && now() - gesture.at <= EARLY_ANSWER_MS) { const entry = { startedAt: gesture.at }; answered(entry, 'gpu'); - return Promise.resolve(answerFrom(gesture.info, at)); + return Promise.resolve(answerFrom(gesture.hit, at)); } return new Promise((resolve) => { const entry = { diff --git a/src/globe/navara/picking.test.mjs b/src/globe/navara/picking.test.mjs index c5fe48fec..b84f8c119 100644 --- a/src/globe/navara/picking.test.mjs +++ b/src/globe/navara/picking.test.mjs @@ -272,3 +272,27 @@ test('a thin line is picked within 8 px of its segments', () => { assert.equal(picking.pick({ x: 100, y: 57.5 })?.id, 'trail-1'); assert.equal(picking.pick({ x: 100, y: 58.5 }), undefined); }); + +test('the pick id is the one the instance had when Navara answered', async () => { + const view = fakeView(); + const sets = [moverSet('flights', [[10, 10, 5, 'before']])]; + let time = 0; + const picking = createNavaraPicking({ + view, + projector, + camera, + movers: () => sets, + lines: () => [], + resolveBatch: (batchId) => + batchId === 1 + ? { set: 'flights', instance: 0, pickId: sets[0].store.pickIds[0] } + : null, + now: () => time, + ...timers(), + }); + view.emit('featureClick', { batchId: 1 }); + // The layer reuses the instance before the engine's click arrives. + sets[0].store.set(0, { pickId: 'after' }); + time += 1; + assert.equal((await picking.clickPick({ x: 10, y: 10 })).id, 'before'); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index b89315e80..f5493c21f 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -727,6 +727,17 @@ export function createContractSuite( sizePx: 24, }, }); + const quiet = engine.movers.createSet({ + id: 'contract-quiet', + kind: 'sprite', + capacity: 1, + order: 100, + depthTest: 'always', + sprite: { + atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, + sizePx: 24, + }, + }); const line = engine.lines.createLine({ id: 'contract-pick-line', widthPx: 8, @@ -753,6 +764,10 @@ export function createContractSuite( set.setCount(3); set.commit(); line.setPositions([at(19, 9), at(21, 9)]); + const unpickable = at(19.5, 11); + quiet.set(0, { position: unpickable, pickId: 'quiet' }); + quiet.setCount(1); + quiet.commit(); await settle(4); if (!pointer) return; const screen = (position) => engine.projector.worldToWindow(position); @@ -780,6 +795,14 @@ export function createContractSuite( drilled.includes('b') && drilled.includes('c'), `two sprites in one place drilled ${JSON.stringify(drilled)}`, ); + const onQuiet = await clickAt(unpickable); + ensure( + onQuiet.picked?.owner !== 'contract-quiet' && + !(onQuiet.drilled ?? []).some( + ({ owner }) => owner === 'contract-quiet', + ), + `a pickable: false sprite was picked (${JSON.stringify(onQuiet.picked?.id)})`, + ); const onLine = await clickAt(at(20, 9)); ensure( onLine.picked?.id === 'line-1', @@ -795,6 +818,7 @@ export function createContractSuite( } finally { for (const remove of removers) remove(); set.destroy(); + quiet.destroy(); line.destroy(); } }, @@ -868,6 +892,231 @@ export function createContractSuite( } }, ], + [ + 'movers: depth tests, rotation, cells, scale, count, points and models', + async () => { + await nadirView(1e6); + const at = (lon, lat, height = 20_000) => + cartesian3.fromDegrees(lon, lat, height); + // Atlas: 'red' a red square; 'up' white in its top half only. + const atlas = makeCanvas + ? makeCanvas(32, 16) + : { width: 32, height: 16 }; + if (makeCanvas) { + const context = atlas.getContext('2d'); + context.fillStyle = '#ff0000'; + context.fillRect(0, 0, 16, 16); + context.fillStyle = '#ffffff'; + context.fillRect(16, 0, 16, 8); + } + const cells = { red: [0, 0, 0.5, 1], up: [0.5, 0, 1, 1] }; + const sprites = (id, extra = {}) => + engine.movers.createSet({ + id, + kind: 'sprite', + capacity: 3, + order: 100, + depthTest: 'always', + sprite: { atlas: { canvas: atlas, cells }, sizePx: 24 }, + ...extra, + }); + const sets = []; + const make = (set) => (sets.push(set), set); + const RED = [1, 0, 0]; + const MAGENTA = [1, 0, 1]; + // Pixels near a colour (within 60 per channel) in a box, and how + // many lie above / below the box's centre row. + const look = (position, colour, half = 40) => { + const screen = engine.projector.worldToWindow(position); + const x0 = Math.round(screen.x) - half; + const y0 = Math.round(screen.y) - half; + const data = readRegion(x0, y0, 2 * half, 2 * half); + let total = 0; + let above = 0; + for (let y = 0; y < 2 * half; y++) + for (let x = 0; x < 2 * half; x++) { + const i = (y * 2 * half + x) * 4; + if ( + colour.every( + (channel, k) => Math.abs(data[i + k] - channel * 255) <= 60, + ) + ) { + total++; + if (y < half) above++; + } + } + return { total, above, below: total - above }; + }; + try { + const a = at(19.5, 10); + const base = make(sprites('contract-cells')); + base.set(0, { position: a, cell: 'red' }); + base.set(1, { position: at(20.5, 10), cell: 'red' }); + base.set(2, { position: at(20.5, 11), cell: 'red' }); + base.setCount(3); + base.commit(); + const far = make( + sprites('contract-far', { + sprite: { + atlas: { canvas: atlas, cells }, + sizePx: 24, + // The camera is 1000 km away: past `far`, half size. + scaleByDistance: [1e3, 1, 1e5, 0.5], + }, + }), + ); + const b = at(19.5, 11); + far.set(0, { position: b, cell: 'red' }); + far.setCount(1); + far.commit(); + const deep = (lon) => at(lon, 9, -30_000); + const depth = { + normal: make(sprites('contract-normal', { depthTest: 'normal' })), + always: make(sprites('contract-always', { depthTest: 'always' })), + within: make( + sprites('contract-within', { + depthTest: { disableWithinM: 2e7 }, + }), + ), + near: make( + sprites('contract-near', { depthTest: { disableWithinM: 10 } }), + ), + }; + Object.values(depth).forEach((set, index) => { + set.set(0, { position: deep(19 + index * 0.7), cell: 'red' }); + set.setCount(1); + set.commit(); + }); + const points = make( + engine.movers.createSet({ + id: 'contract-points', + kind: 'point', + capacity: 1, + depthTest: 'always', + point: { sizePx: 20, outlinePx: 3 }, + }), + ); + const p = at(20.5, 9.5); + points.set(0, { position: p, color: [1, 0, 1, 1] }); + points.setCount(1); + points.commit(); + if (!readRegion) { + base.setCount(1); + base.commit(); + ensure(base.count() === 1, 'setCount did not shrink the set'); + return; + } + await until(() => look(a, RED).total > 300, 'the red cell drawn'); + const unit = look(a, RED).total; + ensure( + look(at(20.5, 11), RED).total > 300, + 'the third sprite is not drawn', + ); + // Scale by distance: a quarter of the area. + const farArea = look(b, RED).total; + ensure( + farArea > unit * 0.12 && farArea < unit * 0.4, + `scaleByDistance drew ${farArea} px against ${unit} at full size`, + ); + // Scale 2: four times the area. + base.set(0, { scale: 2 }); + base.commit(); + await until( + () => look(a, RED).total > unit * 3, + 'the sprite drawn at scale 2', + ); + ensure( + look(a, RED).total < unit * 5, + `scale 2 drew ${look(a, RED).total} px against ${unit}`, + ); + // Cell switch: the 'up' cell is white in its top half (magenta + // times the instance colour), then turned upside down. + base.set(0, { scale: 1, cell: 'up', color: [1, 0, 1, 1] }); + base.commit(); + await until(() => look(a, MAGENTA).total > 100, 'the up cell drawn'); + const upright = look(a, MAGENTA); + ensure( + upright.above > 10 * Math.max(1, upright.below) && + look(a, RED).total < 10, + `the up cell: ${JSON.stringify(upright)}, red ${look(a, RED).total}`, + ); + base.set(0, { rotation: Math.PI }); + base.commit(); + await until( + () => look(a, MAGENTA).below > 100, + 'the rotated cell drawn', + ); + const turned = look(a, MAGENTA); + ensure( + turned.below > 10 * Math.max(1, turned.above), + `rotation π: ${JSON.stringify(turned)}`, + ); + // setCount shrinks: the others go. + base.setCount(1); + base.commit(); + await until( + () => look(at(20.5, 11), RED).total < 10, + 'the shrunk set drawn', + ); + ensure(look(a, MAGENTA).total > 100, 'instance 0 went with them'); + // Depth: under the ground only 'always' and a disableWithinM + // reaching past the camera draw. + const drawn = (index) => + look(deep(19 + index * 0.7), RED).total > 100; + await until(() => drawn(1), "the 'always' sprite drawn"); + ensure(drawn(2), 'disableWithinM past the camera is hidden'); + // Cesium's billboards under the app's photoreal tiles (globe + // hidden) are not occluded by them, measured; Navara's overlay + // tests the scene depth. Occlusion is checked on Navara only. + if (engine.kind === 'navara') { + ensure(!drawn(0), "a 'normal' sprite under the ground is drawn"); + ensure(!drawn(3), 'disableWithinM 10 m under the ground is drawn'); + } + // Points: a magenta disc with a dark outline. + await until(() => look(p, MAGENTA, 16).total > 80, 'the point drawn'); + const ring = look(p, [0, 0, 0], 16).total; + ensure(ring > 20, `no dark outline around the point (${ring} px)`); + // Models: an aircraft drawn in magenta (blended by replace), at + // least 80 px; lit and graded on Navara, so magenta-leaning. + const models = make( + engine.movers.createSet({ + id: 'contract-models', + kind: 'model', + capacity: 1, + model: { + url: '/models/airplane.glb', + minimumPixelSize: 80, + colorBlend: { mode: 'replace', amount: 1 }, + }, + }), + ); + const m = at(19.2, 9.6, 50_000); + models.set(0, { position: m, color: [1, 0, 1, 1] }); + models.setCount(1); + models.commit(); + const modelPixels = () => { + const screen = engine.projector.worldToWindow(m); + const data = readRegion( + Math.round(screen.x) - 50, + Math.round(screen.y) - 50, + 100, + 100, + ); + let count = 0; + for (let i = 0; i < data.length; i += 4) + if ( + Math.min(data[i], data[i + 2]) - data[i + 1] > 40 && + data[i] > 60 + ) + count++; + return count; + }; + await until(() => modelPixels() > 150, 'the model drawn'); + } finally { + for (const set of sets) set.destroy(); + } + }, + ], [ 'render: continuous mode toggles', async () => {