Settle imagery, world data and sky capabilities in the renderer contract

- setFrames takes a timed source (URL frames, or video tiles in
  segments) on the scene clock by default or its own; frameReady fires
  once each time the shown frame is drawn.
- drapeOn is 'surface' | 'objects' | 'all' plus lit; imagery.host() and
  onHostChange go, and the drape rule Core derives is written out.
- ImagerySpec.fetchTile answers bytes, missing or defer on every
  renderer; tileTransform, admit and canvas imagery need imageHooks, and
  add() throws for them without it.
- Generic names for the default renderer's world data: worldDataToken,
  the photoreal fallback route, and world terrain and imagery.
- clouds and water capabilities, with globe.setClouds and setWater.
This commit is contained in:
Sameh Khamis committed 2026-11-05 13:26:14 -08:00
1 parent e9c0dc5405
commit fefdf242ee
7 files changed
+360 -52

No files matched your search

+3 -1
View File
@@ -166,4 +166,6 @@ easing (`courseBlendMs`, `courseGlide`), and a hidden row keeping its eased
course. The stub keeps no drawn course. These wait for scenes that capture course. The stub keeps no drawn course. These wait for scenes that capture
an icon mid-turn. Height queries that count a set an icon mid-turn. Height queries that count a set
(`includeInHeightQueries`) are not checked either: the stub's heights are (`includeInHeightQueries`) are not checked either: the stub's heights are
scripted, so it does not model them. scripted, so it does not model them. Nor are video frames (a check needs a
video), `fetchTile` (the stub fetches nothing) or what clouds and water
draw.
+159 -1
View File
@@ -20,6 +20,14 @@ import {
} from 'satellite.js'; } from 'satellite.js';
import { deepEqual, equal, near, nearVec, ok, throws } from '../expect.js'; import { deepEqual, equal, near, nearVec, ok, throws } from '../expect.js';
/** 1 × 1 PNGs (red, green, blue), so imagery checks load nothing from the network. */
const PIXEL_PNGS = [
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNg+M/wHwAEAQH/cetH5QAAAABJRU5ErkJggg==',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYPj/HwADAgH/5ncLrgAAAABJRU5ErkJggg==',
];
const PIXEL_PNG = PIXEL_PNGS[0];
/** Elements for the orbit check: a station-like orbit, epoch 2026-01-04. */ /** Elements for the orbit check: a station-like orbit, epoch 2026-01-04. */
const ISS_TLE = Object.freeze([ const ISS_TLE = Object.freeze([
'1 25544U 98067A 26004.50000000 .00016717 00000-0 10270-3 0 9992', '1 25544U 98067A 26004.50000000 .00016717 00000-0 10270-3 0 9992',
@@ -1674,11 +1682,161 @@ export function portChecks() {
deepEqual(ours(), ['a', 'b'], 'lowered'); deepEqual(ours(), ['a', 'b'], 'lowered');
a.alpha = 0.5; a.alpha = 0.5;
equal(a.alpha, 0.5, 'alpha'); equal(a.alpha, 0.5, 'alpha');
equal(b.drapeOn, 'all', 'drapes on whatever is drawn by default');
equal(b.lit, true, 'lit by default');
b.drapeOn = 'surface';
b.lit = false;
equal(b.drapeOn, 'surface', 'drapeOn is settable');
equal(b.lit, false, 'lit is settable');
a.remove(); a.remove();
deepEqual(ours(), ['b'], 'the removed layer is the one gone'); deepEqual(ours(), ['b'], 'the removed layer is the one gone');
b.remove(); b.remove();
deepEqual(ours(), [], 'both removed'); deepEqual(ours(), [], 'both removed');
ok(['globe', 'tiles', 'both'].includes(r.globe.imagery.host()), 'host'); },
),
check(
'globe.imagery.frames',
'timed frames follow scene time, or setFrameTime on their own clock',
async (h, r) => {
await overAustin(h, r, 2000000);
const t0 = r.frame.time();
const frames = [0, 60000, 120000].map((dt, i) => ({
time: t0 + dt,
url: PIXEL_PNGS[i],
}));
const ready = [];
// Frames decode in real time: wait on the clock, drawing meanwhile.
const waitFor = async (time, ms = 5000) => {
const until = Date.now() + ms;
while (!ready.includes(time) && Date.now() < until) {
await drawn(h, r, 1);
await new Promise((done) => setTimeout(done, 10));
}
return ready.includes(time);
};
const settle = () => waitFor(Symbol('nothing'), 300);
const scene = r.globe.imagery.add({
kind: 'single-image',
url: PIXEL_PNG,
});
scene.on('frameReady', (time) => ready.push(time));
scene.setFrames({ kind: 'frames', frames });
r.frame.setTime(t0 + 50000);
ok(await waitFor(t0 + 60000), 'the frame nearest scene time is ready');
await settle();
ready.length = 0;
r.frame.setTime(t0 + 55000);
scene.setFrameTime(t0 + 130000);
await settle();
deepEqual(
ready,
[],
'the same frame is not ready twice, and setFrameTime does nothing on the scene clock',
);
scene.remove();
ready.length = 0;
const own = r.globe.imagery.add({
kind: 'single-image',
url: PIXEL_PNG,
});
own.on('frameReady', (time) => ready.push(time));
own.setFrames({ kind: 'frames', frames }, { clock: 'own' });
own.setFrameTime(t0 + 130000);
ok(await waitFor(t0 + 120000), 'setFrameTime on its own clock');
await settle();
ready.length = 0;
r.frame.setTime(t0 + 5000);
await settle();
deepEqual(ready, [], 'scene time does not move its own clock');
own.remove();
ready.length = 0;
const paused = r.globe.imagery.add({
kind: 'single-image',
url: PIXEL_PNG,
});
paused.on('frameReady', (time) => ready.push(time));
r.frame.suspend(true);
paused.setFrames({ kind: 'frames', frames });
r.frame.setTime(t0 + 1000);
r.frame.setTime(t0 + 61000);
await settle();
deepEqual(ready, [], 'nothing is ready while the loop is suspended');
r.frame.suspend(false);
ok(
await waitFor(t0 + 60000),
'the frame shown is ready once it is drawn',
);
equal(ready[0], t0 + 60000, 'and frames never drawn are not announced');
paused.remove();
ready.length = 0;
const first = r.globe.imagery.add({
kind: 'single-image',
url: PIXEL_PNG,
});
const second = r.globe.imagery.add({
kind: 'single-image',
url: PIXEL_PNG,
});
first.on('frameReady', () => {
ready.push('first');
second.remove();
});
second.on('frameReady', () => ready.push('second'));
first.setFrames({ kind: 'frames', frames });
second.setFrames({ kind: 'frames', frames });
r.frame.setTime(t0 + 120000);
await waitFor('first');
await settle();
ok(
!ready.includes('second'),
'a layer removed while another announces says nothing',
);
first.remove();
},
{ capability: 'imageryFrames' },
),
check(
'globe.withoutImageHooks',
'without imageHooks, a spec with main-thread tile hooks throws',
async (h) => {
const r = await h.create({
viewport: SCRIPT_VIEWPORT,
capabilities: CAPABILITIES.filter((c) => c !== 'imageHooks'),
});
if (!r) return { skip: 'this harness cannot drop capabilities' };
try {
const url = 'https://example.invalid/{z}/{x}/{y}.png';
throws(
() =>
r.globe.imagery.add({
kind: 'xyz',
url,
tileTransform: (t, i) => i,
}),
'tileTransform',
);
throws(
() =>
r.globe.imagery.add({
kind: 'xyz',
url,
admit: (t, issue) => issue(),
}),
'admit',
);
throws(
() => r.globe.imagery.add({ kind: 'canvas', drawTile: () => {} }),
'canvas imagery',
);
const plain = r.globe.imagery.add({ kind: 'xyz', url });
ok(plain, 'a plain layer is added');
plain.remove();
} finally {
r.destroy();
}
}, },
), ),
check( check(
+6
View File
@@ -160,6 +160,12 @@ export type Capability =
| 'imageryFrames' | 'imageryFrames'
/** Wind-relief and similar shading of the globe surface. */ /** Wind-relief and similar shading of the globe surface. */
| 'surfaceShading' | 'surfaceShading'
/** Volumetric clouds (`globe.setClouds`). */
| 'clouds'
/** A water surface on the oceans (`globe.setWater`). */
| 'water'
/** Main-thread tile hooks: `tileTransform`, `admit` and `canvas` imagery. */
| 'imageHooks'
/** A screen-space sector modulation pass (a sweeping sonar band). */ /** A screen-space sector modulation pass (a sweeping sonar band). */
| 'screenModulation' | 'screenModulation'
/** GPU frame timing through timer queries. */ /** GPU frame timing through timer queries. */
+95 -18
View File
@@ -10,13 +10,16 @@ export interface PhotorealSpec {
key?: string; key?: string;
/** Renews a session token; called again when the token expires. */ /** Renews a session token; called again when the token expires. */
token?: () => Promise<string>; token?: () => Promise<string>;
/** Fall back to an ion-hosted copy when Google refuses. */ /**
ionFallback?: boolean; * Fall back to the renderer's copy of the same tiles when Google refuses
* (for the default renderer, one hosted on Cesium ion).
*/
fallback?: boolean;
} }
export interface PhotorealStatus { export interface PhotorealStatus {
/** Which route is drawing, or `'none'` when photoreal is off or failed. */ /** Which route is drawing, or `'none'` when photoreal is off or failed. */
route: 'google' | 'ion' | 'none'; route: 'google' | 'fallback' | 'none';
error?: { code: string; message: string }; error?: { code: string; message: string };
} }
@@ -29,7 +32,11 @@ export interface PhotorealStatus {
export type TerrainSpec = ( export type TerrainSpec = (
| { kind: 'ellipsoid' } | { kind: 'ellipsoid' }
| { kind: 'quantizedMesh'; url: string; requestVertexNormals?: boolean } | { kind: 'quantizedMesh'; url: string; requestVertexNormals?: boolean }
| { kind: 'ion'; assetId: number } /**
* The renderer's global terrain, through `CreateOptions.worldDataToken`
* (for the default renderer, Cesium World Terrain).
*/
| { kind: 'world'; requestVertexNormals?: boolean }
/** A DEM image pyramid (`{z}/{x}/{y}`), heights encoded in RGB. */ /** A DEM image pyramid (`{z}/{x}/{y}`), heights encoded in RGB. */
| { | {
kind: 'heightmap'; kind: 'heightmap';
@@ -53,11 +60,27 @@ export interface TileRequest {
level: number; level: number;
} }
/**
* What a tile fetch answered: the bytes to decode, `missing` (no tile
* there; draw the parent's), or `defer` (ask again on a later frame).
*/
export type TileFetch =
| { bytes: ArrayBuffer; contentType?: string }
| { missing: true }
| { defer: true };
export interface ImagerySpec { export interface ImagerySpec {
kind: 'xyz' | 'wmts' | 'arcgis' | 'bing' | 'single-image' | 'canvas'; /**
* `world`: the renderer's global imagery through
* `CreateOptions.worldDataToken` (for the default renderer, Cesium ion's
* world imagery), in `worldStyle`. `canvas`: `imageHooks`.
*/
kind: 'xyz' | 'wmts' | 'arcgis' | 'world' | 'single-image' | 'canvas';
/** `world`: aerial (the default), or aerial with labels. */
worldStyle?: 'aerial' | 'aerialLabels';
/** URL or URL template (`{x}`, `{y}`, `{z}`, `{s}`, `{time}`). */ /** URL or URL template (`{x}`, `{y}`, `{z}`, `{s}`, `{time}`). */
url?: string; url?: string;
/** `'canvas'`: draws one tile. */ /** `'canvas'` (`imageHooks`): draws one tile. */
drawTile?: (tile: TileRequest, canvas: HTMLCanvasElement) => void; drawTile?: (tile: TileRequest, canvas: HTMLCanvasElement) => void;
/** WMTS: layer, style, tile matrix set and format. */ /** WMTS: layer, style, tile matrix set and format. */
wmts?: { wmts?: {
@@ -77,14 +100,26 @@ export interface ImagerySpec {
maxConcurrentRequests?: number; maxConcurrentRequests?: number;
/** HTTP statuses retried rather than treated as missing. */ /** HTTP statuses retried rather than treated as missing. */
retryStatuses?: number[]; retryStatuses?: number[];
/** Changes a tile's image before it is drawn. */ /**
* Fetches a tile in place of the renderer, with the URL it would fetch.
* Every renderer calls it, before its own decoding (in a worker, say).
*/
fetchTile?: (request: {
tile: TileRequest;
url: string;
signal: AbortSignal;
}) => Promise<TileFetch>;
/** `imageHooks`: changes a tile's decoded image before it is drawn. */
tileTransform?: ( tileTransform?: (
tile: TileRequest, tile: TileRequest,
image: ImageBitmap, image: ImageBitmap,
) => ImageBitmap | Promise<ImageBitmap>; ) => ImageBitmap | Promise<ImageBitmap>;
/** /**
* Decides each tile request: call `issue()` to fetch it, or return * `imageHooks`: decides each tile request on the main thread: call
* `undefined` to defer it and be asked again on a later frame. * `issue()` to fetch it, or return `undefined` to defer it and be asked
* again on a later frame. `fetchTile`'s `defer` does the same anywhere.
* With both, `admit` decides first and an issued request goes through
* `fetchTile`; each defers on its own.
*/ */
admit?: ( admit?: (
tile: TileRequest, tile: TileRequest,
@@ -100,20 +135,56 @@ export interface TimedUrl {
url: string; url: string;
} }
/**
* A timed source: frames as URLs (one template per time), or video tiles
* in segments, each a template whose tiles are videos spanning `start` to
* `end` (epoch ms), a time within shown by its offset into the segment's
* videos. Outside every segment, the nearest segment end is shown.
*/
export type TimedSource =
| { kind: 'frames'; frames: TimedUrl[] }
| {
kind: 'video';
segments: Array<{ url: string; start: number; end: number }>;
};
export interface ImageryLayer { export interface ImageryLayer {
alpha: number; alpha: number;
show: boolean; show: boolean;
/** Which side of the split this layer shows on. */ /** Which side of the split this layer shows on. */
split: 'none' | 'left' | 'right'; split: 'none' | 'left' | 'right';
/** What it drapes onto. `'active'` follows whichever base is showing. */ /**
drapeOn: 'globe' | 'tiles' | 'both' | 'active'; * What it drapes onto: the globe's surface (terrain or the ellipsoid),
/** `imageryFrames`: a timed sequence, prefetched hidden. */ * objects (3D tiles and models; `imageryOnTiles`), or all of them. The
* default `all` drapes on whichever is drawn, so under photoreal it is
* on the tiles. Without `imageryOnTiles`, `objects` drapes on nothing and
* `all` on the surface. So nothing drapes while the globe is hidden and
* either photoreal's route is `none` or there is no `imageryOnTiles`:
* Core reads that from what it set, with no event for it.
*/
drapeOn: 'surface' | 'objects' | 'all';
/** Shaded by the sun like the surface (the default), or drawn unlit. */
lit: boolean;
/**
* `imageryFrames`: a timed source, prefetched hidden. `clock: 'scene'`
* (the default) shows the frame for the frame port's scene time;
* `'own'` shows the one `setFrameTime` names.
*/
setFrames?( setFrames?(
frames: TimedUrl[], source: TimedSource,
options?: { mode?: 'nearest' | 'blend'; prefetch?: number }, options?: {
mode?: 'nearest' | 'blend';
prefetch?: number;
clock?: 'scene' | 'own';
},
): void; ): void;
/** `imageryFrames`: which time to show. */ /** `imageryFrames`: which time to show, on the `own` clock. */
setFrameTime?(ms: number): void; setFrameTime?(ms: number): void;
/**
* `imageryFrames`: the frame the layer's clock now shows (by its time)
* has been drawn. Fires once each time the shown frame changes, never
* for frames only prefetched.
*/
on(event: 'frameReady', cb: (time: number) => void): Off; on(event: 'frameReady', cb: (time: number) => void): Off;
on( on(
event: 'tileError', event: 'tileError',
@@ -189,12 +260,18 @@ export interface GlobePort {
setGlobeVisible(visible: boolean): void; setGlobeVisible(visible: boolean): void;
/** `surfaceShading`. */ /** `surfaceShading`. */
setSurfaceShading?(shading: SurfaceShading | null): void; setSurfaceShading?(shading: SurfaceShading | null): void;
/** `clouds`: the renderer's volumetric clouds, or none. */
setClouds?(clouds: { coverage: number } | null): void;
/** `water`: the renderer's own water surface on the oceans, on or off. */
setWater?(enabled: boolean): void;
readonly imagery: { readonly imagery: {
/**
* Without `imageHooks`, a spec with `tileTransform`, `admit` or kind
* `canvas` throws, rather than drawing tiles the hooks were meant to
* change.
*/
add(spec: ImagerySpec, index?: number): ImageryLayer; add(spec: ImagerySpec, index?: number): ImageryLayer;
list(): ImageryLayer[]; list(): ImageryLayer[];
/** What imagery drapes onto now. */
host(): 'globe' | 'tiles' | 'both';
onHostChange(cb: (host: 'globe' | 'tiles' | 'both') => void): Off;
/** `splitSwipe`: split position, a fraction of the canvas's CSS width. */ /** `splitSwipe`: split position, a fraction of the canvas's CSS width. */
getSplit?(): number; getSplit?(): number;
/** `splitSwipe`: move the split to `x01` of the canvas's CSS width. */ /** `splitSwipe`: move the split to `x01` of the canvas's CSS width. */
+6 -2
View File
@@ -23,6 +23,9 @@ export const CAPABILITIES = Object.freeze([
'splitSwipe', 'splitSwipe',
'imageryFrames', 'imageryFrames',
'surfaceShading', 'surfaceShading',
'clouds',
'water',
'imageHooks',
'screenModulation', 'screenModulation',
'gpuTiming', 'gpuTiming',
'pickPosition', 'pickPosition',
@@ -138,8 +141,6 @@ export const PORTS = deepFreeze({
'imagery', 'imagery',
'imagery.add', 'imagery.add',
'imagery.list', 'imagery.list',
'imagery.host',
'imagery.onHostChange',
'sky', 'sky',
'sky.get', 'sky.get',
'sky.set', 'sky.set',
@@ -147,6 +148,8 @@ export const PORTS = deepFreeze({
'on', 'on',
), ),
['setSurfaceShading', 'surfaceShading'], ['setSurfaceShading', 'surfaceShading'],
['setClouds', 'clouds'],
['setWater', 'water'],
['imagery.getSplit', 'splitSwipe'], ['imagery.getSplit', 'splitSwipe'],
['imagery.setSplit', 'splitSwipe'], ['imagery.setSplit', 'splitSwipe'],
]), ]),
@@ -293,6 +296,7 @@ export const PORTS = deepFreeze({
'show', 'show',
'split', 'split',
'drapeOn', 'drapeOn',
'lit',
'on', 'on',
'raise', 'raise',
'lower', 'lower',
+6 -2
View File
@@ -34,8 +34,12 @@ export interface CreateOptions {
googleApiKey?: string; googleApiKey?: string;
/** Short-lived Google session tokens, when the key is held server-side. */ /** Short-lived Google session tokens, when the key is held server-side. */
googleTokens?: () => Promise<string>; googleTokens?: () => Promise<string>;
/** Cesium ion token, for ion-hosted terrain and fallback tiles. */ /**
ionToken?: string; * The token for the renderer's world data: `world` terrain and imagery
* and the photoreal `fallback`. For the default renderer, a Cesium ion
* token.
*/
worldDataToken?: string;
/** Initial multisampling; quality can change it later. */ /** Initial multisampling; quality can change it later. */
msaa?: 0 | 2 | 4 | 8; msaa?: 0 | 2 | 4 | 8;
/** Keep the drawing buffer after presenting, for capture. */ /** Keep the drawing buffer after presenting, for capture. */
+85 -28
View File
@@ -201,6 +201,9 @@ export function createStubRenderer(options = {}) {
runPhase('preRender', t); runPhase('preRender', t);
step('render'); step('render');
rendered += 1; rendered += 1;
// A frame each timed layer was asked to show is now drawn.
for (const [layer, flush] of [...frameFlushes])
if (frameFlushes.get(layer) === flush) flush();
step('postRender'); step('postRender');
runPhase('postRender', t); runPhase('postRender', t);
for (const waiter of onceWaiters.splice(0)) waiter(); for (const waiter of onceWaiters.splice(0)) waiter();
@@ -277,6 +280,7 @@ export function createStubRenderer(options = {}) {
}, },
setTime(ms) { setTime(ms) {
sceneTime = ms; sceneTime = ms;
for (const show of [...sceneFrameLayers.values()]) show(ms);
requestRender('time'); requestRender('time');
}, },
time: () => sceneTime, time: () => sceneTime,
@@ -916,8 +920,10 @@ export function createStubRenderer(options = {}) {
const imageryLayers = []; const imageryLayers = [];
let split = 0.5; let split = 0.5;
let imageryHost = 'globe'; /** Layers whose frames follow scene time, each with its show-a-time function. */
const hostListeners = []; const sceneFrameLayers = new Map();
/** Each timed layer's announcement of the frame it drew, run after a render. */
const frameFlushes = new Map();
const progressListeners = []; const progressListeners = [];
const settled = { base: true, tiles: true, terrain: true, imagery: true }; const settled = { base: true, tiles: true, terrain: true, imagery: true };
let sky = { let sky = {
@@ -933,13 +939,21 @@ export function createStubRenderer(options = {}) {
let photorealStatus = { route: 'none' }; let photorealStatus = { route: 'none' };
function addImagery(spec, index) { function addImagery(spec, index) {
if (
!capabilities.has('imageHooks') &&
(spec.tileTransform || spec.admit || spec.kind === 'canvas')
)
throw new TypeError(
'tileTransform, admit and canvas imagery need imageHooks',
);
const listeners = { frameReady: [], tileError: [] }; const listeners = { frameReady: [], tileError: [] };
const layer = { const layer = {
spec: { ...spec }, spec: { ...spec },
alpha: 1, alpha: 1,
show: true, show: true,
split: 'none', split: 'none',
drapeOn: 'active', drapeOn: 'all',
lit: true,
on(event, cb) { on(event, cb) {
listeners[event]?.push(cb); listeners[event]?.push(cb);
return () => { return () => {
@@ -967,23 +981,67 @@ export function createStubRenderer(options = {}) {
remove() { remove() {
const i = imageryLayers.indexOf(layer); const i = imageryLayers.indexOf(layer);
if (i >= 0) imageryLayers.splice(i, 1); if (i >= 0) imageryLayers.splice(i, 1);
sceneFrameLayers.delete(layer);
frameFlushes.delete(layer);
requestRender('imagery'); requestRender('imagery');
}, },
}; };
if (capabilities.has('imageryFrames')) { if (capabilities.has('imageryFrames')) {
let frames = []; let source = null;
layer.setFrames = (list) => { let clockKind = 'scene';
frames = list.slice(); /** The time the source shows for `ms`: the nearest frame, or within the videos. */
const shownTime = (ms) => {
if (source?.kind === 'video') {
let best = null;
let bestOff = Infinity;
for (const seg of source.segments) {
const t = Math.min(seg.end, Math.max(seg.start, ms));
if (Math.abs(t - ms) < bestOff) {
best = t;
bestOff = Math.abs(t - ms);
}
}
return best;
}
const frames = source?.frames ?? [];
let best = null;
for (const f of frames)
if (!best || Math.abs(f.time - ms) < Math.abs(best.time - ms))
best = f;
return best ? best.time : null;
};
// The stub has every frame at once, so the frame asked for is drawn
// by the next render; it is said once per change of the drawn frame.
let wanted = null;
let drawnTime = null;
const show = (ms) => {
wanted = shownTime(ms);
requestRender('imagery');
};
frameFlushes.set(layer, () => {
if (wanted === null || wanted === drawnTime) return;
drawnTime = wanted;
deliver(listeners.frameReady, drawnTime);
});
layer.setFrames = (next, opts = {}) => {
source =
next.kind === 'video'
? { kind: 'video', segments: next.segments.map((s) => ({ ...s })) }
: { kind: 'frames', frames: next.frames.slice() };
wanted = null;
drawnTime = null;
clockKind = opts.clock ?? 'scene';
if (clockKind === 'scene') {
sceneFrameLayers.set(layer, show);
show(sceneTime);
} else sceneFrameLayers.delete(layer);
requestRender('imagery');
}; };
layer.setFrameTime = (ms) => { layer.setFrameTime = (ms) => {
const nearest = frames.reduce( // On the scene clock the frame port's time decides.
(best, f) => if (clockKind !== 'own') return;
!best || Math.abs(f.time - ms) < Math.abs(best.time - ms) show(ms);
? f requestRender('imagery');
: best,
null,
);
if (nearest) deliver(listeners.frameReady, nearest.time);
}; };
} }
if (index === undefined) imageryLayers.push(layer); if (index === undefined) imageryLayers.push(layer);
@@ -996,7 +1054,7 @@ export function createStubRenderer(options = {}) {
const globe = { const globe = {
async setBase(base) { async setBase(base) {
log('globe', 'setBase', [base]); log('globe', 'setBase', [base]);
for (const layer of imageryLayers.splice(0)) void layer; for (const layer of imageryLayers.slice()) layer.remove();
if (base.photoreal !== undefined) if (base.photoreal !== undefined)
await globe.setPhotoreal(base.photoreal); await globe.setPhotoreal(base.photoreal);
const layers = (base.imagery ?? []).map((spec) => addImagery(spec)); const layers = (base.imagery ?? []).map((spec) => addImagery(spec));
@@ -1008,11 +1066,6 @@ export function createStubRenderer(options = {}) {
spec && capabilities.has('photoreal') spec && capabilities.has('photoreal')
? { route: 'google' } ? { route: 'google' }
: { route: 'none' }; : { route: 'none' };
const next = photorealStatus.route === 'none' ? 'globe' : 'tiles';
if (next !== imageryHost) {
imageryHost = next;
deliver(hostListeners, next);
}
requestRender('base'); requestRender('base');
return photorealStatus; return photorealStatus;
}, },
@@ -1027,14 +1080,6 @@ export function createStubRenderer(options = {}) {
imagery: { imagery: {
add: addImagery, add: addImagery,
list: () => imageryLayers.slice(), list: () => imageryLayers.slice(),
host: () => imageryHost,
onHostChange(cb) {
hostListeners.push(cb);
return () => {
const i = hostListeners.indexOf(cb);
if (i >= 0) hostListeners.splice(i, 1);
};
},
}, },
sky: { sky: {
get: () => ({ ...sky }), get: () => ({ ...sky }),
@@ -1058,6 +1103,16 @@ export function createStubRenderer(options = {}) {
log('globe', 'setSurfaceShading', [shading]); log('globe', 'setSurfaceShading', [shading]);
requestRender('base'); requestRender('base');
}; };
if (capabilities.has('clouds'))
globe.setClouds = (clouds) => {
log('globe', 'setClouds', [clouds]);
requestRender('base');
};
if (capabilities.has('water'))
globe.setWater = (water) => {
log('globe', 'setWater', [water]);
requestRender('base');
};
if (capabilities.has('splitSwipe')) { if (capabilities.has('splitSwipe')) {
globe.imagery.getSplit = () => split; globe.imagery.getSplit = () => split;
globe.imagery.setSplit = (x01) => { globe.imagery.setSplit = (x01) => {
@@ -1328,6 +1383,8 @@ export function createStubRenderer(options = {}) {
onceTimers.clear(); onceTimers.clear();
sets.clear(); sets.clear();
satrecs.clear(); satrecs.clear();
sceneFrameLayers.clear();
frameFlushes.clear();
}, },
testing: { testing: {
/** Run one frame of the loop; true when it drew. */ /** Run one frame of the loop; true when it drew. */