From 8b6bbf0f2155c76c30223ece2c8607a508113492 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 6 Nov 2026 17:11:53 -0800 Subject: [PATCH] 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. --- src/render/cesium/appRenderer.test.mjs | 3 - src/render/cesium/harness.js | 2 + src/render/cesium/ports.js | 76 +++++++++++++++++++++++++- src/render/cesium/ports.test.mjs | 72 ++++++++++++++++++++++++ src/render/conformance/checks/ports.js | 46 ++++++++++++++++ 5 files changed, 194 insertions(+), 5 deletions(-) diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index 1665e11f7..eb0cf71a0 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -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', diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 6390c7a62..55a8643a3 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -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', diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index 45b2ccc0a..3a18938c4 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -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(); diff --git a/src/render/cesium/ports.test.mjs b/src/render/cesium/ports.test.mjs index e1e66f0d2..edd276e23 100644 --- a/src/render/cesium/ports.test.mjs +++ b/src/render/cesium/ports.test.mjs @@ -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(); +}); diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js index a7e1894ed..133f65a67 100644 --- a/src/render/conformance/checks/ports.js +++ b/src/render/conformance/checks/ports.js @@ -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',