test(globe): mover coverage on both engines; pickable: false honoured

Review of Tasks 2 and 4:
- A contract check draws, on both engines: an atlas cell switch, a
  rotation of π (a half-white cell flips from above to below the
  centre), scale 2 (about four times the area), scaleByDistance past
  `far` (about a quarter), setCount shrinking the set, depth modes for
  sprites 30 km under the ground ('always' and a disableWithinM past the
  camera draw), a point with its dark outline, and a glTF model
  (airplane.glb, minimumPixelSize 80, colorBlend replace) in magenta.
  'normal' and a 10 m disableWithinM hide the buried sprite on Navara;
  Cesium's billboards are not occluded by the app's photoreal tiles
  (globe hidden; measured), so those two are asserted on Navara only.
- pickable: false: Cesium's GPU pick sees every primitive, so the
  picker drops picks (and drill entries) of a set created with
  pickable: false; Navara only registers pickable sets. The contract's
  click check now clicks a pickable: false sprite on both engines.
- Navara resolves a GPU answer's batch id to the pick id when Navara
  answers (the pick's own frame), not when the engine's click consumes
  it: a set that reuses the instance in between still yields the
  instance that was under the pointer.

Contract: Cesium 70/70, Navara 76/76.
This commit is contained in:
German Khokhlov committed 2026-10-16 19:20:47 +04:00
1 parent 0957d9712b
commit 6436f580d9
7 files changed
+310 -7

No files matched your search

+2 -1
View File
@@ -316,7 +316,8 @@ export function createCesiumMovers(
/** The set and instance a Cesium pick result belongs to, or null. */ /** The set and instance a Cesium pick result belongs to, or null. */
identify(picked) { identify(picked) {
const known = picked?.primitive ? owners.get(picked.primitive) : null; 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() { destroy() {
registry.destroyAll(); registry.destroyAll();
+1
View File
@@ -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 }), { assert.deepEqual(identify({ primitive: second, id: 25544 }), {
owner: 'sats', owner: 'sats',
instance: 1, instance: 1,
pickable: false,
}); });
assert.equal(identify({ primitive: {}, id: 'x' }), null); assert.equal(identify({ primitive: {}, id: 'x' }), null);
set.destroy(); set.destroy();
+5 -1
View File
@@ -14,6 +14,9 @@ export function createCesiumPicker(viewer, { identify = () => null } = {}) {
const normalise = (picked) => { const normalise = (picked) => {
if (picked == null) return undefined; if (picked == null) return undefined;
const known = identify(picked); 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 { return {
id: picked.id, id: picked.id,
primitive: picked.primitive, primitive: picked.primitive,
@@ -37,7 +40,8 @@ export function createCesiumPicker(viewer, { identify = () => null } = {}) {
drillPick(screen, limit, width, height) { drillPick(screen, limit, width, height) {
return scene return scene
.drillPick(toCesium2(screen), limit, width, height) .drillPick(toCesium2(screen), limit, width, height)
.map(normalise); .map(normalise)
.filter(Boolean);
}, },
pickPosition(screen) { pickPosition(screen) {
if (!scene.pickPositionSupported) return undefined; if (!scene.pickPositionSupported) return undefined;
+21
View File
@@ -134,3 +134,24 @@ test('picks of movers and lines name their owner and instance', () => {
assert.equal(drilled[0].owner, 'flights'); assert.equal(drilled[0].owner, 'flights');
assert.equal(drilled[1].owner, undefined); 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'],
);
});
+8 -5
View File
@@ -51,8 +51,11 @@ export function createNavaraPicking({
// Navara 0.2.1 answers clicks in quick succession (within a frame or so) // 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 // 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. // near the click is stale, and the CPU pick answers instead.
const answerFrom = (info, at) => { // `hit` is the batch id resolved when Navara answered (the pick's own
const hit = info ? resolveBatch(info.batchId) : null; // 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)) { if (hit && !drillPick.near(hit.set, hit.instance, at)) {
stats.stale++; stats.stale++;
return drillPick(at, 1)[0]; return drillPick(at, 1)[0];
@@ -68,12 +71,12 @@ export function createNavaraPicking({
const onFeatureClick = (info) => { const onFeatureClick = (info) => {
const next = waiting.shift(); const next = waiting.shift();
if (!next) { if (!next) {
early = { info, at: now() }; early = { hit: resolveInfo(info), at: now() };
return; return;
} }
cancel(next.timer); cancel(next.timer);
answered(next, 'gpu'); answered(next, 'gpu');
next.resolve(answerFrom(info, next.at)); next.resolve(answerFrom(resolveInfo(info), next.at));
}; };
view.on('featureClick', onFeatureClick); view.on('featureClick', onFeatureClick);
@@ -87,7 +90,7 @@ export function createNavaraPicking({
if (gesture && now() - gesture.at <= EARLY_ANSWER_MS) { if (gesture && now() - gesture.at <= EARLY_ANSWER_MS) {
const entry = { startedAt: gesture.at }; const entry = { startedAt: gesture.at };
answered(entry, 'gpu'); answered(entry, 'gpu');
return Promise.resolve(answerFrom(gesture.info, at)); return Promise.resolve(answerFrom(gesture.hit, at));
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const entry = { const entry = {
+24
View File
@@ -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: 57.5 })?.id, 'trail-1');
assert.equal(picking.pick({ x: 100, y: 58.5 }), undefined); 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');
});
+249
View File
@@ -727,6 +727,17 @@ export function createContractSuite(
sizePx: 24, 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({ const line = engine.lines.createLine({
id: 'contract-pick-line', id: 'contract-pick-line',
widthPx: 8, widthPx: 8,
@@ -753,6 +764,10 @@ export function createContractSuite(
set.setCount(3); set.setCount(3);
set.commit(); set.commit();
line.setPositions([at(19, 9), at(21, 9)]); 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); await settle(4);
if (!pointer) return; if (!pointer) return;
const screen = (position) => engine.projector.worldToWindow(position); const screen = (position) => engine.projector.worldToWindow(position);
@@ -780,6 +795,14 @@ export function createContractSuite(
drilled.includes('b') && drilled.includes('c'), drilled.includes('b') && drilled.includes('c'),
`two sprites in one place drilled ${JSON.stringify(drilled)}`, `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)); const onLine = await clickAt(at(20, 9));
ensure( ensure(
onLine.picked?.id === 'line-1', onLine.picked?.id === 'line-1',
@@ -795,6 +818,7 @@ export function createContractSuite(
} finally { } finally {
for (const remove of removers) remove(); for (const remove of removers) remove();
set.destroy(); set.destroy();
quiet.destroy();
line.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', 'render: continuous mode toggles',
async () => { async () => {