diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9b46f62c1..de5f8bd54 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2781,6 +2781,7 @@ "src/render/creditMarkup.js", "src/render/rendererOf.js", "src/render/cesium/ports.js", + "src/render/cesium/globe.js", "src/render/cesium/quality.js", "src/render/cesium/credits.js", "src/render/cesium/effects.js", @@ -3184,6 +3185,7 @@ "src/maps/imageryComparison.js", "src/maps/credits.js", "src/maps/defaultSources.js", + "src/render/cesium/imagery.js", "src/maps/google3d.js", "src/maps/imagery.js", "src/maps/registry.js", diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index d867deaf4..78ba32fc2 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -6,10 +6,10 @@ * * The renderer it makes is transitional. Its ports are the shared Cesium * ports (`ports.js`), the ones the conformance renderer is checked with, - * less the members that would not be true of the application yet: the - * globe (it draws photoreal tiles the port does not know). Those arrive - * with the layer families that need them (phase 2 of the renderer plan); - * `checkRendererShape` lists what is still missing. `whenSettled` waits + * and it has every member of the contract. The application's base (the + * photoreal tiles, the map stack's imagery and terrain) is still loaded + * through the viewer by the scene and the map stack; the globe port drapes + * its imagery on that base as drawn (see `globe.js`). `whenSettled` waits * for the globe, the scene's 3D tiles and what the draw sets hold, not for * what layers still draw through the viewer themselves. * Until a family is ported, its code reaches the Cesium viewer through @@ -23,15 +23,7 @@ import { bindRenderer } from '../rendererOf.js'; import { createCesiumInput } from './input.js'; import { cesiumCapabilities, createCesiumPorts } from './ports.js'; -/** - * The members a transitional renderer has today. - * @typedef {Pick} - * TransitionalRenderer - */ +/** @typedef {import('../contract/renderer').Renderer} TransitionalRenderer */ const viewers = new WeakMap(); @@ -75,12 +67,18 @@ export function createCesiumRendererPlugin({ return Object.freeze({ id: 'cesium', /** - * Credentials stay with the scene, which still loads the tiles; they - * move into the renderer with the globe port. - * @param {{container: HTMLElement, signal: AbortSignal}} options + * The credentials are the globe port's, for a base set through it; the + * scene still loads the application's own base itself. + * @param {import('../contract/renderer').CreateOptions} options * @returns {Promise} */ - async create({ container, signal }) { + async create({ + container, + signal, + googleApiKey, + googleTokens, + worldDataToken, + }) { signal?.throwIfAborted(); // Provider attribution stays visible, including clean-view and recording. const creditContainer = document.createElement('div'); @@ -93,7 +91,10 @@ export function createCesiumRendererPlugin({ try { viewer = createViewer({ container, creditContainer }); capabilities = new ReadonlySet(cesiumCapabilities(viewer.scene)); - ports = createCesiumPorts(viewer, { capabilities }); + ports = createCesiumPorts(viewer, { + capabilities, + credentials: { googleApiKey, googleTokens, worldDataToken }, + }); input = createCesiumInput(viewer, { query: ports.query, report: ports.report, @@ -111,6 +112,7 @@ export function createCesiumRendererPlugin({ capabilities, canvas: viewer.canvas, frame: ports.frame, + globe: ports.globe, draw: ports.draw, camera: ports.camera, query: ports.query, diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index 00f74338f..6ca5327aa 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -317,6 +317,4 @@ test('the application refuses a renderer it cannot run on, and destroys it', asy assert.deepEqual(destroyed, ['other']); }); -const EXPECTED_MISSING = ['renderer.globe'].map( - (member) => `${member} is missing`, -); +const EXPECTED_MISSING = []; diff --git a/src/render/cesium/globe.js b/src/render/cesium/globe.js new file mode 100644 index 000000000..0fb521ece --- /dev/null +++ b/src/render/cesium/globe.js @@ -0,0 +1,479 @@ +/** + * @module render/cesium/globe + * @description The Cesium renderer's globe port (`src/render/contract/globe.d.ts`): + * the base (photoreal tiles, terrain, imagery), the globe's visibility, + * imagery layers (`imagery.js`), the split, the sky, settling and load + * progress, over a Cesium widget or viewer someone else made. + * + * Imagery drapes on the globe, or on the drawn base tileset: the photoreal + * tileset this port loaded, or one the application's map stack marked as + * its base (`markBaseTileset`) while it still loads its own. Where each + * layer drapes follows what is drawn, looked at every frame, so a base + * switched outside the port moves the layers with it. + * + * The sky reads the scene as it was set up and writes through to it; what + * the scene has no part for (stars without a sky box, say) is kept as set. + */ +import * as Cesium from 'cesium'; +import { loadPhotorealisticTileset } from '../../maps/google3d.js'; +import { provideUnboundGlobe } from '../rendererOf.js'; +import { listenerList, uncaught } from './common.js'; +import { createCesiumImagery } from './imagery.js'; + +/** Tilesets the application's map stack draws as its base. */ +const baseTilesets = new WeakSet(); + +/** + * Mark a tileset as a base the application draws outside the port, so + * imagery drapes on it while it is shown. For code that still loads its + * own base; the port's own photoreal tileset needs no mark. + * @param {object} tileset Cesium `Cesium3DTileset`. + */ +export function markBaseTileset(tileset) { + if (tileset && typeof tileset === 'object') baseTilesets.add(tileset); +} + +/** Ion's world terrain asset. */ +const WORLD_TERRAIN_ASSET = 1; +const ROUTES = { + 'google-direct': 'google', + 'google-token': 'google', + 'google-ion': 'fallback', +}; + +/** Heights of one DEM tile, decoded from its RGB pixels. */ +function decodeHeights(pixels, encoding) { + const heights = new Float32Array(pixels.length / 4); + for (let i = 0, p = 0; i < heights.length; i++, p += 4) { + const r = pixels[p]; + const g = pixels[p + 1]; + const b = pixels[p + 2]; + heights[i] = + encoding === 'mapbox' + ? -10000 + (r * 65536 + g * 256 + b) * 0.1 + : r * 256 + g + b / 256 - 32768; + } + return heights; +} + +/** A terrain provider for a heightmap DEM pyramid. */ +function heightmapTerrain(spec) { + if (spec.geoid) + throw new TypeError('Geoid-relative heightmaps are not supported yet'); + const size = 256; + return new Cesium.CustomHeightmapTerrainProvider({ + width: size, + height: size, + tilingScheme: new Cesium.WebMercatorTilingScheme(), + callback: async (x, y, level) => { + // Past the pyramid a tile fails, and the globe refines its parent's. + if (spec.maxLevel !== undefined && level > spec.maxLevel) + throw new RangeError('Past the heightmap’s deepest level'); + const url = spec.url + .replaceAll('{x}', String(x)) + .replaceAll('{y}', String(y)) + .replaceAll('{z}', String(level)); + const image = await Cesium.Resource.fetchImage({ + url, + preferImageBitmap: true, + }); + const canvas = new OffscreenCanvas(size, size); + const context = canvas.getContext('2d'); + context.drawImage(image, 0, 0, size, size); + return decodeHeights( + context.getImageData(0, 0, size, size).data, + spec.encoding, + ); + }, + }); +} + +/** The terrain provider a spec describes, once ready. */ +async function terrainProvider(spec, { worldDataToken }) { + if (!spec || spec.kind === 'ellipsoid') + return new Cesium.EllipsoidTerrainProvider(); + if (spec.kind === 'quantizedMesh') + return Cesium.CesiumTerrainProvider.fromUrl(spec.url, { + requestVertexNormals: spec.requestVertexNormals ?? false, + }); + if (spec.kind === 'world') { + const accessToken = String(worldDataToken || '').trim(); + if (!accessToken) + throw new TypeError( + 'World terrain needs the renderer’s world data token', + ); + const resource = await Cesium.IonResource.fromAssetId(WORLD_TERRAIN_ASSET, { + accessToken, + }); + return Cesium.CesiumTerrainProvider.fromUrl(resource, { + requestVertexNormals: spec.requestVertexNormals ?? false, + requestWaterMask: false, + }); + } + if (spec.kind === 'heightmap') return heightmapTerrain(spec); + throw new TypeError(`Unknown terrain kind: ${spec.kind}`); +} + +/** + * @param {object} widget A Cesium `CesiumWidget` or `Viewer`. + * @param {{capabilities: ReadonlySet, + * requestRender: (reason: string) => void, + * report?: (error: unknown) => void, + * credits?: import('../contract/credits').CreditsPort | null, + * credentials?: {googleApiKey?: string, googleTokens?: () => Promise, + * worldDataToken?: string}}} options + * @returns {{port: import('../contract/globe').GlobePort, dispose: () => void}} + */ +export function createCesiumGlobe( + widget, + { + capabilities, + requestRender, + report = uncaught, + credits = null, + credentials = {}, + }, +) { + const scene = widget.scene; + const can = (capability) => capabilities.has(capability); + let disposed = false; + + // --- Base ------------------------------------------------------------------ + + /** The photoreal tileset this port loaded, if any. */ + let photoreal = null; + let photorealStatus = { route: 'none' }; + let photorealGen = 0; + let terrainGen = 0; + let baseCredit = null; + + /** Every 3D tileset the scene holds. */ + 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 base tileset drawn now, which `objects` imagery drapes on. */ + function drawnBase() { + if (photoreal && !photoreal.isDestroyed() && photoreal.show) + return photoreal; + for (const tileset of tilesets()) + if (baseTilesets.has(tileset) && tileset.show && !tileset.isDestroyed()) + return tileset; + return null; + } + + const imagery = createCesiumImagery(scene, { + capabilities, + requestRender, + report, + objects: drawnBase, + worldDataToken: () => credentials.worldDataToken, + }); + + function removePhotoreal() { + if (!photoreal) return; + const tileset = photoreal; + photoreal = null; + // The layers draped on it move off before it takes them with it. + imagery.sync(); + if (!tileset.isDestroyed() && !scene.primitives.remove(tileset)) + tileset.destroy(); + } + + async function setPhotoreal(spec) { + const gen = ++photorealGen; + removePhotoreal(); + photorealStatus = { route: 'none' }; + if (!spec || !can('photoreal')) { + photorealStatus = { route: 'none' }; + requestRender('base'); + return photorealStatus; + } + // The spec's own route first: its key, else its tokens, else the + // renderer's credentials. + const googleApiKey = + spec.key ?? (spec.token ? '' : (credentials.googleApiKey ?? '')); + const token = spec.token ?? credentials.googleTokens; + const result = await loadPhotorealisticTileset(Cesium, { + googleApiKey, + // A renewal names the token it replaces. + googleTokens: token ? { token: (options) => token(options) } : null, + cesiumToken: spec.fallback ? (credentials.worldDataToken ?? '') : '', + }); + if (gen !== photorealGen || disposed) { + if (result.tileset && !result.tileset.isDestroyed()) + result.tileset.destroy(); + return photorealStatus; + } + if (!result.tileset) { + const error = result.errors.at(-1); + photorealStatus = { + route: 'none', + error: { + code: error ? 'load-failed' : 'no-credentials', + message: error?.message ?? 'No photoreal credentials were given', + }, + }; + } else { + photoreal = scene.primitives.add(result.tileset); + photorealStatus = { route: ROUTES[result.route] ?? 'google' }; + } + imagery.sync(); + requestRender('base'); + return photorealStatus; + } + + async function setTerrain(spec) { + if (spec && spec.kind !== 'ellipsoid' && !can('terrain')) + throw new TypeError('This renderer draws no terrain'); + // The last call wins, even one whose terrain then fails to load. + const gen = ++terrainGen; + const provider = await terrainProvider(spec, credentials); + if (gen !== terrainGen || disposed) { + if (!provider.isDestroyed?.()) provider.destroy?.(); + return; + } + scene.terrainProvider = provider; + scene.verticalExaggeration = spec?.exaggeration ?? 1; + requestRender('base'); + } + + async function setBase(base) { + if (base.vector) throw new TypeError('This renderer draws no vector base'); + for (const handle of imagery.list()) handle.remove(); + baseCredit?.(); + baseCredit = base.credit && credits ? credits.addStatic(base.credit) : null; + const loads = []; + if (base.photoreal !== undefined) loads.push(setPhotoreal(base.photoreal)); + if (base.terrain !== undefined) loads.push(setTerrain(base.terrain)); + const layers = (base.imagery ?? []).map((spec) => + imagery.add(spec, undefined, { base: true }), + ); + await Promise.all(loads); + await imagery.ready(layers); + await baseSettled(); + return { + photoreal: photorealStatus, + imagery: layers, + settled: isSettled('base'), + }; + } + + /** How long `setBase` waits for the new base to settle. */ + const BASE_SETTLE_MS = 20000; + + /** + * Resolves once the base has settled, checked after each drawn frame, or + * after `BASE_SETTLE_MS`; `setBase` then says whether it had. + */ + function baseSettled() { + if (isSettled('base') || !scene.postRender?.addEventListener) + return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + off(); + clearTimeout(timer); + settleWaits.delete(done); + resolve(); + }; + const off = scene.postRender.addEventListener(() => { + // Loads draw on their own as they land; no frame is asked for. + if (isSettled('base')) done(); + }); + const timer = setTimeout(done, BASE_SETTLE_MS); + settleWaits.add(done); + requestRender('base'); + }); + } + const settleWaits = new Set(); + + // --- Sky ------------------------------------------------------------------- + + const sky = { + atmosphere: + scene.skyAtmosphere?.show ?? scene.globe?.showGroundAtmosphere ?? false, + atmosphereTuning: { + brightness: scene.skyAtmosphere?.brightnessShift ?? 0, + hueShift: scene.skyAtmosphere?.hueShift ?? 0, + saturation: scene.skyAtmosphere?.saturationShift ?? 0, + }, + stars: scene.skyBox?.show ?? false, + milkyWay: scene.skyBox?.milkyWayVisibility ?? 0, + sun: scene.sun?.show ?? false, + moon: scene.moon?.show ?? false, + fog: scene.fog?.enabled ?? false, + lighting: scene.globe?.enableLighting ?? false, + }; + + /** The settings as drawn: the scene's own parts, else what was set. */ + function readSky() { + const out = { ...sky, atmosphereTuning: { ...sky.atmosphereTuning } }; + if (scene.skyAtmosphere) out.atmosphere = scene.skyAtmosphere.show; + else if (scene.globe) out.atmosphere = scene.globe.showGroundAtmosphere; + if (scene.skyBox && 'show' in scene.skyBox) out.stars = scene.skyBox.show; + if (scene.skyBox?.milkyWayVisibility !== undefined) + out.milkyWay = scene.skyBox.milkyWayVisibility; + if (scene.sun) out.sun = scene.sun.show; + if (scene.moon) out.moon = scene.moon.show; + if (scene.fog) out.fog = scene.fog.enabled; + if (scene.globe) out.lighting = scene.globe.enableLighting; + return out; + } + + function setSky(settings) { + const { atmosphereTuning, ...rest } = settings; + Object.assign(sky, rest); + if (atmosphereTuning) + sky.atmosphereTuning = { ...sky.atmosphereTuning, ...atmosphereTuning }; + if ('atmosphere' in settings) { + if (scene.skyAtmosphere) scene.skyAtmosphere.show = sky.atmosphere; + if (scene.globe) scene.globe.showGroundAtmosphere = sky.atmosphere; + } + if (atmosphereTuning && scene.skyAtmosphere) { + const tuning = sky.atmosphereTuning; + if (tuning.brightness !== undefined) + scene.skyAtmosphere.brightnessShift = tuning.brightness; + if (tuning.hueShift !== undefined) + scene.skyAtmosphere.hueShift = tuning.hueShift; + if (tuning.saturation !== undefined) + scene.skyAtmosphere.saturationShift = tuning.saturation; + } + if ('stars' in settings && scene.skyBox && 'show' in scene.skyBox) + scene.skyBox.show = sky.stars; + if ( + 'milkyWay' in settings && + scene.skyBox?.milkyWayVisibility !== undefined + ) + scene.skyBox.milkyWayVisibility = sky.milkyWay; + if ('sun' in settings && scene.sun) scene.sun.show = sky.sun; + if ('moon' in settings && scene.moon) scene.moon.show = sky.moon; + if ('fog' in settings && scene.fog) scene.fog.enabled = sky.fog; + if ('lighting' in settings && scene.globe) + scene.globe.enableLighting = sky.lighting; + requestRender('sky'); + } + + // --- Settling and progress --------------------------------------------- + + const terrainLoaded = () => + !scene.globe?.show || scene.globe.tilesLoaded !== false; + const tilesLoaded = () => { + const base = drawnBase(); + return !base || base.tilesLoaded !== false; + }; + const imageryLoaded = () => imagery.pending() === 0 && terrainLoaded(); + + function isSettled(kind) { + if (kind === 'terrain') return terrainLoaded(); + if (kind === 'tiles') return tilesLoaded(); + if (kind === 'imagery') return imageryLoaded(); + return terrainLoaded() && tilesLoaded() && imageryLoaded(); + } + + const progressListeners = listenerList(report); + const progress = { tilesPending: 0, imageryPending: 0, terrainPending: 0 }; + let lastProgress = ''; + function emitProgress() { + progress.imageryPending = imagery.pending(); + const key = `${progress.tilesPending}/${progress.imageryPending}/${progress.terrainPending}`; + if (key === lastProgress || progressListeners.size === 0) return; + lastProgress = key; + progressListeners.fire({ ...progress }); + } + const removers = []; + const globeProgress = scene.globe?.tileLoadProgressEvent; + if (globeProgress?.addEventListener) + removers.push( + globeProgress.addEventListener((queued) => { + progress.terrainPending = queued; + emitProgress(); + }), + ); + + // Every tick: where each layer drapes follows what is drawn, and the base + // tileset's load progress is read. + if (scene.preUpdate?.addEventListener) + removers.push( + scene.preUpdate.addEventListener(() => { + // A throw here would stop the widget's render loop. + try { + if (imagery.list().length) imagery.sync(); + if (progressListeners.size === 0) return; + const base = drawnBase(); + progress.tilesPending = base && !base.tilesLoaded ? 1 : 0; + emitProgress(); + } catch (error) { + report(error); + } + }), + ); + + // --- Port ------------------------------------------------------------------ + + const port = { + setBase, + setPhotoreal, + setTerrain, + setGlobeVisible(visible) { + if (!scene.globe) return; + scene.globe.show = Boolean(visible); + imagery.sync(); + requestRender('base'); + }, + imagery: { + add: (spec, index) => imagery.add(spec, index), + list: imagery.list, + }, + sky: { get: readSky, set: setSky }, + isSettled, + on(event, cb) { + if (event !== 'progress') return () => {}; + return progressListeners.on(cb); + }, + }; + if (can('splitSwipe')) { + // Cesium's split starts at the canvas's left edge, as the scene was + // made: a `left` layer shows once the split moves. + port.imagery.getSplit = () => scene.splitPosition ?? 0; + port.imagery.setSplit = (x01) => { + scene.splitPosition = Math.min(1, Math.max(0, Number(x01))); + requestRender('imagery'); + }; + } + + return { + port, + dispose() { + if (disposed) return; + disposed = true; + photorealGen += 1; + terrainGen += 1; + for (const off of removers) off(); + progressListeners.clear(); + for (const done of [...settleWaits]) done(); + imagery.dispose(); + baseCredit?.(); + baseCredit = null; + }, + }; +} + +// A viewer of a consumer's own, with no renderer bound, gets a globe port +// built over it: imagery and the sky as the renderer's would. +provideUnboundGlobe((viewer) => { + if (!viewer?.scene) return null; + const { port, dispose } = createCesiumGlobe(viewer, { + capabilities: new Set(['imageHooks', 'imageryOnTiles', 'splitSwipe']), + requestRender: () => viewer.scene.requestRender?.(), + }); + // A renderer bound later leaves it running: its layers keep following + // the base until their callers remove them. + return { port, dispose, release() {} }; +}); diff --git a/src/render/cesium/globe.test.mjs b/src/render/cesium/globe.test.mjs new file mode 100644 index 000000000..49fb95413 --- /dev/null +++ b/src/render/cesium/globe.test.mjs @@ -0,0 +1,534 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createCesiumGlobe, markBaseTileset } from './globe.js'; +import { hookProvider, xyzTileUrl } from './imagery.js'; + +const ALL = new Set(['imageHooks', 'imageryOnTiles', 'splitSwipe', 'terrain']); +const url = (name) => `https://${name}.invalid/{z}/{x}/{y}.png`; + +function fakeScene() { + const primitives = []; + return { + imageryLayers: new Cesium.ImageryLayerCollection(), + globe: { + show: true, + tilesLoaded: true, + showGroundAtmosphere: true, + enableLighting: false, + }, + primitives: { + get length() { + return primitives.length; + }, + get: (i) => primitives[i], + add: (p) => (primitives.push(p), p), + remove(p) { + const i = primitives.indexOf(p); + if (i < 0) return false; + primitives.splice(i, 1); + p.destroy(); + return true; + }, + }, + preUpdate: new Cesium.Event(), + fog: { enabled: true }, + sun: { show: true }, + moon: { show: false }, + skyAtmosphere: { + show: true, + brightnessShift: -0.08, + saturationShift: -0.12, + }, + skyBox: { show: true, milkyWayVisibility: 1 }, + splitPosition: 0, + verticalExaggeration: 1, + }; +} + +/** A 3D tileset as the port sees one: shown, with its imagery collection. */ +function fakeTileset() { + const tileset = Object.create(Cesium.Cesium3DTileset.prototype); + let destroyed = false; + Object.defineProperties(tileset, { + show: { value: true, writable: true }, + tilesLoaded: { value: true, writable: true }, + imageryLayers: { value: new Cesium.ImageryLayerCollection() }, + }); + tileset.isDestroyed = () => destroyed; + tileset.destroy = () => { + destroyed = true; + tileset.imageryLayers.destroy(); + }; + return tileset; +} + +function setup(capabilities = ALL) { + const scene = fakeScene(); + const renders = []; + const { port, dispose } = createCesiumGlobe( + { scene }, + { capabilities, requestRender: (reason) => renders.push(reason) }, + ); + const tick = () => scene.preUpdate.raiseEvent(scene); + return { scene, port, dispose, renders, tick }; +} + +const providerUrl = (layer) => layer.imageryProvider.url; + +test('port layers keep their order above layers others added', () => { + const { scene, port } = setup(); + const base = scene.imageryLayers.addImageryProvider( + new Cesium.UrlTemplateImageryProvider({ url: url('base') }), + ); + const a = port.imagery.add({ kind: 'xyz', url: url('a') }); + const b = port.imagery.add({ kind: 'xyz', url: url('b') }); + const order = () => + Array.from({ length: scene.imageryLayers.length }, (_, i) => + providerUrl(scene.imageryLayers.get(i)).split('.')[0].slice(8), + ); + assert.deepEqual(order(), ['base', 'a', 'b']); + a.raise(); + assert.deepEqual(order(), ['base', 'b', 'a']); + assert.deepEqual(port.imagery.list(), [b, a]); + a.lower(); + assert.deepEqual(order(), ['base', 'a', 'b']); + const under = port.imagery.add({ kind: 'xyz', url: url('c') }, 0); + assert.deepEqual(order(), ['base', 'c', 'a', 'b']); + under.remove(); + a.remove(); + assert.deepEqual(order(), ['base', 'b']); + assert.ok(!base.isDestroyed(), 'the other layer is left alone'); +}); + +test('zOrder places a layer above lower and equal ones', () => { + const { port } = setup(); + const top = port.imagery.add({ kind: 'xyz', url: url('a'), zOrder: 5 }); + const low = port.imagery.add({ kind: 'xyz', url: url('b'), zOrder: 1 }); + const mid = port.imagery.add({ kind: 'xyz', url: url('c'), zOrder: 1 }); + assert.deepEqual(port.imagery.list(), [low, mid, top]); +}); + +test('the look writes through to the Cesium layer', () => { + const { scene, port } = setup(); + const layer = port.imagery.add({ kind: 'xyz', url: url('a') }); + const cesium = scene.imageryLayers.get(0); + layer.alpha = 0.25; + layer.show = false; + layer.split = 'left'; + assert.equal(cesium.alpha, 0.25); + assert.equal(cesium.show, false); + assert.equal(cesium.splitDirection, Cesium.SplitDirection.LEFT); + assert.throws(() => (layer.split = 'up'), TypeError); + assert.throws(() => (layer.drapeOn = 'sky'), TypeError); + assert.equal(layer.lit, true); + layer.lit = false; + assert.equal(layer.lit, false); +}); + +test('imagery drapes on the drawn base tileset while the globe is hidden', () => { + const { scene, port, tick } = setup(); + const tileset = fakeTileset(); + markBaseTileset(tileset); + scene.primitives.add(tileset); + const all = port.imagery.add({ kind: 'xyz', url: url('all') }); + const surface = port.imagery.add({ kind: 'xyz', url: url('surface') }); + surface.drapeOn = 'surface'; + const objects = port.imagery.add({ kind: 'xyz', url: url('objects') }); + objects.drapeOn = 'objects'; + assert.equal(scene.imageryLayers.length, 2, 'all and surface on the globe'); + assert.equal(tileset.imageryLayers.length, 1, 'objects on the tiles'); + + // The map stack hides the globe itself; the next tick follows it. + scene.globe.show = false; + tick(); + assert.equal(scene.imageryLayers.length, 1, 'surface stays'); + assert.equal(tileset.imageryLayers.length, 2); + assert.ok( + tileset.imageryLayers.indexOf(tileset.imageryLayers.get(0)) === 0 && + providerUrl(tileset.imageryLayers.get(0)).includes('all'), + 'port order holds on the tiles', + ); + + tileset.show = false; + tick(); + assert.equal(tileset.imageryLayers.length, 0, 'nothing on hidden tiles'); + + // A tileset destroyed with a layer on it: the layer is built again. + tileset.show = true; + tick(); + scene.primitives.remove(tileset); + scene.globe.show = true; + tick(); + assert.equal(scene.imageryLayers.length, 2); + all.remove(); + surface.remove(); + objects.remove(); + assert.equal(scene.imageryLayers.length, 0); +}); + +test('an unmarked tileset is not a base, and without imageryOnTiles nothing drapes on tiles', () => { + const { scene, port, tick } = setup(); + const tileset = fakeTileset(); + scene.primitives.add(tileset); + scene.globe.show = false; + port.imagery.add({ kind: 'xyz', url: url('a') }); + tick(); + assert.equal(tileset.imageryLayers.length, 0); + + const other = setup(new Set(['imageHooks'])); + const marked = fakeTileset(); + markBaseTileset(marked); + other.scene.primitives.add(marked); + other.scene.globe.show = false; + const layer = other.port.imagery.add({ kind: 'xyz', url: url('a') }); + layer.drapeOn = 'objects'; + assert.equal(marked.imageryLayers.length, 0); + assert.equal(other.scene.imageryLayers.length, 0); +}); + +test('without imageHooks a spec with main-thread hooks throws', () => { + const { port } = setup(new Set()); + assert.throws( + () => port.imagery.add({ kind: 'canvas', drawTile() {} }), + /imageHooks/, + ); + assert.throws( + () => + port.imagery.add({ kind: 'xyz', url: url('a'), admit: (t, i) => i() }), + /imageHooks/, + ); + assert.ok(!('getSplit' in port.imagery), 'no split without splitSwipe'); +}); + +test('the split position is a clamped fraction of the canvas', () => { + const { scene, port } = setup(); + port.imagery.setSplit(0.3); + assert.equal(scene.splitPosition, 0.3); + assert.equal(port.imagery.getSplit(), 0.3); + port.imagery.setSplit(2); + assert.equal(port.imagery.getSplit(), 1); +}); + +test('tile errors are told with the tile and status', () => { + const { scene, port } = setup(); + const layer = port.imagery.add({ kind: 'xyz', url: url('a') }); + const errors = []; + layer.on('tileError', (error) => errors.push(error)); + const provider = scene.imageryLayers.get(0).imageryProvider; + provider.errorEvent.raiseEvent({ + x: 1, + y: 2, + level: 3, + error: { statusCode: 404 }, + }); + assert.deepEqual(errors, [{ tile: { x: 1, y: 2, level: 3 }, status: 404 }]); + layer.remove(); + provider.errorEvent.raiseEvent({ x: 0, y: 0, level: 0 }); + assert.equal(errors.length, 1, 'a removed layer says nothing'); +}); + +test('admit decides first; at most maxConcurrentRequests are in flight', async () => { + const resolvers = []; + const provider = { + tilingScheme: new Cesium.WebMercatorTilingScheme(), + requestImage: () => new Promise((resolve) => resolvers.push(resolve)), + }; + const counts = { started: 0, settled: 0, retries: 0 }; + const ctx = { + started: () => (counts.started += 1), + settled: () => (counts.settled += 1), + retrySoon: () => (counts.retries += 1), + laterFrame: () => Promise.resolve(), + signals: new Set(), + }; + const admitted = []; + const hooked = hookProvider( + provider, + { + kind: 'xyz', + url: url('a'), + maxConcurrentRequests: 1, + admit(tile, issue) { + admitted.push(tile); + return tile.x === 9 ? undefined : issue(); + }, + }, + ctx, + ); + const first = hooked.requestImage(0, 0, 1); + assert.ok(first, 'the first is issued'); + assert.equal(hooked.requestImage(1, 0, 1), undefined, 'the second waits'); + assert.equal(counts.retries, 0, 'a full budget asks for no frame itself'); + assert.equal(hooked.requestImage(9, 0, 1), undefined, 'admit deferred'); + assert.equal(counts.retries, 1, 'admit deferring asks for a later frame'); + assert.deepEqual(admitted[0], { x: 0, y: 0, level: 1 }); + resolvers[0]('image'); + assert.equal(await first, 'image'); + assert.deepEqual(counts, { started: 1, settled: 1, retries: 1 }); + assert.ok(hooked.requestImage(1, 0, 1), 'the budget is free again'); +}); + +test('fetchTile fetches in place of the provider, with the URL it would fetch', async (t) => { + const asked = []; + const answers = [{ defer: true }, { missing: true }]; + const provider = { + tilingScheme: new Cesium.WebMercatorTilingScheme(), + requestImage: () => assert.fail('the provider fetches nothing'), + }; + const ctx = { + started() {}, + settled() {}, + retrySoon() {}, + laterFrame: () => Promise.resolve(), + signals: new Set(), + }; + const hooked = hookProvider( + provider, + { + kind: 'xyz', + url: 'https://{s}.tiles.invalid/{z}/{x}/{reverseY}.png', + subdomains: ['p', 'q'], + async fetchTile(request) { + asked.push(request.url); + return answers.shift(); + }, + }, + ctx, + ); + await assert.rejects(hooked.requestImage(1, 0, 1), { name: 'MissingTile' }); + assert.deepEqual(asked, [ + 'https://p.tiles.invalid/1/1/1.png', + 'https://p.tiles.invalid/1/1/1.png', + ]); + assert.equal(ctx.signals.size, 0); + const { port } = setup(); + assert.throws( + () => + port.imagery.add({ + kind: 'arcgis', + url: 'https://arcgis.invalid', + fetchTile: async () => ({ missing: true }), + }), + /xyz/, + 'refused at once, before any provider is built', + ); + t.diagnostic( + xyzTileUrl({ url: '{z}/{x}/{y}' }, provider.tilingScheme, { + x: 1, + y: 2, + level: 3, + }), + ); +}); + +test('the sky reads the scene and writes through, keeping the rest', () => { + const { scene, port } = setup(); + const before = port.sky.get(); + assert.deepEqual( + { ...before, atmosphereTuning: undefined }, + { + atmosphere: true, + atmosphereTuning: undefined, + stars: true, + milkyWay: 1, + sun: true, + moon: false, + fog: true, + lighting: false, + }, + ); + port.sky.set({ + fog: false, + milkyWay: 0.25, + atmosphereTuning: { hueShift: 0.1 }, + }); + assert.equal(scene.fog.enabled, false); + assert.equal(scene.skyBox.milkyWayVisibility, 0.25); + assert.equal(scene.skyAtmosphere.hueShift, 0.1); + assert.equal(scene.skyAtmosphere.brightnessShift, -0.08, 'tuning kept'); + // Written outside the port, as the IR mode still does: read as drawn. + scene.fog.enabled = true; + assert.equal(port.sky.get().fog, true); + assert.equal(port.sky.get().stars, true); +}); + +test('a sky part the scene lacks keeps what was set', () => { + const scene = fakeScene(); + delete scene.skyBox; + const { port } = createCesiumGlobe( + { scene }, + { capabilities: ALL, requestRender() {} }, + ); + assert.equal(port.sky.get().stars, false); + port.sky.set({ stars: true, milkyWay: 0.5 }); + assert.equal(port.sky.get().stars, true); + assert.equal(port.sky.get().milkyWay, 0.5); +}); + +test('terrain: the ellipsoid with exaggeration; the last call wins', async () => { + const { scene, port } = setup(); + await port.setTerrain({ kind: 'ellipsoid', exaggeration: 2 }); + assert.ok(scene.terrainProvider instanceof Cesium.EllipsoidTerrainProvider); + assert.equal(scene.verticalExaggeration, 2); + await port.setTerrain(null); + assert.equal(scene.verticalExaggeration, 1); + await assert.rejects( + port.setTerrain({ + kind: 'heightmap', + url: '', + encoding: 'mapbox', + geoid: 'egm96', + }), + /Geoid/, + ); + const flat = setup(new Set()); + await assert.rejects( + flat.port.setTerrain({ kind: 'quantizedMesh', url: 'x' }), + /no terrain/, + ); +}); + +test('setBase replaces the imagery and says what it drew', async () => { + const { scene, port } = setup(); + const overlay = port.imagery.add({ kind: 'xyz', url: url('overlay') }); + const status = await port.setBase({ + photoreal: null, + terrain: { kind: 'ellipsoid' }, + imagery: [ + { kind: 'xyz', url: url('one') }, + { kind: 'xyz', url: url('two') }, + ], + }); + assert.deepEqual(status.photoreal, { route: 'none' }); + assert.equal(status.imagery.length, 2); + assert.deepEqual(port.imagery.list(), status.imagery); + assert.ok(!port.imagery.list().includes(overlay)); + assert.equal(scene.imageryLayers.length, 2); + assert.equal(status.settled, true); + await assert.rejects(port.setBase({ vector: { styleUrl: 'x' } }), /vector/); +}); + +test('globe visibility, settling and progress', () => { + const { scene, port, tick } = setup(); + port.setGlobeVisible(false); + assert.equal(scene.globe.show, false); + assert.equal(port.isSettled('terrain'), true, 'a hidden globe loads nothing'); + port.setGlobeVisible(true); + scene.globe.tilesLoaded = false; + assert.equal(port.isSettled('terrain'), false); + assert.equal(port.isSettled('base'), false); + const tileset = fakeTileset(); + markBaseTileset(tileset); + scene.primitives.add(tileset); + tileset.tilesLoaded = false; + const seen = []; + port.on('progress', (p) => seen.push(p)); + tick(); + assert.deepEqual(seen, [ + { tilesPending: 1, imageryPending: 0, terrainPending: 0 }, + ]); + tick(); + assert.equal(seen.length, 1, 'only changes are told'); + assert.equal(port.isSettled('tiles'), false); +}); + +test('a disposed port stops following the scene', () => { + const { scene, port, dispose, tick } = setup(); + const tileset = fakeTileset(); + markBaseTileset(tileset); + scene.primitives.add(tileset); + port.imagery.add({ kind: 'xyz', url: url('a') }); + dispose(); + scene.globe.show = false; + tick(); + assert.equal(tileset.imageryLayers.length, 0); + assert.throws(() => port.imagery.add({ kind: 'xyz', url: url('b') })); +}); + +test('a layer with no zOrder counts as 0', () => { + const { port } = setup(); + const top = port.imagery.add({ kind: 'xyz', url: url('a'), zOrder: 5 }); + const plain = port.imagery.add({ kind: 'xyz', url: url('b') }); + assert.deepEqual(port.imagery.list(), [plain, top]); +}); + +test('a deferred fetch frees its budget slot while it waits', async () => { + let release; + const waiting = new Promise((resolve) => (release = resolve)); + const asked = []; + const ctx = { + started() {}, + settled() {}, + retrySoon() {}, + laterFrame: () => waiting, + signals: new Set(), + }; + const hooked = hookProvider( + { tilingScheme: new Cesium.WebMercatorTilingScheme() }, + { + kind: 'xyz', + url: url('a'), + maxConcurrentRequests: 1, + async fetchTile({ tile, signal }) { + asked.push(tile.x); + signal.throwIfAborted(); + return tile.x === 0 && asked.length === 1 + ? { defer: true } + : { missing: true }; + }, + }, + ctx, + ); + const first = hooked.requestImage(0, 0, 1); + await new Promise((resolve) => setImmediate(resolve)); + const second = hooked.requestImage(1, 0, 1); + assert.ok(second, 'issued while the first waits'); + await assert.rejects(second, { name: 'MissingTile' }); + for (const controller of ctx.signals) controller.abort(); + release(); + await assert.rejects(first, { name: 'AbortError' }); +}); + +test('a hook that throws fails its tile, never the frame', async () => { + const ctx = { + started() {}, + settled() {}, + retrySoon() {}, + laterFrame: () => Promise.resolve(), + signals: new Set(), + }; + const hooked = hookProvider( + { + tilingScheme: new Cesium.WebMercatorTilingScheme(), + requestImage() { + throw new Error('provider'); + }, + }, + { + kind: 'xyz', + url: url('a'), + admit(tile, issue) { + if (tile.x === 1) throw new Error('admit'); + return issue(); + }, + }, + ctx, + ); + await assert.rejects(hooked.requestImage(1, 0, 1), /admit/); + await assert.rejects(hooked.requestImage(0, 0, 1), /provider/); +}); + +test('a scene with no globe drapes on its base tileset', () => { + const scene = fakeScene(); + delete scene.globe; + const { port } = createCesiumGlobe( + { scene }, + { capabilities: ALL, requestRender() {} }, + ); + const tileset = fakeTileset(); + markBaseTileset(tileset); + scene.primitives.add(tileset); + port.imagery.add({ kind: 'xyz', url: url('a') }); + assert.equal(tileset.imageryLayers.length, 1); +}); diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 1ab279fe9..0f036deea 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -50,7 +50,10 @@ export const CESIUM_CHECKS = new Set([ 'frame.renderOnce', 'frame.suspend', 'frame.time', + 'globe.imagery', 'globe.sky', + 'globe.split', + 'globe.withoutImageHooks', 'input.behaviours', 'input.clickPick', 'input.lease', diff --git a/src/render/cesium/imagery.js b/src/render/cesium/imagery.js new file mode 100644 index 000000000..5b66a0a7b --- /dev/null +++ b/src/render/cesium/imagery.js @@ -0,0 +1,636 @@ +/** + * @module render/cesium/imagery + * @description The Cesium globe port's imagery layers: a contract + * `ImagerySpec` as a Cesium imagery provider, its tile hooks, and the layer + * handles `globe.imagery` hands out (`src/render/contract/globe.d.ts`). + * + * Each handle owns one Cesium `ImageryLayer` and moves it to where its + * `drapeOn` says it drapes: the globe's imagery collection, or the drawn + * base tileset's own (`imageryOnTiles`). `all` drapes on the globe while it + * is shown, else on the base tileset. Port layers keep their order among + * themselves in every collection; layers others added to a collection stay + * where they are. + */ +import * as Cesium from 'cesium'; +import { creditMarkup } from './credits.js'; +import { listenerList } from './common.js'; +import { withTileRequestHook } from './tileRequests.js'; + +const SPLITS = { none: 'NONE', left: 'LEFT', right: 'RIGHT' }; +const DRAPES = new Set(['surface', 'objects', 'all']); +/** Delay before the one frame that asks again for deferred tiles. */ +export const TILE_RETRY_DELAY_MS = 250; +/** Ion's world imagery assets, by contract style. */ +const WORLD_ASSETS = { aerial: 2, aerialLabels: 3 }; + +/** A tile the source says is not there: Cesium draws the parent's. */ +class MissingTile extends Error { + constructor() { + super('No tile here'); + this.name = 'MissingTile'; + } +} + +const rectangleOf = (rect) => + rect && + Cesium.Rectangle.fromDegrees(rect.west, rect.south, rect.east, rect.north); + +const tilingOf = (name) => + name === 'geographic' + ? new Cesium.GeographicTilingScheme() + : new Cesium.WebMercatorTilingScheme(); + +/** A contract credit as Cesium's, or undefined. */ +export const cesiumCredit = (credit) => + credit + ? new Cesium.Credit(creditMarkup(credit), credit.inline === true) + : undefined; + +/** The URL, as a resource retrying the statuses the spec names. */ +function resourceOf(spec) { + if (!spec.retryStatuses?.length) return spec.url; + const statuses = new Set(spec.retryStatuses); + return new Cesium.Resource({ + url: spec.url, + retryAttempts: 2, + retryCallback: (_resource, error) => statuses.has(error?.statusCode), + }); +} + +/** + * A provider that draws each tile on a canvas (`canvas` imagery), with the + * members Cesium's imagery layer reads. + */ +function canvasProvider(spec) { + const tilingScheme = tilingOf(spec.tiling ?? 'geographic'); + const size = spec.tileSize ?? 256; + const errorEvent = new Cesium.Event(); + return { + tilingScheme, + tileWidth: size, + tileHeight: size, + minimumLevel: spec.minLevel ?? 0, + maximumLevel: spec.maxLevel, + rectangle: rectangleOf(spec.rectangle) ?? tilingScheme.rectangle, + credit: cesiumCredit(spec.credit), + errorEvent, + hasAlphaChannel: true, + tileDiscardPolicy: undefined, + proxy: undefined, + getTileCredits: () => undefined, + pickFeatures: () => undefined, + requestImage(x, y, level) { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + return guarded(() => { + spec.drawTile({ x, y, level }, canvas); + return Promise.resolve(canvas); + }); + }, + }; +} + +/** + * The Cesium provider a spec describes; a promise for the kinds Cesium + * builds from metadata it fetches first. + * @param {import('../contract/globe').ImagerySpec} spec + * @param {{worldDataToken?: string}} options + */ +export function providerFor(spec, { worldDataToken } = {}) { + const common = { + rectangle: rectangleOf(spec.rectangle), + minimumLevel: spec.minLevel, + maximumLevel: spec.maxLevel, + credit: cesiumCredit(spec.credit), + ...(spec.tileSize + ? { tileWidth: spec.tileSize, tileHeight: spec.tileSize } + : {}), + }; + for (const key of Object.keys(common)) + if (common[key] === undefined) delete common[key]; + switch (spec.kind) { + case 'xyz': + return new Cesium.UrlTemplateImageryProvider({ + url: resourceOf(spec), + tilingScheme: tilingOf(spec.tiling), + ...(spec.subdomains ? { subdomains: spec.subdomains } : {}), + ...common, + }); + case 'wmts': + if (!spec.wmts) throw new TypeError('WMTS imagery needs its wmts fields'); + return new Cesium.WebMapTileServiceImageryProvider({ + url: resourceOf(spec), + layer: spec.wmts.layer, + style: spec.wmts.style, + tileMatrixSetID: spec.wmts.tileMatrixSetId, + ...(spec.wmts.format ? { format: spec.wmts.format } : {}), + tilingScheme: tilingOf(spec.tiling), + ...(spec.subdomains ? { subdomains: spec.subdomains } : {}), + ...common, + }); + case 'arcgis': + return Cesium.ArcGisMapServerImageryProvider.fromUrl(resourceOf(spec), { + enablePickFeatures: false, + ...common, + }); + case 'single-image': + return Cesium.SingleTileImageryProvider.fromUrl(resourceOf(spec), common); + case 'world': { + const accessToken = String(worldDataToken || '').trim(); + if (!accessToken) + throw new TypeError( + 'World imagery needs the renderer’s world data token', + ); + return Cesium.IonImageryProvider.fromAssetId( + WORLD_ASSETS[spec.worldStyle ?? 'aerial'] ?? WORLD_ASSETS.aerial, + { accessToken }, + ); + } + case 'canvas': + if (typeof spec.drawTile !== 'function') + throw new TypeError('Canvas imagery needs drawTile'); + return canvasProvider(spec); + default: + throw new TypeError(`Unknown imagery kind: ${spec.kind}`); + } +} + +/** + * The URL a tile of an `xyz` spec is fetched from, as Cesium's URL template + * provider builds it, for `fetchTile`. + */ +export function xyzTileUrl(spec, tilingScheme, { x, y, level }) { + const subdomains = spec.subdomains?.length + ? spec.subdomains + : ['a', 'b', 'c']; + const rows = tilingScheme.getNumberOfYTilesAtLevel(level); + return spec.url + .replaceAll('{x}', String(x)) + .replaceAll('{y}', String(y)) + .replaceAll('{z}', String(level)) + .replaceAll('{reverseY}', String(rows - y - 1)) + .replaceAll('{s}', subdomains[(x + y + level) % subdomains.length]); +} + +/** + * Cesium draws an `ImageBitmap` tile as decoded, flipped as it decodes its + * own (`imageOrientation: 'flipY'`); any other image it flips as it uploads. + */ +const FLIPPED = { imageOrientation: 'flipY' }; +const isBitmap = (image) => + typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap; + +/** A tile's bytes decoded as Cesium decodes its own. */ +const decodeTile = (blob) => + createImageBitmap(blob, { + ...FLIPPED, + premultiplyAlpha: 'none', + colorSpaceConversion: 'default', + }); + +/** + * `tileTransform` on an upright tile; its result goes back to Cesium + * flipped, as Cesium's own bitmaps are. + */ +async function transformTile(spec, tile, image) { + const upright = await createImageBitmap( + image, + isBitmap(image) ? FLIPPED : {}, + ); + const result = await spec.tileTransform(tile, upright); + return createImageBitmap(result, FLIPPED); +} + +/** A hook's synchronous throw as a failed tile, never a throw into the frame. */ +function guarded(run) { + try { + return run(); + } catch (error) { + return Promise.reject(error); + } +} + +/** + * The provider with the spec's hooks and the port's accounting on its tile + * requests: `admit` decides first, an issued request goes through + * `fetchTile` (or the provider), then `tileTransform`; at most + * `maxConcurrentRequests` are in flight. + * @param {object} provider + * @param {import('../contract/globe').ImagerySpec} spec + * @param {{started: () => void, settled: () => void, retrySoon: () => void, + * laterFrame: () => Promise, signals: Set}} ctx + */ +export function hookProvider(provider, spec, ctx) { + const limit = + spec.maxConcurrentRequests > 0 ? spec.maxConcurrentRequests : Infinity; + let inFlight = 0; + async function fetchVia(tile) { + const url = xyzTileUrl(spec, provider.tilingScheme, tile); + const controller = new AbortController(); + ctx.signals.add(controller); + try { + for (;;) { + const answer = await spec.fetchTile({ + tile, + url, + signal: controller.signal, + }); + controller.signal.throwIfAborted(); + if (answer?.defer) { + // Its budget slot is free while it waits, so a deferred tile + // never holds up the others. + inFlight -= 1; + try { + await ctx.laterFrame(); + // Back in when the budget has room again. + while (inFlight >= limit) { + controller.signal.throwIfAborted(); + await ctx.laterFrame(); + } + } finally { + inFlight += 1; + } + controller.signal.throwIfAborted(); + continue; + } + if (!answer || answer.missing) throw new MissingTile(); + const blob = new Blob( + [answer.bytes], + answer.contentType ? { type: answer.contentType } : undefined, + ); + return await decodeTile(blob); + } + } finally { + ctx.signals.delete(controller); + } + } + + return withTileRequestHook(provider, { + requestImage({ x, y, level }, ask) { + const tile = { x, y, level }; + // Deferred by the budget: the request in flight asks for a frame as + // it settles, so none is asked for here. + let waitsOnBudget = false; + const issue = () => { + if (inFlight >= limit) { + waitsOnBudget = true; + return undefined; + } + waitsOnBudget = false; + const started = spec.fetchTile ? fetchVia(tile) : guarded(ask); + if (!started) { + // Cesium's own request limits deferred it: one later frame asks + // again. + ctx.retrySoon(); + return undefined; + } + inFlight += 1; + ctx.started(); + let result = Promise.resolve(started); + if (spec.tileTransform) + result = result.then((image) => transformTile(spec, tile, image)); + return result.finally(() => { + inFlight -= 1; + ctx.settled(); + }); + }; + if (!spec.admit) return issue(); + const answer = guarded(() => spec.admit(tile, issue)); + if (answer === undefined && !waitsOnBudget) ctx.retrySoon(); + return answer; + }, + }); +} + +/** + * The imagery layers of one globe port. + * @param {object} scene Cesium Scene. + * @param {{capabilities: ReadonlySet, requestRender: (reason: string) => void, + * report: (error: unknown) => void, objects: () => object|null, + * worldDataToken?: () => string|undefined}} options + * `objects`: the drawn base tileset imagery drapes onto, or null. + */ +export function createCesiumImagery( + scene, + { + capabilities, + requestRender, + report, + objects, + worldDataToken = () => undefined, + }, +) { + /** Port layers, bottom to top. */ + const layers = []; + let pending = 0; + let retryTimer = null; + let disposed = false; + const waiters = []; + + const retrySoon = () => { + if (retryTimer !== null || disposed) return; + retryTimer = setTimeout(() => { + retryTimer = null; + for (const resolve of waiters.splice(0)) resolve(); + if (!disposed) requestRender('imagery'); + }, TILE_RETRY_DELAY_MS); + }; + const laterFrame = () => + new Promise((resolve) => { + if (disposed) { + setTimeout(resolve, TILE_RETRY_DELAY_MS); + return; + } + waiters.push(resolve); + retrySoon(); + }); + + const globeCollection = () => + scene.imageryLayers ?? scene.globe?.imageryLayers; + const usable = (collection) => collection && !collection.isDestroyed?.(); + + /** The collection a layer should be in now. */ + function hostFor(record) { + const tiles = capabilities.has('imageryOnTiles') ? objects() : null; + const tileCollection = + tiles && !tiles.isDestroyed?.() ? tiles.imageryLayers : null; + if (record.drapeOn === 'surface') return globeCollection(); + if (record.drapeOn === 'objects') return tileCollection; + if (scene.globe?.show !== true && usable(tileCollection)) + return tileCollection; + return globeCollection(); + } + + /** Where to put a layer in a collection, keeping port order. */ + function placeIndex(record, collection) { + const at = layers.indexOf(record); + // A layer taken out of the collection by someone else is passed over. + for (let i = at + 1; i < layers.length; i++) { + const found = + layers[i].host === collection + ? collection.indexOf(layers[i].cesium) + : -1; + if (found >= 0) return found; + } + for (let i = at - 1; i >= 0; i--) { + const found = + layers[i].host === collection + ? collection.indexOf(layers[i].cesium) + : -1; + if (found >= 0) return found + 1; + } + return record.base ? 0 : collection.length; + } + + /** Moves a layer within its collection to its place among port layers. */ + function reorder(record) { + const collection = record.host; + if (!usable(collection) || !collection.contains(record.cesium)) return; + const at = layers.indexOf(record); + const inHere = (r) => + r.host === collection && collection.contains(r.cesium); + const above = layers.slice(at + 1).find(inHere); + const below = layers.slice(0, at).reverse().find(inHere); + let guard = collection.length; + while ( + above && + collection.indexOf(record.cesium) > collection.indexOf(above.cesium) && + guard-- > 0 + ) + collection.lower(record.cesium); + guard = collection.length; + while ( + below && + collection.indexOf(record.cesium) < collection.indexOf(below.cesium) && + guard-- > 0 + ) + collection.raise(record.cesium); + } + + /** A Cesium layer for the record, with its look. */ + function build(record) { + const layer = + record.provider !== undefined + ? new Cesium.ImageryLayer(record.provider) + : Cesium.ImageryLayer.fromProviderAsync(record.providerPromise); + layer.alpha = record.alpha; + layer.show = record.show; + layer.splitDirection = Cesium.SplitDirection[SPLITS[record.split]]; + return layer; + } + + /** Puts each layer where it drapes now; rebuilds one a tileset destroyed. */ + function sync() { + if (disposed) return; + let changed = false; + for (const record of layers) { + if (record.removed) continue; + if (record.cesium.isDestroyed()) { + record.cesium = build(record); + record.host = null; + } + // Taken out by someone else: put back where it drapes. + if (usable(record.host) && !record.host.contains(record.cesium)) + record.host = null; + const next = hostFor(record); + const target = usable(next) ? next : null; + if (record.host === target) continue; + if (usable(record.host) && record.host.contains(record.cesium)) + record.host.remove(record.cesium, false); + record.host = null; + if (target) { + target.add(record.cesium, placeIndex(record, target)); + record.host = target; + } + changed = true; + } + if (changed) requestRender('imagery'); + } + + function add(spec, index, { base = false } = {}) { + if (disposed) throw new Error('This globe has been disposed'); + if ( + !capabilities.has('imageHooks') && + (spec.tileTransform || spec.admit || spec.kind === 'canvas') + ) + throw new TypeError( + 'tileTransform, admit and canvas imagery need imageHooks', + ); + if (spec.fetchTile && spec.kind !== 'xyz') + throw new TypeError('fetchTile is supported for xyz imagery only'); + const listeners = { + tileError: listenerList(report), + frameReady: listenerList(report), + }; + const signals = new Set(); + const ctx = { + started: () => (pending += 1), + settled: () => { + pending -= 1; + if (!disposed) requestRender('imagery'); + }, + retrySoon, + laterFrame, + signals, + }; + const record = { + spec: { ...spec }, + base, + alpha: 1, + show: true, + split: 'none', + drapeOn: 'all', + lit: true, + host: null, + removed: false, + provider: undefined, + providerPromise: undefined, + cesium: null, + signals, + offError: null, + }; + const watchErrors = (provider) => { + record.offError = provider.errorEvent?.addEventListener?.((error) => { + if (error?.error instanceof MissingTile) return; + if (listeners.tileError.size === 0) return; + const status = error?.error?.statusCode; + listeners.tileError.fire({ + tile: { x: error?.x, y: error?.y, level: error?.level }, + ...(Number.isFinite(status) ? { status } : {}), + }); + }); + }; + const made = providerFor(spec, { worldDataToken: worldDataToken() }); + if (typeof made?.then === 'function') { + record.providerPromise = Promise.resolve(made).then((provider) => { + const hooked = hookProvider(provider, spec, ctx); + if (!record.removed) watchErrors(provider); + record.provider = hooked; + return hooked; + }); + record.providerPromise.catch(() => {}); + } else { + record.provider = hookProvider(made, spec, ctx); + watchErrors(made); + } + record.cesium = build(record); + + const handle = { + get alpha() { + return record.alpha; + }, + set alpha(value) { + record.alpha = Number(value); + record.cesium.alpha = record.alpha; + requestRender('imagery'); + }, + get show() { + return record.show; + }, + set show(value) { + record.show = Boolean(value); + record.cesium.show = record.show; + requestRender('imagery'); + }, + get split() { + return record.split; + }, + set split(value) { + if (!(value in SPLITS)) throw new TypeError(`Unknown split: ${value}`); + record.split = value; + record.cesium.splitDirection = Cesium.SplitDirection[SPLITS[value]]; + requestRender('imagery'); + }, + get drapeOn() { + return record.drapeOn; + }, + set drapeOn(value) { + if (!DRAPES.has(value)) throw new TypeError(`Unknown drape: ${value}`); + record.drapeOn = value; + sync(); + }, + get lit() { + return record.lit; + }, + // Cesium lights imagery with its surface; an unlit layer is recorded + // and drawn as the surface draws it. + set lit(value) { + record.lit = Boolean(value); + }, + on(event, cb) { + return listeners[event]?.on(cb) ?? (() => {}); + }, + raise() { + const i = layers.indexOf(record); + if (i < 0 || i === layers.length - 1) return; + layers.splice(i, 1); + layers.splice(i + 1, 0, record); + reorder(record); + requestRender('imagery'); + }, + lower() { + const i = layers.indexOf(record); + if (i <= 0) return; + layers.splice(i, 1); + layers.splice(i - 1, 0, record); + reorder(record); + requestRender('imagery'); + }, + remove() { + if (record.removed) return; + record.removed = true; + const i = layers.indexOf(record); + if (i >= 0) layers.splice(i, 1); + record.offError?.(); + for (const controller of signals) controller.abort(); + listeners.tileError.clear(); + listeners.frameReady.clear(); + if (usable(record.host) && record.host.contains(record.cesium)) + record.host.remove(record.cesium, true); + else if (!record.cesium.isDestroyed()) record.cesium.destroy(); + record.host = null; + requestRender('imagery'); + }, + }; + record.handle = handle; + + let at = index; + if (at === undefined) { + // Above every layer of the same or a lower zOrder; none is 0. + const z = Number.isFinite(spec.zOrder) ? spec.zOrder : 0; + at = layers.length; + while (at > 0 && (layers[at - 1].spec.zOrder ?? 0) > z) at -= 1; + } + if (at === undefined || at > layers.length) at = layers.length; + layers.splice(Math.max(0, at), 0, record); + sync(); + requestRender('imagery'); + return handle; + } + + return { + add, + list: () => layers.map((record) => record.handle), + /** Layers whose providers are still being built, as promises. */ + ready: (handles) => + Promise.allSettled( + layers + .filter((r) => handles.includes(r.handle) && r.providerPromise) + .map((r) => r.providerPromise), + ), + sync, + /** Whether any port layer has a tile request in flight. */ + pending: () => pending, + /** + * Stop asking for frames. Layers stay drawn where they are and their + * handles keep working; a deferred fetch asks again on a timer. + */ + dispose() { + disposed = true; + clearTimeout(retryTimer); + retryTimer = null; + for (const resolve of waiters.splice(0)) resolve(); + }, + }; +} diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index f1dc127c7..f36f768ae 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -2,8 +2,8 @@ * @module render/cesium/ports * @description The Cesium renderer's contract ports, built over a Cesium * widget or viewer someone else made and configured: frame phases, the - * draw sets, `setView` and the pose, picks and projection, credits, - * quality and capture. + * globe, the draw sets, `setView` and the pose, picks and projection, + * credits, quality and capture. * Building them puts the loop on demand (no holds yet, and no redraws for * scene time alone); nothing else in the scene changes until a call. * @@ -18,6 +18,7 @@ import { createCesiumCamera } from './camera.js'; import { listenerList, toCartesian, uncaught } from './common.js'; import { createCesiumCredits } from './credits.js'; import { createCesiumEffects } from './effects.js'; +import { createCesiumGlobe } from './globe.js'; import { createCesiumQuality } from './quality.js'; import { atEndOfTick, @@ -103,6 +104,18 @@ function cameraEqual(camera0, camera1, epsilon) { ); } +/** + * What every Cesium globe does: photoreal tiles, terrain, imagery draped on + * tiles, the split and main-thread tile hooks. + */ +const GLOBE_CAPABILITIES = [ + 'photoreal', + 'terrain', + 'imageryOnTiles', + 'splitSwipe', + 'imageHooks', +]; + /** * What a Cesium scene can do, as contract capabilities; `only` limits them * to those it lists. Depth picks, height samples and ray casts all need @@ -114,7 +127,7 @@ function cameraEqual(camera0, camera1, epsilon) { * @returns {string[]} */ export function cesiumCapabilities(scene, only) { - const all = ['models', 'styles', 'bloom', 'sharpen']; + const all = ['models', 'styles', 'bloom', 'sharpen', ...GLOBE_CAPABILITIES]; if (scene.pickPositionSupported) all.push('pickPosition'); if (scene.sampleHeightSupported) all.push('sampleHeight', 'finestHeights', 'rayCast'); @@ -124,10 +137,12 @@ export function cesiumCapabilities(scene, only) { /** * @param {object} widget A Cesium `CesiumWidget` or `Viewer`. - * @param {{capabilities: ReadonlySet}} options The renderer's - * capabilities, which gate the members that need them. + * @param {{capabilities: ReadonlySet, credentials?: object}} options + * The renderer's capabilities, which gate the members that need them, and + * the credentials its globe loads photoreal tiles and world data with + * (`googleApiKey`, `googleTokens`, `worldDataToken`). */ -export function createCesiumPorts(widget, { capabilities }) { +export function createCesiumPorts(widget, { capabilities, credentials = {} }) { const scene = widget.scene; const camera = scene.camera; const sets = new Map(); @@ -841,6 +856,14 @@ export function createCesiumPorts(widget, { capabilities }) { requestRender: () => frame.requestRender('credits'), }); + const globePart = createCesiumGlobe(widget, { + capabilities, + requestRender: (reason) => frame.requestRender(reason), + report, + credits: creditsPart.port, + credentials, + }); + // --- Query ----------------------------------------------------------------- const screen = (x, y) => new Cesium.Cartesian2(x, y); @@ -1390,6 +1413,7 @@ export function createCesiumPorts(widget, { capabilities }) { }, report, frame, + globe: globePart.port, draw, camera: cameraPort, credits: creditsPart.port, @@ -1418,6 +1442,7 @@ export function createCesiumPorts(widget, { capabilities }) { cameraPart.dispose(); creditsPart.dispose(); effectsPart.dispose(); + globePart.dispose(); qualityPart.dispose(); for (const done of [...pendingRenders]) done(); // Late model loads find no host and destroy themselves. diff --git a/src/render/cesium/ports.test.mjs b/src/render/cesium/ports.test.mjs index 632f0af95..ee1b457f7 100644 --- a/src/render/cesium/ports.test.mjs +++ b/src/render/cesium/ports.test.mjs @@ -9,7 +9,16 @@ import { import { updateCameraChanged } from './internals.js'; test('capabilities follow what the scene supports', () => { - const effects = ['styles', 'bloom', 'sharpen']; + const effects = [ + 'styles', + 'bloom', + 'sharpen', + 'photoreal', + 'terrain', + 'imageryOnTiles', + 'splitSwipe', + 'imageHooks', + ]; assert.deepEqual(cesiumCapabilities({}), ['models', ...effects]); assert.deepEqual(cesiumCapabilities({ pickPositionSupported: true }), [ 'models', diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index 2805159f6..765f8aae3 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -1,7 +1,7 @@ /** * @module render/cesium/sceneRenderer * @description The part of the Cesium renderer the reference scenes need, - * through the contract's own members: a bare globe and sky, icons, points, + * through the contract's own members: the globe and sky, icons, points, * lines, shapes and models, `setView`, picks and projection, the frame * phases, pointer input, credits, effects, settling, capture, pixel reads * and cache release. The @@ -10,8 +10,8 @@ * * Its ports are `ports.js`'s, which the application's renderer shares; * this factory adds the conformance widget, its fixed settings, the input - * port (`input.js`, not yet the application's) and the bare-globe and sky - * members. + * port (`input.js`) and a bare starting globe and sky: no imagery or + * terrain, every sky part off. * * It is not yet the whole Cesium renderer: each later migration adds the * members its layer family needs, until it passes the whole suite. @@ -33,16 +33,6 @@ const feeds = new WeakMap(); */ export const pointerFeedOf = (renderer) => feeds.get(renderer) ?? null; -const SKY_DEFAULTS = Object.freeze({ - atmosphere: false, - stars: false, - milkyWay: 0, - sun: false, - moon: false, - fog: false, - lighting: false, -}); - /** * @param {{container: HTMLElement, msaa?: number, capabilities?: Iterable}} options * `capabilities` limits the renderer to those it lists. @@ -78,51 +68,16 @@ export async function createCesiumSceneRenderer({ scene.fog.enabled = false; if (scene.sun) scene.sun.show = false; if (scene.moon) scene.moon.show = false; + scene.globe.enableLighting = false; - const sky = { ...SKY_DEFAULTS }; const capabilities = new Set(cesiumCapabilities(scene, only)); - function applySky() { - scene.globe.enableLighting = sky.lighting; - scene.fog.enabled = sky.fog; - if (scene.sun) scene.sun.show = sky.sun; - if (scene.moon) scene.moon.show = sky.moon; - scene.globe.showGroundAtmosphere = sky.atmosphere; - scene.requestRender(); - } - const ports = createCesiumPorts(widget, { capabilities }); const input = createCesiumInput(widget, { query: ports.query, report: ports.report, }); - // --- Globe ----------------------------------------------------------------- - - const globe = { - async setBase(base) { - if ( - base.photoreal || - (base.terrain && base.terrain.kind !== 'ellipsoid') || - base.imagery?.length - ) - throw new Error('the scene renderer draws a bare globe only'); - return { photoreal: { route: 'none' }, imagery: [], settled: true }; - }, - setGlobeVisible(visible) { - scene.globe.show = visible; - scene.requestRender(); - }, - sky: { - get: () => ({ ...sky }), - set(settings) { - Object.assign(sky, settings); - applySky(); - }, - }, - isSettled: () => scene.globe.tilesLoaded, - }; - // --- Renderer -------------------------------------------------------------- const renderer = { @@ -131,7 +86,7 @@ export async function createCesiumSceneRenderer({ capabilities, canvas: scene.canvas, frame: ports.frame, - globe, + globe: ports.globe, // A member of a capability left out is absent, not stubbed. draw: capabilities.has('models') ? ports.draw diff --git a/src/render/rendererOf.js b/src/render/rendererOf.js index 49546e5ad..0ceea1b01 100644 --- a/src/render/rendererOf.js +++ b/src/render/rendererOf.js @@ -60,6 +60,7 @@ function unboundPorts(member) { const unboundCameras = unboundPorts('camera'); const unboundEffects = unboundPorts('effects'); +const unboundGlobes = unboundPorts('globe'); /** Credits ports are `{port, dispose, release}`: see `bindRenderer`. */ const unboundCredits = unboundPorts('credits'); @@ -93,6 +94,17 @@ export function provideUnboundEffects(build) { unboundEffects.provide(build); } +/** + * Provide the factory `findGlobe` builds a globe port with for a scene + * object no renderer is bound to, as `provideUnboundCamera` does. A bound + * renderer supersedes it with `release`, as credits are. + * @param {(host: object) => ({port: object, dispose: () => void, + * release?: () => void} | null)} build + */ +export function provideUnboundGlobe(build) { + unboundGlobes.provide(build); +} + /** * Let go of `port`, if it is the effects port `findEffects` built for * `host`: its passes go and the scene's own settings come back. A @@ -109,7 +121,9 @@ export function releaseUnboundEffects(host, port) { * built for it while it had no renderer is disposed: its listeners hear * nothing more, and they answer with the renderer's ports from then on. * A credits port `findCredits` built is released, not disposed: the - * credits added through it stay shown until their own Off. + * credits added through it stay shown until their own Off. A globe port + * `findGlobe` built is released too: its imagery stays drawn, following the + * base, until its callers remove it. * @param {object} host The scene object callers hold. * @param {object} renderer * @returns {() => void} Unbinds, if the binding is still this one. @@ -119,6 +133,7 @@ export function bindRenderer(host, renderer) { // The renderer's ports supersede ones built while there was none. unboundCameras.drop(host); unboundEffects.drop(host); + unboundGlobes.supersede(host); unboundCredits.supersede(host); renderers.set(host, renderer); return () => { @@ -157,6 +172,19 @@ export function findEffects(host) { return unboundEffects.find(host); } +/** + * The globe port of the renderer bound to `host`; with no renderer bound, + * one built over `host` by the provided factory and kept with it while + * `host` lives, as `findCamera` does. Imagery added through a built port + * stays drawn once a renderer is bound; the renderer's port lists only + * its own. + * @param {object|null|undefined} host + * @returns {import('./contract/globe').GlobePort | null} + */ +export function findGlobe(host) { + return unboundGlobes.find(host); +} + /** * The credits port of the renderer bound to `host`; with no renderer bound, * one built over `host` by the provided factory and kept with it while diff --git a/src/render/unboundCamera.test.mjs b/src/render/unboundCamera.test.mjs index 5cc603410..3ab1f0aa3 100644 --- a/src/render/unboundCamera.test.mjs +++ b/src/render/unboundCamera.test.mjs @@ -6,7 +6,8 @@ * that module too. It loads `render/cesium/credits.js`, whose factory * builds `findCredits`' port for such a viewer the same way. * Likewise an entry that reaches a caller of `findEffects` must load - * `render/cesium/effects.js`, which provides the effects factory. + * `render/cesium/effects.js`, which provides the effects factory, and one + * that reaches a caller of `findGlobe` must load `render/cesium/globe.js`. * * The graph is every static import, re-export and literal dynamic import of * the entry's module and those it reaches, read by the parsers the Cesium @@ -27,6 +28,7 @@ const root = path.resolve( const BRIDGE = 'src/render/rendererOf.js'; const FACTORY = 'src/render/cesium/camera.js'; const EFFECTS_FACTORY = 'src/render/cesium/effects.js'; +const GLOBE_FACTORY = 'src/render/cesium/globe.js'; /** * Entries that take their Cesium code injected: the module named here is @@ -166,28 +168,41 @@ test('every public entry that reaches the renderer bridge loads the unbound came ); }); -test('every public entry that reaches a findEffects caller loads the unbound effects factory', () => { +/** + * Every public entry reaching a caller of `lookup` loads `factory`. With + * `reached`, at least one entry must reach one, or the walk is broken. + */ +function checkLookupFactory(lookup, factory, { reached = true } = {}) { const missing = []; let reaching = 0; - const callsFindEffects = (file) => + const calls = (file) => !file.startsWith('src/render/') && /\.(m?js|vue)$/.test(file) && - readFileSync(path.join(root, file), 'utf8').includes('findEffects('); + readFileSync(path.join(root, file), 'utf8').includes(`${lookup}(`); for (const [key, declaration] of Object.entries(pkg.exports)) { const target = browserTarget(declaration); if (typeof target !== 'string' || !target.startsWith('./src/')) continue; const entry = path.normalize(target.slice(2)); - if (![...reachable(entry)].some(callsFindEffects)) continue; + if (![...reachable(entry)].some(calls)) continue; reaching += 1; const carrier = INJECTED[key]; - const loads = (file) => - reachable(file, { eager: true }).has(EFFECTS_FACTORY); + const loads = (file) => reachable(file, { eager: true }).has(factory); if (!loads(entry) && !(carrier && loads(carrier))) missing.push(key); } - assert.ok(reaching > 0, 'no entry reaches findEffects: the walk is broken'); + if (reached) + assert.ok(reaching > 0, `no entry reaches ${lookup}: the walk is broken`); assert.deepEqual( missing, [], - `These entries can reach findEffects without ${EFFECTS_FACTORY}; import it where the entry first reaches the lookup.`, + `These entries can reach ${lookup} without ${factory}; import it where the entry first reaches the lookup.`, ); +} + +test('every public entry that reaches a findEffects caller loads the unbound effects factory', () => { + checkLookupFactory('findEffects', EFFECTS_FACTORY); +}); + +test('every public entry that reaches a findGlobe caller loads the unbound globe factory', () => { + // Only the application reaches one today, through no public entry. + checkLookupFactory('findGlobe', GLOBE_FACTORY, { reached: false }); });