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',