Give the Cesium renderer the contract's globe port

The globe port sets the base (photoreal tiles, terrain, imagery), shows
or hides the globe, and adds imagery layers: XYZ, WMTS, ArcGIS, world,
single-image and canvas sources, with admission, fetch and transform
hooks, a request budget and tile errors. Each layer drapes where its
drapeOn says: on the globe, or on the drawn base tileset, which is the
port's own photoreal tileset or one the map stack marks as its base,
followed every frame so a base switched outside the port moves the
layers. Port layers keep their order above other layers in each
collection. The sky reads the scene and writes through to it, and the
port reports settling and load progress.

The application renderer and the conformance renderer both have it, so
the application renderer now has every member of the contract. A viewer
with no renderer bound gets a globe port through findGlobe. The Cesium
conformance run checks imagery order, the split and the image-hook gate.
This commit is contained in:
Sameh Khamis committed 2026-11-06 18:29:40 -08:00
1 parent 9049357916
commit fbcac37f13
12 files changed
+1774 -88

No files matched your search

+2
View File
@@ -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",
+20 -18
View File
@@ -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<import('../contract/renderer').Renderer,
* 'id' | 'contractVersion' | 'capabilities' | 'canvas' | 'frame' | 'draw'
* | 'camera' | 'query' | 'input' | 'effects' | 'quality' | 'capture'
* | 'credits' | 'readPixels' | 'releaseUnused' | 'whenSettled' | 'on'
* | 'resize' | 'destroy'>}
* 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<TransitionalRenderer>}
*/
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,
+1 -3
View File
@@ -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 = [];
+479
View File
@@ -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<string>,
* requestRender: (reason: string) => void,
* report?: (error: unknown) => void,
* credits?: import('../contract/credits').CreditsPort | null,
* credentials?: {googleApiKey?: string, googleTokens?: () => Promise<string>,
* 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() {} };
});
+534
View File
@@ -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);
});
+3
View File
@@ -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',
+636
View File
@@ -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<void>, signals: Set<AbortController>}} 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<string>, 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();
},
};
}
+31 -6
View File
@@ -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<string>}} options The renderer's
* capabilities, which gate the members that need them.
* @param {{capabilities: ReadonlySet<string>, 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.
+10 -1
View File
@@ -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',
+5 -50
View File
@@ -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<string>}} 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
+29 -1
View File
@@ -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
+24 -9
View File
@@ -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 });
});