Give the Cesium draw port atlases and the clamp state

The shared Cesium ports gain draw.atlas, draw.clampState and
draw.on('clampState'), on both the conformance renderer and the
application's. An icon names an image by atlas slot; it draws once its atlas
holds the slot, and registering an atlas again rebuilds the icon sets that
read from it. A generated image with a key is one texture however many icons
draw it.

clampState is draped unless a shown 3D tileset is in the scene: the port
draws clamped items where it is given them and drapes nothing onto tiles yet,
so over tiles it says overlay. A change is told after the drawn frame that
shows it, never on subscribing.

A new draw.atlas check registers an atlas after its icons and picks them;
it and draw.clampState now run on Cesium.
This commit is contained in:
Sameh Khamis committed 2026-11-06 17:38:21 -08:00
1 parent 96bc73181b
commit 8b6bbf0f21
5 files changed
+194 -5

No files matched your search

-3
View File
@@ -318,9 +318,6 @@ test('the application refuses a renderer it cannot run on, and destroys it', asy
});
const EXPECTED_MISSING = [
'draw.atlas',
'draw.clampState',
'draw.on',
'frame.setTime',
'renderer.globe',
'renderer.readPixels',
+2
View File
@@ -33,6 +33,8 @@ export const CESIUM_CHECKS = new Set([
'camera.trace.view and projection matrices',
'credits',
'credits.listenerRules',
'draw.atlas',
'draw.clampState',
'draw.drillPick',
'draw.ids',
'draw.models',
+74 -2
View File
@@ -223,6 +223,7 @@ export function createCesiumPorts(widget, { capabilities }) {
scene.preRender.addEventListener(onPhase('preRender')),
scene.postRender.addEventListener(drew),
scene.postRender.addEventListener(onPhase('postRender')),
scene.postRender.addEventListener(() => watchClamp()),
];
/**
@@ -363,6 +364,7 @@ export function createCesiumPorts(widget, { capabilities }) {
build(record);
setsVersion += 1;
};
record.rebuild = rebuild;
const handle = {
id: spec.id,
set(list) {
@@ -437,13 +439,34 @@ export function createCesiumPorts(widget, { capabilities }) {
scene.primitives.add(host);
}
// Atlases icons name images from, by id: `{atlas, slot}` reads the slot
// when its set is built, and registering an atlas again rebuilds the icon
// sets that drew from it.
const atlases = new Map();
/**
* An icon's image: an atlas slot read through its atlas, or undefined
* while its atlas or slot is not registered (it draws once it is).
*/
function iconImage(record, image) {
if (typeof image?.atlas !== 'string') return image;
record.atlases.add(image.atlas);
return atlases.get(image.atlas)?.[image.slot];
}
function buildIcons(record) {
const host = new Cesium.BillboardCollection({ scene });
record.atlases = new Set();
for (const item of record.items.values()) {
const image = iconImage(record, item.image);
if (image === undefined) continue;
// A generated image is one texture under its key, however many icons
// draw it.
const keyed = image?.canvas && typeof image.key === 'string';
const position = toCartesian(item.pos);
const options = {
position,
image: toImage(item.image),
...(keyed ? {} : { image: toImage(image) }),
width: item.size[0] * (item.scale ?? 1),
height: item.size[1] * (item.scale ?? 1),
color: toColor(item.color, item.alpha ?? 1),
@@ -462,7 +485,8 @@ export function createCesiumPorts(widget, { capabilities }) {
);
options.rotation = -Cesium.Math.toRadians(item.course ?? 0);
}
host.add(options);
const billboard = host.add(options);
if (keyed) billboard.setImage(image.key, image.canvas);
}
replaceHost(record, host);
}
@@ -728,12 +752,59 @@ export function createCesiumPorts(widget, { capabilities }) {
}
}
/** The 3D tilesets in the scene, shown or not. */
function tilesets() {
const found = [];
const primitives = scene.primitives;
const n = primitives?.length ?? 0;
for (let i = 0; i < n; i++) {
const primitive = primitives.get(i);
if (primitive instanceof Cesium.Cesium3DTileset) found.push(primitive);
}
return found;
}
/**
* The Cesium port draws clamped items where it is given them; it does not
* drape them onto 3D tiles yet, so over shown tiles it is `overlay`.
* Terrain and the ellipsoid always take draped items.
*/
const clampState = () =>
tilesets().some((tileset) => tileset.show) ? 'overlay' : 'draped';
const clampListeners = listenerList(report);
// The state last told, while anyone listens; measured after each drawn
// frame, when what the base holds can have changed.
let toldClamp = null;
function watchClamp() {
if (clampListeners.size === 0) return;
const state = clampState();
if (state === toldClamp) return;
toldClamp = state;
clampListeners.fire(state);
}
const draw = {
icons: (spec) => makeSet('icons', { ...spec }, buildIcons),
points: (spec) => makeSet('points', { ...spec }, buildPoints),
lines: (spec) => makeSet('lines', { ...spec }, buildLines),
shapes: (spec) => makeSet('shapes', { ...spec }, buildShapes),
models: (spec) => makeSet('models', { ...spec }, buildModels),
clampState,
on(event, cb) {
if (event !== 'clampState') return () => {};
if (clampListeners.size === 0) toldClamp = clampState();
return clampListeners.on(cb);
},
atlas(id, images) {
atlases.set(id, { ...images });
let rebuilt = false;
for (const record of [...sets.values()])
if (record.kind === 'icons' && record.atlases?.has(id)) {
record.rebuild();
rebuilt = true;
}
if (rebuilt) scene.requestRender();
},
};
const cameraPart = createCesiumCamera({ scene, camera, report, widget });
@@ -1116,6 +1187,7 @@ export function createCesiumPorts(widget, { capabilities }) {
// A listener that disposes the renderer stops the rest of its event.
for (const list of Object.values(rendererListeners)) list.clear();
for (const list of Object.values(frameListeners)) list.clear();
clampListeners.clear();
cameraPart.dispose();
creditsPart.dispose();
effectsPart.dispose();
+72
View File
@@ -625,3 +625,75 @@ test('viewRectangle is in degrees, around the point below a downward eye', () =>
assert.ok(rect.south < 30.27 && rect.north > 30.27, JSON.stringify(rect));
ports.dispose();
});
/** A scene's primitives as Cesium lists them: `length` and `get`. */
function listedPrimitives(list) {
return {
get length() {
return list.length;
},
get: (i) => list[i],
add: (p) => list.push(p),
remove: (p) => list.splice(list.indexOf(p), 1),
};
}
test('clampState drapes without tiles and overlays shown ones, told on change', () => {
const { widget, scene, raise } = tickingWidget();
const list = [];
scene.primitives = listedPrimitives(list);
const ports = createCesiumPorts(widget, { capabilities: all });
assert.equal(ports.draw.clampState(), 'draped', 'nothing but the ellipsoid');
const told = [];
const off = ports.draw.on('clampState', (state) => told.push(state));
raise('postRender');
assert.deepEqual(told, [], 'not on subscribing, nor without a change');
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
tileset.show = true;
list.push(tileset);
assert.equal(ports.draw.clampState(), 'overlay', 'the port drapes no tiles');
raise('postRender');
raise('postRender');
assert.deepEqual(told, ['overlay'], 'told once, after a drawn frame');
tileset.show = false;
raise('postRender');
assert.deepEqual(told, ['overlay', 'draped'], 'hidden tiles take nothing');
off();
tileset.show = true;
raise('postRender');
assert.equal(told.length, 2, 'off stops it');
assert.equal(typeof ports.draw.on('ready', () => {}), 'function');
ports.dispose();
});
test('an icon draws its atlas slot once the atlas has it, and again when it changes', () => {
const { widget, scene } = tickingWidget();
const list = [];
scene.primitives = listedPrimitives(list);
// What a billboard reads of its scene as it is added.
scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
const ports = createCesiumPorts(widget, { capabilities: all });
const icon = (id, slot) => ({
id,
pos: { x: 6378137, y: 0, z: 0 },
image: { atlas: 'conf', slot },
size: [4, 4],
});
const icons = ports.draw.icons({ id: 'icons' });
icons.set([icon('one', 'dot'), icon('two', 'ring')]);
assert.equal(icons.size(), 2);
assert.equal(list.at(-1).length, 0, 'no atlas yet: nothing drawn');
ports.draw.atlas('conf', { dot: 'dot.png' });
const first = list.at(-1);
assert.equal(first.length, 1, 'the slot it has draws');
assert.equal(first.get(0).image, 'dot.png');
ports.draw.atlas('other', { dot: 'other.png' });
assert.equal(list.at(-1), first, 'another atlas leaves the set alone');
ports.draw.atlas('conf', { dot: 'next.png', ring: 'ring.png' });
assert.notEqual(list.at(-1), first, 'rebuilt');
assert.deepEqual(
[list.at(-1).get(0).image, list.at(-1).get(1).image],
['next.png', 'ring.png'],
);
ports.dispose();
});
+46
View File
@@ -1125,6 +1125,52 @@ export function portChecks() {
off();
},
),
check(
'draw.atlas',
'icons draw slots of an atlas registered after them, and are picked',
async (h, r) => {
await overAustin(h, r);
const set = r.draw.icons({ id: 'conf:atlas' });
const at = (lon) => h.toEcef({ lon, lat: AUSTIN.lat, height: 0 });
// A generated slot too, where there is a page to make it in.
const canvas = globalThis.document?.createElement('canvas');
const slots = { svg: ['dot', DOT, AUSTIN.lon - 0.002] };
if (canvas) {
canvas.width = canvas.height = 16;
const g = canvas.getContext('2d');
g.fillStyle = '#fff';
g.fillRect(0, 0, 16, 16);
slots.canvas = [
'square',
{ canvas, key: 'conf:square' },
AUSTIN.lon + 0.002,
];
}
set.set(
Object.entries(slots).map(([id, [slot, , lon]]) => ({
id,
pos: at(lon),
image: { atlas: 'conf:atlas', slot },
size: [24, 24],
})),
);
await drawn(h, r);
r.draw.atlas(
'conf:atlas',
Object.fromEntries(
Object.values(slots).map(([slot, image]) => [slot, image]),
),
);
await drawn(h, r);
await settled(r);
for (const [id, [, , lon]] of Object.entries(slots)) {
const s = r.query.project(at(lon));
ok(s, `${id} projects`);
equal(r.query.pick(s.x, s.y)?.id, id, `${id} slot drawn and picked`);
}
set.dispose();
},
),
check(
'draw.columns',
'column updates are rows: sized, picked, removed',