Create the application scene through a renderer plugin

createApplicationScene takes a renderer plugin, defaulting to Cesium,
which builds today's viewer and credits exactly as before. Its renderer
is transitional: it has only the members it really has, with read-only
capabilities, and a test pins the contract members still to come. Code
not yet ported reaches the viewer through cesiumViewerOf, never through
a renderer member. A plugin whose renderer has no viewer is refused and
destroyed, since the application still runs on the viewer. The result
now includes the renderer.

Not yet checked in a browser.
This commit is contained in:
Sameh Khamis committed 2026-11-05 14:51:42 -08:00
1 parent bcd24c458b
commit 8d7f82f110
5 files changed
+383 -16

No files matched your search

+1
View File
@@ -671,6 +671,7 @@
"src/services/application.js",
"src/services/requests.js",
"src/app/scene.js",
"src/app/renderer.test.mjs",
"src/geo/geo.test.mjs",
"src/geo/celestial.test.mjs",
"src/geo/quaternion.test.mjs",
+2
View File
@@ -2421,6 +2421,8 @@
"src/app/layers/traffic.js",
"src/app/operations.js",
"src/app/scene.js",
"src/app/renderer.js",
"src/render/contract/index.js",
"src/sources/sourceSlot.js",
"src/app/sources.js",
"src/app/startupChrome.js",
+107
View File
@@ -0,0 +1,107 @@
/**
* @module app/renderer
* @description The application's default renderer plugin: today's Cesium
* viewer, set up exactly as before, behind the contract's `RendererPlugin`
* (`src/render/contract/renderer.d.ts`).
*
* The renderer it makes is transitional: it has only the members it really
* has, and the contract's ports arrive one layer family at a time (phase 2
* of the renderer plan); `checkRendererShape` lists what is still missing.
* Until a family is ported, its code reaches the Cesium viewer through
* `cesiumViewerOf`, never through a renderer member, so no engine type
* crosses the contract and each call marks code still to port.
*/
import { CONTRACT_VERSION } from '../render/contract/index.js';
import { createApplicationViewer } from './viewer.js';
/**
* The members a transitional renderer has today.
* @typedef {Pick<import('../render/contract/renderer').Renderer,
* 'id' | 'contractVersion' | 'capabilities' | 'canvas' | 'resize' | 'destroy'>}
* TransitionalRenderer
*/
const viewers = new WeakMap();
/** A Set no one can change once built, so `capabilities` stays true. */
export class ReadonlySet extends Set {
#sealed;
constructor(values = []) {
// Filled here, not by Set's constructor: it would call the guarded add
// before the private field exists.
super();
for (const value of values) super.add(value);
this.#sealed = true;
}
add(value) {
if (this.#sealed) throw new TypeError('capabilities are read-only');
return super.add(value);
}
delete() {
throw new TypeError('capabilities are read-only');
}
clear() {
throw new TypeError('capabilities are read-only');
}
}
/**
* The Cesium viewer behind a renderer this plugin made, or null for any
* other renderer.
* @param {object} renderer
* @returns {object|null}
*/
export const cesiumViewerOf = (renderer) => viewers.get(renderer) ?? null;
/**
* The Cesium plugin over a viewer factory; tests pass a fake one.
* @param {{createViewer?: typeof createApplicationViewer}} [options]
*/
export function createCesiumRendererPlugin({
createViewer = createApplicationViewer,
} = {}) {
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
* @returns {Promise<TransitionalRenderer>}
*/
async create({ container, signal }) {
signal?.throwIfAborted();
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer);
let viewer;
try {
viewer = createViewer({ container, creditContainer });
} catch (error) {
creditContainer.remove();
throw error;
}
const renderer = Object.freeze({
id: 'cesium',
contractVersion: CONTRACT_VERSION,
capabilities: new ReadonlySet(),
canvas: viewer.canvas,
resize() {
viewer.resize();
},
destroy() {
try {
if (!viewer.isDestroyed()) viewer.destroy();
} finally {
creditContainer.remove();
}
},
});
viewers.set(renderer, viewer);
return renderer;
},
});
}
/** The default plugin. */
export const cesiumRenderer = createCesiumRendererPlugin();
+235
View File
@@ -0,0 +1,235 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
CONTRACT_VERSION,
checkRendererShape,
} from '../render/contract/index.js';
import {
ReadonlySet,
cesiumRenderer,
cesiumViewerOf,
createCesiumRendererPlugin,
} from './renderer.js';
/** A document with just a body, recording what is attached. */
function fakeDocument() {
const children = [];
const make = () => {
const el = {
id: '',
remove() {
const i = children.indexOf(el);
if (i >= 0) children.splice(i, 1);
},
};
return el;
};
return {
children,
createElement: make,
body: { appendChild: (el) => children.push(el) },
getElementById: () => null,
};
}
function fakeViewer(log, { failDestroy = false } = {}) {
let destroyed = false;
return {
canvas: { tag: 'canvas' },
resize: () => log.push('resize'),
isDestroyed: () => destroyed,
destroy() {
log.push('destroy');
if (failDestroy) throw new Error('destroy failed');
destroyed = true;
},
};
}
function withDocument(t, doc) {
const saved = globalThis.document;
globalThis.document = doc;
t.after(() => {
globalThis.document = saved;
});
}
test('the default plugin is the Cesium renderer', () => {
assert.equal(cesiumRenderer.id, 'cesium');
assert.ok(Object.isFrozen(cesiumRenderer));
});
test('the plugin wraps the viewer it is given, behind the accessor only', async (t) => {
const doc = fakeDocument();
withDocument(t, doc);
const log = [];
let given;
const plugin = createCesiumRendererPlugin({
createViewer(options) {
given = options;
return fakeViewer(log);
},
});
const container = { tag: 'container' };
const renderer = await plugin.create({
container,
signal: new AbortController().signal,
});
assert.equal(given.container, container);
assert.equal(given.creditContainer.id, 'cesium-credits');
assert.deepEqual(
doc.children,
[given.creditContainer],
'credits on the page',
);
assert.equal(renderer.id, 'cesium');
assert.equal(renderer.contractVersion, CONTRACT_VERSION);
assert.equal(renderer.canvas.tag, 'canvas');
assert.ok(Object.isFrozen(renderer));
assert.ok(renderer.capabilities instanceof Set);
assert.throws(() => renderer.capabilities.add('models'), TypeError);
assert.throws(() => renderer.capabilities.delete('models'), TypeError);
assert.throws(() => renderer.capabilities.clear(), TypeError);
assert.ok(!('viewer' in renderer), 'no engine object is a member');
assert.equal(cesiumViewerOf(renderer).canvas, renderer.canvas);
renderer.resize();
renderer.destroy();
assert.deepEqual(log, ['resize', 'destroy']);
assert.deepEqual(doc.children, [], 'credits leave with the renderer');
renderer.destroy();
assert.deepEqual(log, ['resize', 'destroy'], 'destroying twice is safe');
});
test('the credits leave even when the viewer fails to destroy', async (t) => {
const doc = fakeDocument();
withDocument(t, doc);
const plugin = createCesiumRendererPlugin({
createViewer: () => fakeViewer([], { failDestroy: true }),
});
const renderer = await plugin.create({
container: {},
signal: new AbortController().signal,
});
assert.throws(() => renderer.destroy(), /destroy failed/);
assert.deepEqual(doc.children, []);
});
test('a viewer that fails to build leaves no credits behind', async (t) => {
const doc = fakeDocument();
withDocument(t, doc);
const plugin = createCesiumRendererPlugin({
createViewer() {
throw new Error('no WebGL');
},
});
await assert.rejects(
plugin.create({ container: {}, signal: new AbortController().signal }),
/no WebGL/,
);
assert.deepEqual(doc.children, []);
});
test('an aborted creation builds nothing', async (t) => {
const doc = fakeDocument();
withDocument(t, doc);
let built = false;
const plugin = createCesiumRendererPlugin({
createViewer: () => {
built = true;
return fakeViewer([]);
},
});
const controller = new AbortController();
controller.abort();
await assert.rejects(
plugin.create({ container: {}, signal: controller.signal }),
{ name: 'AbortError' },
);
assert.equal(built, false);
assert.deepEqual(doc.children, []);
});
test('the transitional renderer lacks exactly the ports still to come', async (t) => {
withDocument(t, fakeDocument());
const renderer = await createCesiumRendererPlugin({
createViewer: () => fakeViewer([]),
}).create({ container: {}, signal: new AbortController().signal });
// Each phase 2 port shrinks this list; nothing may be faked to shorten it.
assert.deepEqual(checkRendererShape(renderer).sort(), EXPECTED_MISSING);
});
test('a read-only set keeps the values it was built with', () => {
const set = new ReadonlySet(['models', 'terrain']);
assert.deepEqual([...set], ['models', 'terrain']);
assert.ok(set.has('terrain'));
assert.throws(() => set.add('x'), TypeError);
assert.equal(set.size, 2);
});
test('only renderers this plugin made reach a Cesium viewer', () => {
assert.equal(cesiumViewerOf({ id: 'cesium' }), null);
assert.equal(cesiumViewerOf({}), null);
});
test('the application refuses a renderer it cannot run on, and destroys it', async (t) => {
const { createApplicationScene } = await import('./scene.js');
const { createApplicationRequestServices } =
await import('../services/requests.js');
const destroyed = [];
const deferred = [];
const plugin = {
id: 'other',
async create(options) {
assert.ok(options.signal, 'the scene passes its signal');
return {
id: 'other',
destroy: () => {
destroyed.push('other');
throw new Error('and it fails to go');
},
};
},
};
const saved = { document: globalThis.document, window: globalThis.window };
globalThis.document = fakeDocument();
globalThis.window = new EventTarget();
const warn = console.warn;
console.warn = () => {};
t.after(() => {
for (const fn of deferred.reverse()) fn();
console.warn = warn;
globalThis.document = saved.document;
globalThis.window = saved.window;
});
await assert.rejects(
createApplicationScene({
renderer: plugin,
requestServices: createApplicationRequestServices(),
loaderStatus: { textContent: '' },
signal: new AbortController().signal,
defer: (fn) => deferred.push(fn),
}),
/other renderer cannot run the application yet/,
'the refusal is not hidden by the failed destroy',
);
assert.deepEqual(destroyed, ['other']);
});
const EXPECTED_MISSING = [
'camera',
'capture',
'credits',
'draw',
'effects',
'frame',
'globe',
'input',
'on',
'quality',
'query',
'readPixels',
'releaseUnused',
'whenSettled',
].map((member) => `renderer.${member} is missing`);
+38 -16
View File
@@ -1,9 +1,7 @@
import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import {
createApplicationViewer,
installTrackpadPinchZoom,
} from '../app/viewer.js';
import { installTrackpadPinchZoom } from '../app/viewer.js';
import { cesiumRenderer, cesiumViewerOf } from './renderer.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
@@ -17,8 +15,14 @@ import {
import { describeError } from './errors.js';
import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
/**
* Construct the application globe using the caller's local configuration.
* `renderer` is the `RendererPlugin` that draws it (default: Cesium). The
* application still runs on the Cesium viewer until its layers are ported,
* so a plugin whose renderer has none is refused.
*/
export async function createApplicationScene({
renderer: rendererPlugin = cesiumRenderer,
requestServices,
googleApiKey,
googleTokens = null,
@@ -45,18 +49,29 @@ export async function createApplicationScene({
});
}
loaderStatus.textContent = t('Configuring viewer...');
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer);
defer(() => creditContainer.remove());
const viewer = createApplicationViewer({
container: 'cesiumContainer',
creditContainer,
// The scene still loads the tiles, so their credentials stay here.
const renderer = await rendererPlugin.create({
container: document.getElementById('cesiumContainer'),
signal,
});
const viewer = cesiumViewerOf(renderer);
if (!viewer) {
const refusal = new Error(
`The ${rendererPlugin.id} renderer cannot run the application yet`,
);
try {
renderer.destroy();
} catch (error) {
console.warn('[Init] Refused renderer failed to destroy:', error);
}
throw refusal;
}
defer(() => {
uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy();
try {
uninstallRenderGovernor(viewer);
} finally {
renderer.destroy();
}
});
// Before any layer binds a click: every layer and tool shares this one.
// Deferred after the viewer, so it is released before the viewer goes.
@@ -133,5 +148,12 @@ export async function createApplicationScene({
});
signal.throwIfAborted();
return { viewer, tileset, mapStackController, operations, input };
return {
renderer,
viewer,
tileset,
mapStackController,
operations,
input,
};
}