From 8d7f82f110b19b25c98750dd119b0701a3529413 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 14:51:42 -0800 Subject: [PATCH] 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. --- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 2 + src/app/renderer.js | 107 +++++++++++++++ src/app/renderer.test.mjs | 235 ++++++++++++++++++++++++++++++++ src/app/scene.js | 54 +++++--- 5 files changed, 383 insertions(+), 16 deletions(-) create mode 100644 src/app/renderer.js create mode 100644 src/app/renderer.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9bb755f08..2a60061a6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 1a7242601..cc651edd9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/app/renderer.js b/src/app/renderer.js new file mode 100644 index 000000000..44f43f321 --- /dev/null +++ b/src/app/renderer.js @@ -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} + * 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} + */ + 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(); diff --git a/src/app/renderer.test.mjs b/src/app/renderer.test.mjs new file mode 100644 index 000000000..8fe1b1c16 --- /dev/null +++ b/src/app/renderer.test.mjs @@ -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`); diff --git a/src/app/scene.js b/src/app/scene.js index ab70da51a..951eac947 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -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, + }; }