Share the Cesium ports between the app and conformance renderers

The frame, draw, camera, query and capture ports move out of the
conformance scene renderer into src/render/cesium/ports.js, unchanged.
The app's transitional renderer, now src/render/cesium/appRenderer.js,
builds the same ports over the full viewer; it still leaves out globe,
whenSettled and setTime, which would misreport photoreal tiles and stop
the app clock.

Disposing the ports now settles a pending renderOnce and detaches the
draw sets, so a model that loads after destroy is destroyed rather than
added to a destroyed collection. A viewer whose ports fail to build is
destroyed with its credits.

Checked in a browser: the app boots with one credits element, and all
eleven conformance scenes match their references exactly.
This commit is contained in:
Sameh Khamis committed 2026-11-05 15:50:48 -08:00
1 parent 03f2e2a9b9
commit a5143f4467
7 files changed
+770 -561

No files matched your search

+1 -1
View File
@@ -671,7 +671,7 @@
"src/services/application.js",
"src/services/requests.js",
"src/app/scene.js",
"src/app/renderer.test.mjs",
"src/render/cesium/appRenderer.test.mjs",
"src/render/noCesiumOutsideRender.test.mjs",
"src/render/cesiumAllowList.json",
"src/geo/geo.test.mjs",
+2 -1
View File
@@ -2421,7 +2421,8 @@
"src/app/layers/traffic.js",
"src/app/operations.js",
"src/app/scene.js",
"src/app/renderer.js",
"src/render/cesium/appRenderer.js",
"src/render/cesium/ports.js",
"src/render/contract/index.js",
"src/sources/sourceSlot.js",
"src/app/sources.js",
+4 -1
View File
@@ -1,7 +1,10 @@
import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import { installTrackpadPinchZoom } from '../app/viewer.js';
import { cesiumRenderer, cesiumViewerOf } from './renderer.js';
import {
cesiumRenderer,
cesiumViewerOf,
} from '../render/cesium/appRenderer.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
@@ -1,23 +1,30 @@
/**
* @module app/renderer
* @module render/cesium/appRenderer
* @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.
* 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), `whenSettled`
* (it would not wait for them) and `frame.setTime` (it would stop the
* application's clock). Those arrive with the layer families that need
* them (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';
import { createApplicationViewer } from '../../app/viewer.js';
import { CONTRACT_VERSION } from '../contract/index.js';
import { createCesiumPorts } from './ports.js';
/**
* The members a transitional renderer has today.
* @typedef {Pick<import('../render/contract/renderer').Renderer,
* 'id' | 'contractVersion' | 'capabilities' | 'canvas' | 'resize' | 'destroy'>}
* @typedef {Pick<import('../contract/renderer').Renderer,
* 'id' | 'contractVersion' | 'capabilities' | 'canvas' | 'frame' | 'draw'
* | 'camera' | 'query' | 'capture' | 'resize' | 'destroy'>}
* TransitionalRenderer
*/
@@ -75,22 +82,31 @@ export function createCesiumRendererPlugin({
creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer);
let viewer;
let ports;
try {
viewer = createViewer({ container, creditContainer });
ports = createCesiumPorts(viewer);
} catch (error) {
if (viewer && !viewer.isDestroyed()) viewer.destroy();
creditContainer.remove();
throw error;
}
const renderer = Object.freeze({
id: 'cesium',
contractVersion: CONTRACT_VERSION,
capabilities: new ReadonlySet(),
capabilities: new ReadonlySet(['models']),
canvas: viewer.canvas,
frame: ports.frame,
draw: ports.draw,
camera: ports.camera,
query: ports.query,
capture: ports.capture,
resize() {
viewer.resize();
},
destroy() {
try {
ports.dispose();
if (!viewer.isDestroyed()) viewer.destroy();
} finally {
creditContainer.remove();
@@ -1,15 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
CONTRACT_VERSION,
checkRendererShape,
} from '../render/contract/index.js';
import * as Cesium from 'cesium';
import { CONTRACT_VERSION, checkRendererShape } from '../contract/index.js';
import {
ReadonlySet,
cesiumRenderer,
cesiumViewerOf,
createCesiumRendererPlugin,
} from './renderer.js';
} from './appRenderer.js';
/** A document with just a body, recording what is attached. */
function fakeDocument() {
@@ -32,9 +30,29 @@ function fakeDocument() {
};
}
/** A Cesium event: listeners added, and removed by the returned function. */
function fakeEvent(listeners) {
return {
addEventListener(cb) {
listeners.add(cb);
return () => listeners.delete(cb);
},
};
}
function fakeViewer(log, { failDestroy = false } = {}) {
let destroyed = false;
const listeners = new Set();
return {
listeners,
scene: {
camera: {},
primitives: { add() {}, remove() {} },
requestRender() {},
preUpdate: fakeEvent(listeners),
preRender: fakeEvent(listeners),
postRender: fakeEvent(listeners),
},
canvas: { tag: 'canvas' },
resize: () => log.push('resize'),
isDestroyed: () => destroyed,
@@ -64,10 +82,12 @@ test('the plugin wraps the viewer it is given, behind the accessor only', async
withDocument(t, doc);
const log = [];
let given;
let viewer;
const plugin = createCesiumRendererPlugin({
createViewer(options) {
given = options;
return fakeViewer(log);
viewer = fakeViewer(log);
return viewer;
},
});
const container = { tag: 'container' };
@@ -94,14 +114,65 @@ test('the plugin wraps the viewer it is given, behind the accessor only', async
assert.ok(!('viewer' in renderer), 'no engine object is a member');
assert.equal(cesiumViewerOf(renderer).canvas, renderer.canvas);
assert.ok(viewer.listeners.size > 0, 'the ports follow the frame phases');
assert.equal(typeof renderer.draw.icons, 'function');
assert.ok(renderer.capabilities.has('models'), 'draw.models is declared');
renderer.resize();
renderer.destroy();
assert.equal(viewer.listeners.size, 0, 'and stop on 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('work pending at destroy settles and lets go of the scene', async (t) => {
withDocument(t, fakeDocument());
const viewers = [];
const plugin = createCesiumRendererPlugin({
createViewer: () => {
const viewer = fakeViewer([]);
viewers.push(viewer);
return viewer;
},
});
const renderer = await plugin.create({ container: {} });
const rendered = renderer.frame.renderOnce();
renderer.destroy();
await rendered;
assert.equal(viewers[0].listeners.size, 0, 'no one-shot render stays');
await renderer.frame.renderOnce();
// A model that finishes loading after destroy is destroyed, not added.
const load = Promise.withResolvers();
const saved = Cesium.Model.fromGltfAsync;
Cesium.Model.fromGltfAsync = () => load.promise;
t.after(() => {
Cesium.Model.fromGltfAsync = saved;
});
const again = await plugin.create({ container: {} });
again.draw.models({ id: 'm' }).set([
{
id: 'a',
url: 'a.glb',
pos: { lon: 0, lat: 0, height: 0 },
orientation: { heading: 0, pitch: 0, roll: 0 },
},
]);
again.destroy();
let destroyed = false;
load.resolve({
destroy: () => (destroyed = true),
readyEvent: fakeEvent(new Set()),
errorEvent: fakeEvent(new Set()),
});
await load.promise;
await new Promise((resolve) => setImmediate(resolve));
assert.ok(destroyed, 'the late model is destroyed');
});
test('the credits leave even when the viewer fails to destroy', async (t) => {
const doc = fakeDocument();
withDocument(t, doc);
@@ -151,7 +222,7 @@ test('an aborted creation builds nothing', async (t) => {
assert.deepEqual(doc.children, []);
});
test('the transitional renderer lacks exactly the ports still to come', async (t) => {
test('the transitional renderer lacks exactly the members still to come', async (t) => {
withDocument(t, fakeDocument());
const renderer = await createCesiumRendererPlugin({
createViewer: () => fakeViewer([]),
@@ -174,9 +245,9 @@ test('only renderers this plugin made reach a Cesium viewer', () => {
});
test('the application refuses a renderer it cannot run on, and destroys it', async (t) => {
const { createApplicationScene } = await import('./scene.js');
const { createApplicationScene } = await import('../../app/scene.js');
const { createApplicationRequestServices } =
await import('../services/requests.js');
await import('../../services/requests.js');
const destroyed = [];
const deferred = [];
const plugin = {
@@ -218,18 +289,69 @@ test('the application refuses a renderer it cannot run on, and destroys it', asy
});
const EXPECTED_MISSING = [
'camera',
'capture',
'credits',
'draw',
'effects',
'frame',
'globe',
'input',
'on',
'quality',
'query',
'readPixels',
'releaseUnused',
'whenSettled',
].map((member) => `renderer.${member} is missing`);
'camera.cancelFlight',
'camera.controls',
'camera.controls.coast',
'camera.controls.coast.drag',
'camera.controls.coast.zoom',
'camera.controls.collision',
'camera.controls.enableRotate',
'camera.controls.enableTilt',
'camera.controls.enableZoom',
'camera.controls.enabled',
'camera.controls.maxDistance',
'camera.controls.minDistance',
'camera.controls.pitchRange',
'camera.controls.suspend',
'camera.controls.zoomFactor',
'camera.controls.zoomInputs',
'camera.flyTo',
'camera.flyToBounds',
'camera.flying',
'camera.follow',
'camera.followFramed',
'camera.followOffset',
'camera.followOffset.get',
'camera.followOffset.set',
'camera.followed',
'camera.look',
'camera.lookAt',
'camera.matrices',
'camera.move',
'camera.on',
'camera.onCommand',
'camera.release',
'camera.rotateAround',
'camera.setFov',
'camera.viewRectangle',
'camera.zoom',
'draw.atlas',
'draw.clampState',
'draw.on',
'frame.diagnostics',
'frame.holdContinuous',
'frame.mode',
'frame.setTargetFps',
'frame.setTime',
'frame.suspend',
'query.clampToSurface',
'query.drillPick',
'query.isOccluded',
'query.pickGlobe',
'query.pickPosition',
'query.pickRay',
'query.projectMany',
'query.rayCast',
'query.sampleHeight',
'query.sampleHeightNow',
'query.sampleHeightNowMany',
'renderer.credits',
'renderer.effects',
'renderer.globe',
'renderer.input',
'renderer.on',
'renderer.quality',
'renderer.readPixels',
'renderer.releaseUnused',
'renderer.whenSettled',
].map((member) => `${member} is missing`);
+575
View File
@@ -0,0 +1,575 @@
/**
* @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, and capture.
* Building them changes nothing in the scene; only calls do.
*
* The conformance renderer (`sceneRenderer.js`) and the application's
* renderer (`appRenderer.js`) both use them, so what conformance
* checks is what the application runs. Members a port does not have are
* absent, never stubbed.
*/
import * as Cesium from 'cesium';
/** The contract's colour as Cesium's. */
export function toColor(color, alpha = 1) {
if (color === undefined || color === null)
return Cesium.Color.WHITE.withAlpha(alpha);
const c =
typeof color === 'string'
? Cesium.Color.fromCssColorString(color)
: new Cesium.Color(color[0], color[1], color[2], color[3] ?? 1);
return c.withAlpha(c.alpha * alpha);
}
/** An image reference as something a billboard takes. */
export function toImage(image) {
if (typeof image === 'string') return image;
if (image?.svg)
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(image.svg)}`;
if (image?.canvas) return image.canvas;
throw new Error('the Cesium renderer takes URL, SVG and canvas images only');
}
/** A contract position as ECEF. */
export function toCartesian(pos) {
if ('x' in pos) return new Cesium.Cartesian3(pos.x, pos.y, pos.z);
return Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.height ?? 0);
}
const ANCHORS = {
center: Cesium.VerticalOrigin.CENTER,
bottom: Cesium.VerticalOrigin.BOTTOM,
top: Cesium.VerticalOrigin.TOP,
};
/**
* @param {object} widget A Cesium `CesiumWidget` or `Viewer`.
*/
export function createCesiumPorts(widget) {
const scene = widget.scene;
const camera = scene.camera;
const sets = new Map();
const frameListeners = { preUpdate: [], preRender: [], postRender: [] };
let frames = 0;
const onPhase = (phase) => (_scene, time) => {
if (frameListeners[phase].length === 0) return;
const t = {
frame: frames,
time: Cesium.JulianDate.toDate(
time ?? widget.clock.currentTime,
).getTime(),
now: performance.now(),
dt: 1 / 60,
rendering: phase !== 'preUpdate',
};
for (const cb of frameListeners[phase].slice()) cb(t);
};
let disposed = false;
const removers = [
scene.preUpdate.addEventListener(() => (frames += 1)),
scene.preUpdate.addEventListener(onPhase('preUpdate')),
scene.preRender.addEventListener(onPhase('preRender')),
scene.postRender.addEventListener(onPhase('postRender')),
];
// Pending one-shot renders; disposal settles them rather than leave them
// attached to a scene that will not render again.
const pendingRenders = new Set();
function nextPostRender() {
if (disposed) return Promise.resolve();
return new Promise((resolve) => {
const done = () => {
off();
pendingRenders.delete(done);
resolve();
};
const off = scene.postRender.addEventListener(done);
pendingRenders.add(done);
scene.requestRender();
});
}
const frame = {
on(phase, cb) {
frameListeners[phase].push(cb);
return () => {
const i = frameListeners[phase].indexOf(cb);
if (i >= 0) frameListeners[phase].splice(i, 1);
};
},
requestRender: () => scene.requestRender(),
renderNow: () => scene.render(widget.clock.currentTime),
renderOnce: () => nextPostRender(),
time: () => Cesium.JulianDate.toDate(widget.clock.currentTime).getTime(),
};
// --- Draw ------------------------------------------------------------------
function makeSet(kind, spec, build) {
if (sets.has(spec.id)) throw new Error(`a set with id ${spec.id} is live`);
const items = new Map();
const listeners = { ready: [] };
const record = { kind, spec, items, listeners, host: null, rebuild: null };
const handle = {
id: spec.id,
set(list) {
items.clear();
for (const item of list) items.set(item.id, item);
build(record);
scene.requestRender();
},
upsert(list) {
for (const item of list) items.set(item.id, item);
build(record);
scene.requestRender();
},
remove(ids) {
for (const id of ids) items.delete(id);
build(record);
scene.requestRender();
},
clear() {
items.clear();
build(record);
scene.requestRender();
},
setOptions(options) {
Object.assign(spec, options);
if (record.host) record.host.show = spec.show !== false;
scene.requestRender();
},
size: () => items.size,
get show() {
return spec.show !== false;
},
set show(value) {
spec.show = value;
if (record.host) record.host.show = value;
scene.requestRender();
},
on(event, cb) {
listeners[event]?.push(cb);
return () => {
const list = listeners[event];
const i = list?.indexOf(cb) ?? -1;
if (i >= 0) list.splice(i, 1);
};
},
dispose() {
if (record.host && !record.host.isDestroyed?.())
scene.primitives.remove(record.host);
record.host = null;
// A newer set under the same id is not this handle's to remove.
if (sets.get(spec.id) === record) sets.delete(spec.id);
scene.requestRender();
},
};
sets.set(spec.id, record);
return handle;
}
const pickId = (record, item) => ({
set: record.spec.id,
id: item.id,
meta: item.meta,
});
function replaceHost(record, host) {
if (record.host) scene.primitives.remove(record.host);
record.host = host;
host.show = record.spec.show !== false;
scene.primitives.add(host);
}
function buildIcons(record) {
const host = new Cesium.BillboardCollection({ scene });
for (const item of record.items.values()) {
const position = toCartesian(item.pos);
const options = {
position,
image: toImage(item.image),
width: item.size[0] * (item.scale ?? 1),
height: item.size[1] * (item.scale ?? 1),
color: toColor(item.color, item.alpha ?? 1),
verticalOrigin: ANCHORS[item.anchor ?? 'center'],
disableDepthTestDistance: item.disableDepthTest ?? 0,
show: item.show !== false,
id: pickId(record, item),
};
if (item.rotationMode === 'screen') options.rotation = item.rotation ?? 0;
if (item.rotationMode === 'course') {
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
options.alignedAxis = Cesium.Matrix4.multiplyByPointAsVector(
enu,
Cesium.Cartesian3.UNIT_Y,
new Cesium.Cartesian3(),
);
options.rotation = -Cesium.Math.toRadians(item.course ?? 0);
}
host.add(options);
}
replaceHost(record, host);
}
function buildPoints(record) {
const host = new Cesium.PointPrimitiveCollection();
for (const item of record.items.values())
host.add({
position: toCartesian(item.pos),
pixelSize: item.pixelSize,
color: toColor(item.color),
outlineColor: toColor(item.outlineColor ?? [0, 0, 0, 0]),
outlineWidth: item.outlineWidth ?? 0,
disableDepthTestDistance: item.disableDepthTest ?? 0,
show: item.show !== false,
id: pickId(record, item),
});
replaceHost(record, host);
}
function lineMaterial(style) {
switch (style.kind) {
case 'solid':
return Cesium.Material.fromType('Color', {
color: toColor(style.color),
});
case 'glow':
return Cesium.Material.fromType('PolylineGlow', {
color: toColor(style.color),
glowPower: style.power,
taperPower: style.taper ?? 1,
});
case 'dash':
return Cesium.Material.fromType('PolylineDash', {
color: toColor(style.color),
gapColor: toColor(style.gapColor ?? [0, 0, 0, 0]),
dashLength: style.length,
...(style.pattern16 !== undefined
? { dashPattern: style.pattern16 }
: {}),
});
case 'arrow':
return Cesium.Material.fromType('PolylineArrow', {
color: toColor(style.color),
});
default:
throw new Error(`the Cesium renderer has no ${style.kind} lines`);
}
}
function linePositions(item) {
const flat = item.positions instanceof Float64Array;
const points = [];
const n = flat ? item.positions.length / 3 : item.positions.length;
for (let i = 0; i < n; i++)
points.push(
flat
? new Cesium.Cartesian3(
item.positions[i * 3],
item.positions[i * 3 + 1],
item.positions[i * 3 + 2],
)
: toCartesian(item.positions[i]),
);
if (item.mode !== 'geodesic') return points;
const dense = [];
for (let i = 0; i + 1 < points.length; i++) {
const a = Cesium.Cartographic.fromCartesian(points[i]);
const b = Cesium.Cartographic.fromCartesian(points[i + 1]);
const geodesic = new Cesium.EllipsoidGeodesic(a, b);
for (let k = 0; k < 64; k++) {
const s = k / 64;
const at = geodesic.interpolateUsingFraction(s);
dense.push(
Cesium.Cartesian3.fromRadians(
at.longitude,
at.latitude,
a.height + (b.height - a.height) * s,
),
);
}
}
dense.push(points.at(-1));
return dense;
}
function buildLines(record) {
const host = new Cesium.PolylineCollection();
for (const item of record.items.values()) {
if (item.mode === 'draped')
throw new Error('the Cesium renderer has no draped lines');
host.add({
positions: linePositions(item),
width: item.widthPx,
material: lineMaterial(item.style),
show: item.show !== false,
id: pickId(record, item),
});
}
replaceHost(record, host);
}
const heightOf = (pos) =>
'x' in pos
? Cesium.Cartographic.fromCartesian(toCartesian(pos)).height
: (pos.height ?? 0);
function shapeGeometry(item) {
const g = item.geometry;
const extrudedHeight = item.extrudeHeight;
switch (g.kind) {
case 'polygon': {
const [outer, ...holes] = g.rings.map((ring) => ring.map(toCartesian));
const hierarchy = new Cesium.PolygonHierarchy(
outer,
holes.map((h) => new Cesium.PolygonHierarchy(h)),
);
return {
fill: new Cesium.PolygonGeometry({
polygonHierarchy: hierarchy,
height: g.perPositionHeight ? undefined : heightOf(g.rings[0][0]),
extrudedHeight,
perPositionHeight: g.perPositionHeight,
}),
ring: outer,
};
}
case 'ellipse':
return {
fill: new Cesium.EllipseGeometry({
center: toCartesian(g.center),
semiMajorAxis: g.semiMajor,
semiMinorAxis: g.semiMinor,
rotation: g.rotation ?? 0,
height: heightOf(g.center),
extrudedHeight,
}),
};
case 'rectangle':
return {
fill: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(
g.rect.west,
g.rect.south,
g.rect.east,
g.rect.north,
),
height: g.height ?? 0,
extrudedHeight,
}),
};
case 'box': {
const d = g.dimensions;
return {
fill: Cesium.BoxGeometry.fromDimensions({
dimensions: new Cesium.Cartesian3(d.x, d.y, d.z),
}),
modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(
toCartesian(g.center),
),
};
}
default:
throw new Error(`the Cesium renderer has no ${g.kind} shapes`);
}
}
function buildShapes(record) {
const host = new Cesium.PrimitiveCollection();
const outlines = new Cesium.PolylineCollection();
for (const item of record.items.values()) {
if (item.show === false) continue;
if (
item.fill &&
typeof item.fill === 'object' &&
!Array.isArray(item.fill)
)
throw new Error('the Cesium renderer fills shapes with colours only');
const shape = shapeGeometry(item);
const id = pickId(record, item);
const solid =
item.extrudeHeight !== undefined || item.geometry.kind === 'box';
if (item.fill && !item.outlineOnly) {
const color = toColor(item.fill);
host.add(
new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: shape.fill,
modelMatrix: shape.modelMatrix,
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
},
id,
}),
appearance: new Cesium.PerInstanceColorAppearance({
translucent: color.alpha < 1,
closed: solid,
// Flat shapes unlit: lit, their two triangles shade apart.
flat: !solid,
}),
asynchronous: false,
}),
);
}
if (item.outline && shape.ring)
outlines.add({
positions: [...shape.ring, shape.ring[0]],
width: item.outlineWidth ?? 1,
material: Cesium.Material.fromType('Color', {
color: toColor(item.outline),
}),
id,
});
}
host.add(outlines);
replaceHost(record, host);
}
function buildModels(record) {
const host = new Cesium.PrimitiveCollection();
replaceHost(record, host);
// A model counts as loading until it is ready or fails, not when added;
// each model settles once, and a failure is kept for `whenSettled`.
host.loading = record.items.size;
host.failed = 0;
for (const item of record.items.values()) {
let settled = false;
const settle = (failed) => {
if (settled) return;
settled = true;
host.loading -= 1;
if (failed) host.failed += 1;
scene.requestRender();
};
const o = item.orientation;
const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame(
toCartesian(item.pos),
new Cesium.HeadingPitchRoll(o.heading, o.pitch, o.roll),
);
Cesium.Model.fromGltfAsync({
url: item.url,
modelMatrix,
scale: item.scale ?? 1,
minimumPixelSize: item.minPixelSize ?? 0,
// All textures before ready, so a settled capture is the whole model.
incrementallyLoadTextures: false,
id: pickId(record, item),
}).then(
(model) => {
// Rebuilt, disposed, or the renderer is gone.
if (record.host !== host || host.isDestroyed())
return model.destroy();
host.add(model);
model.readyEvent.addEventListener(() => {
settle(false);
for (const cb of record.listeners.ready.slice()) cb(item.id);
});
model.errorEvent.addEventListener(() => settle(true));
scene.requestRender();
},
() => settle(true),
);
}
}
const draw = {
icons: (spec) => makeSet('icons', { ...spec }, buildIcons),
points: (spec) => makeSet('points', { ...spec }, buildPoints),
lines: (spec) => makeSet('lines', { ...spec }, buildLines),
shapes: (spec) => makeSet('shapes', { ...spec }, buildShapes),
models: (spec) => makeSet('models', { ...spec }, buildModels),
};
const cameraPort = {
setView(spec) {
const d = spec.destination;
if (d && 'rect' in d)
throw new Error('the Cesium renderer takes position destinations only');
const o = spec.orientation ?? {};
camera.setView({
...(d ? { destination: toCartesian(d) } : {}),
orientation: {
heading: o.heading ?? 0,
pitch: o.pitch ?? -Math.PI / 2,
roll: o.roll ?? 0,
},
});
scene.requestRender();
},
pose() {
const v = (c) => ({ x: c.x, y: c.y, z: c.z });
return {
position: v(camera.positionWC),
direction: v(camera.directionWC),
up: v(camera.upWC),
right: v(camera.rightWC),
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
height: camera.positionCartographic.height,
fovy: camera.frustum.fovy,
aspectRatio: camera.frustum.aspectRatio,
near: camera.frustum.near,
far: camera.frustum.far,
frameIsIdentity: Cesium.Matrix4.equals(
camera.transform,
Cesium.Matrix4.IDENTITY,
),
};
},
};
const query = {
pick(x, y) {
const picked = scene.pick(new Cesium.Cartesian2(x, y));
const id = picked?.id;
return id && typeof id.set === 'string'
? { ...id, surface: false }
: null;
},
project(position) {
const s = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
toCartesian(position),
);
return s ? { x: s.x, y: s.y } : null;
},
pickEllipsoid(x, y) {
const c = camera.pickEllipsoid(new Cesium.Cartesian2(x, y));
return c ? { x: c.x, y: c.y, z: c.z } : null;
},
};
async function capture({ type = 'png' } = {}) {
scene.render(widget.clock.currentTime);
return new Promise((resolve) =>
scene.canvas.toBlob(resolve, `image/${type}`),
);
}
/** The live model sets, for a renderer's own `whenSettled`. */
const modelSets = () =>
[...sets.values()].filter((r) => r.kind === 'models' && r.host);
return {
frame,
draw,
camera: cameraPort,
query,
capture,
modelSets,
nextPostRender,
/** Detach from the scene; the widget's owner destroys the widget. */
dispose() {
disposed = true;
for (const off of removers) off();
for (const done of [...pendingRenders]) done();
// Late model loads find no host and destroy themselves.
for (const record of sets.values()) record.host = null;
sets.clear();
},
};
}
+17 -525
View File
@@ -6,11 +6,18 @@
* phases, settling and capture. The `/dev/conformance/` page draws the
* scenes with it to make and check the visual references.
*
* Its ports are `ports.js`'s, which the application's renderer shares;
* this factory adds the conformance widget, its fixed settings and the
* bare-globe and sky members.
*
* It is not yet the whole Cesium renderer: each later migration adds the
* members its layer family needs, until it passes the whole suite.
* Members it does not have are absent, never stubbed.
*/
import * as Cesium from 'cesium';
import { createCesiumPorts } from './ports.js';
export { toCartesian, toColor, toImage } from './ports.js';
const SKY_DEFAULTS = Object.freeze({
atmosphere: false,
@@ -22,38 +29,6 @@ const SKY_DEFAULTS = Object.freeze({
lighting: false,
});
/** The contract's colour as Cesium's. */
export function toColor(color, alpha = 1) {
if (color === undefined || color === null)
return Cesium.Color.WHITE.withAlpha(alpha);
const c =
typeof color === 'string'
? Cesium.Color.fromCssColorString(color)
: new Cesium.Color(color[0], color[1], color[2], color[3] ?? 1);
return c.withAlpha(c.alpha * alpha);
}
/** An image reference as something a billboard takes. */
export function toImage(image) {
if (typeof image === 'string') return image;
if (image?.svg)
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(image.svg)}`;
if (image?.canvas) return image.canvas;
throw new Error('the scene renderer takes URL, SVG and canvas images only');
}
/** A contract position as ECEF. */
export function toCartesian(pos) {
if ('x' in pos) return new Cesium.Cartesian3(pos.x, pos.y, pos.z);
return Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.height ?? 0);
}
const ANCHORS = {
center: Cesium.VerticalOrigin.CENTER,
bottom: Cesium.VerticalOrigin.BOTTOM,
top: Cesium.VerticalOrigin.TOP,
};
/**
* @param {{container: HTMLElement, msaa?: number}} options
* @returns {Promise<object>} A renderer with the members listed above.
@@ -72,7 +47,6 @@ export async function createCesiumSceneRenderer({ container, msaa = 4 }) {
contextOptions: { webgl: { preserveDrawingBuffer: true } },
});
const scene = widget.scene;
const camera = scene.camera;
// A fixed time, so lighting is the same in every capture.
widget.clock.shouldAnimate = false;
widget.clock.currentTime = Cesium.JulianDate.fromIso8601(
@@ -85,7 +59,6 @@ export async function createCesiumSceneRenderer({ container, msaa = 4 }) {
if (scene.sun) scene.sun.show = false;
if (scene.moon) scene.moon.show = false;
const sets = new Map();
const sky = { ...SKY_DEFAULTS };
const capabilities = new Set(['models']);
@@ -98,49 +71,9 @@ export async function createCesiumSceneRenderer({ container, msaa = 4 }) {
scene.requestRender();
}
const frameListeners = { preUpdate: [], preRender: [], postRender: [] };
let frames = 0;
const onPhase = (phase) => (_scene, time) => {
const t = {
frame: frames,
time: Cesium.JulianDate.toDate(
time ?? widget.clock.currentTime,
).getTime(),
now: performance.now(),
dt: 1 / 60,
rendering: phase !== 'preUpdate',
};
for (const cb of frameListeners[phase].slice()) cb(t);
};
const removers = [
scene.preUpdate.addEventListener(() => (frames += 1)),
scene.preUpdate.addEventListener(onPhase('preUpdate')),
scene.preRender.addEventListener(onPhase('preRender')),
scene.postRender.addEventListener(onPhase('postRender')),
];
function nextPostRender() {
return new Promise((resolve) => {
const off = scene.postRender.addEventListener(() => {
off();
resolve();
});
scene.requestRender();
});
}
const ports = createCesiumPorts(widget);
const frame = {
on(phase, cb) {
frameListeners[phase].push(cb);
return () => {
const i = frameListeners[phase].indexOf(cb);
if (i >= 0) frameListeners[phase].splice(i, 1);
};
},
requestRender: () => scene.requestRender(),
renderNow: () => scene.render(widget.clock.currentTime),
renderOnce: () => nextPostRender(),
time: () => Cesium.JulianDate.toDate(widget.clock.currentTime).getTime(),
...ports.frame,
setTime(ms) {
widget.clock.currentTime = Cesium.JulianDate.fromDate(new Date(ms));
widget.clock.shouldAnimate = false;
@@ -148,382 +81,7 @@ export async function createCesiumSceneRenderer({ container, msaa = 4 }) {
},
};
// --- Draw ------------------------------------------------------------------
function makeSet(kind, spec, build) {
if (sets.has(spec.id)) throw new Error(`a set with id ${spec.id} is live`);
const items = new Map();
const listeners = { ready: [] };
const record = { kind, spec, items, listeners, host: null, rebuild: null };
const handle = {
id: spec.id,
set(list) {
items.clear();
for (const item of list) items.set(item.id, item);
build(record);
scene.requestRender();
},
upsert(list) {
for (const item of list) items.set(item.id, item);
build(record);
scene.requestRender();
},
remove(ids) {
for (const id of ids) items.delete(id);
build(record);
scene.requestRender();
},
clear() {
items.clear();
build(record);
scene.requestRender();
},
setOptions(options) {
Object.assign(spec, options);
if (record.host) record.host.show = spec.show !== false;
scene.requestRender();
},
size: () => items.size,
get show() {
return spec.show !== false;
},
set show(value) {
spec.show = value;
if (record.host) record.host.show = value;
scene.requestRender();
},
on(event, cb) {
listeners[event]?.push(cb);
return () => {
const list = listeners[event];
const i = list?.indexOf(cb) ?? -1;
if (i >= 0) list.splice(i, 1);
};
},
dispose() {
if (record.host && !record.host.isDestroyed?.())
scene.primitives.remove(record.host);
record.host = null;
// A newer set under the same id is not this handle's to remove.
if (sets.get(spec.id) === record) sets.delete(spec.id);
scene.requestRender();
},
};
sets.set(spec.id, record);
return handle;
}
const pickId = (record, item) => ({
set: record.spec.id,
id: item.id,
meta: item.meta,
});
function replaceHost(record, host) {
if (record.host) scene.primitives.remove(record.host);
record.host = host;
host.show = record.spec.show !== false;
scene.primitives.add(host);
}
function buildIcons(record) {
const host = new Cesium.BillboardCollection({ scene });
for (const item of record.items.values()) {
const position = toCartesian(item.pos);
const options = {
position,
image: toImage(item.image),
width: item.size[0] * (item.scale ?? 1),
height: item.size[1] * (item.scale ?? 1),
color: toColor(item.color, item.alpha ?? 1),
verticalOrigin: ANCHORS[item.anchor ?? 'center'],
disableDepthTestDistance: item.disableDepthTest ?? 0,
show: item.show !== false,
id: pickId(record, item),
};
if (item.rotationMode === 'screen') options.rotation = item.rotation ?? 0;
if (item.rotationMode === 'course') {
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
options.alignedAxis = Cesium.Matrix4.multiplyByPointAsVector(
enu,
Cesium.Cartesian3.UNIT_Y,
new Cesium.Cartesian3(),
);
options.rotation = -Cesium.Math.toRadians(item.course ?? 0);
}
host.add(options);
}
replaceHost(record, host);
}
function buildPoints(record) {
const host = new Cesium.PointPrimitiveCollection();
for (const item of record.items.values())
host.add({
position: toCartesian(item.pos),
pixelSize: item.pixelSize,
color: toColor(item.color),
outlineColor: toColor(item.outlineColor ?? [0, 0, 0, 0]),
outlineWidth: item.outlineWidth ?? 0,
disableDepthTestDistance: item.disableDepthTest ?? 0,
show: item.show !== false,
id: pickId(record, item),
});
replaceHost(record, host);
}
function lineMaterial(style) {
switch (style.kind) {
case 'solid':
return Cesium.Material.fromType('Color', {
color: toColor(style.color),
});
case 'glow':
return Cesium.Material.fromType('PolylineGlow', {
color: toColor(style.color),
glowPower: style.power,
taperPower: style.taper ?? 1,
});
case 'dash':
return Cesium.Material.fromType('PolylineDash', {
color: toColor(style.color),
gapColor: toColor(style.gapColor ?? [0, 0, 0, 0]),
dashLength: style.length,
...(style.pattern16 !== undefined
? { dashPattern: style.pattern16 }
: {}),
});
case 'arrow':
return Cesium.Material.fromType('PolylineArrow', {
color: toColor(style.color),
});
default:
throw new Error(`the scene renderer has no ${style.kind} lines`);
}
}
function linePositions(item) {
const flat = item.positions instanceof Float64Array;
const points = [];
const n = flat ? item.positions.length / 3 : item.positions.length;
for (let i = 0; i < n; i++)
points.push(
flat
? new Cesium.Cartesian3(
item.positions[i * 3],
item.positions[i * 3 + 1],
item.positions[i * 3 + 2],
)
: toCartesian(item.positions[i]),
);
if (item.mode !== 'geodesic') return points;
const dense = [];
for (let i = 0; i + 1 < points.length; i++) {
const a = Cesium.Cartographic.fromCartesian(points[i]);
const b = Cesium.Cartographic.fromCartesian(points[i + 1]);
const geodesic = new Cesium.EllipsoidGeodesic(a, b);
for (let k = 0; k < 64; k++) {
const s = k / 64;
const at = geodesic.interpolateUsingFraction(s);
dense.push(
Cesium.Cartesian3.fromRadians(
at.longitude,
at.latitude,
a.height + (b.height - a.height) * s,
),
);
}
}
dense.push(points.at(-1));
return dense;
}
function buildLines(record) {
const host = new Cesium.PolylineCollection();
for (const item of record.items.values()) {
if (item.mode === 'draped')
throw new Error('the scene renderer has no draped lines');
host.add({
positions: linePositions(item),
width: item.widthPx,
material: lineMaterial(item.style),
show: item.show !== false,
id: pickId(record, item),
});
}
replaceHost(record, host);
}
const heightOf = (pos) =>
'x' in pos
? Cesium.Cartographic.fromCartesian(toCartesian(pos)).height
: (pos.height ?? 0);
function shapeGeometry(item) {
const g = item.geometry;
const extrudedHeight = item.extrudeHeight;
switch (g.kind) {
case 'polygon': {
const [outer, ...holes] = g.rings.map((ring) => ring.map(toCartesian));
const hierarchy = new Cesium.PolygonHierarchy(
outer,
holes.map((h) => new Cesium.PolygonHierarchy(h)),
);
return {
fill: new Cesium.PolygonGeometry({
polygonHierarchy: hierarchy,
height: g.perPositionHeight ? undefined : heightOf(g.rings[0][0]),
extrudedHeight,
perPositionHeight: g.perPositionHeight,
}),
ring: outer,
};
}
case 'ellipse':
return {
fill: new Cesium.EllipseGeometry({
center: toCartesian(g.center),
semiMajorAxis: g.semiMajor,
semiMinorAxis: g.semiMinor,
rotation: g.rotation ?? 0,
height: heightOf(g.center),
extrudedHeight,
}),
};
case 'rectangle':
return {
fill: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(
g.rect.west,
g.rect.south,
g.rect.east,
g.rect.north,
),
height: g.height ?? 0,
extrudedHeight,
}),
};
case 'box': {
const d = g.dimensions;
return {
fill: Cesium.BoxGeometry.fromDimensions({
dimensions: new Cesium.Cartesian3(d.x, d.y, d.z),
}),
modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(
toCartesian(g.center),
),
};
}
default:
throw new Error(`the scene renderer has no ${g.kind} shapes`);
}
}
function buildShapes(record) {
const host = new Cesium.PrimitiveCollection();
const outlines = new Cesium.PolylineCollection();
for (const item of record.items.values()) {
if (item.show === false) continue;
if (
item.fill &&
typeof item.fill === 'object' &&
!Array.isArray(item.fill)
)
throw new Error('the scene renderer fills shapes with colours only');
const shape = shapeGeometry(item);
const id = pickId(record, item);
const solid =
item.extrudeHeight !== undefined || item.geometry.kind === 'box';
if (item.fill && !item.outlineOnly) {
const color = toColor(item.fill);
host.add(
new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: shape.fill,
modelMatrix: shape.modelMatrix,
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
},
id,
}),
appearance: new Cesium.PerInstanceColorAppearance({
translucent: color.alpha < 1,
closed: solid,
// Flat shapes unlit: lit, their two triangles shade apart.
flat: !solid,
}),
asynchronous: false,
}),
);
}
if (item.outline && shape.ring)
outlines.add({
positions: [...shape.ring, shape.ring[0]],
width: item.outlineWidth ?? 1,
material: Cesium.Material.fromType('Color', {
color: toColor(item.outline),
}),
id,
});
}
host.add(outlines);
replaceHost(record, host);
}
function buildModels(record) {
const host = new Cesium.PrimitiveCollection();
replaceHost(record, host);
// A model counts as loading until it is ready or fails, not when added;
// each model settles once, and a failure is kept for `whenSettled`.
host.loading = record.items.size;
host.failed = 0;
for (const item of record.items.values()) {
let settled = false;
const settle = (failed) => {
if (settled) return;
settled = true;
host.loading -= 1;
if (failed) host.failed += 1;
scene.requestRender();
};
const o = item.orientation;
const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame(
toCartesian(item.pos),
new Cesium.HeadingPitchRoll(o.heading, o.pitch, o.roll),
);
Cesium.Model.fromGltfAsync({
url: item.url,
modelMatrix,
scale: item.scale ?? 1,
minimumPixelSize: item.minPixelSize ?? 0,
// All textures before ready, so a settled capture is the whole model.
incrementallyLoadTextures: false,
id: pickId(record, item),
}).then(
(model) => {
if (record.host !== host) return model.destroy();
host.add(model);
model.readyEvent.addEventListener(() => {
settle(false);
for (const cb of record.listeners.ready.slice()) cb(item.id);
});
model.errorEvent.addEventListener(() => settle(true));
scene.requestRender();
},
() => settle(true),
);
}
}
const draw = {
icons: (spec) => makeSet('icons', { ...spec }, buildIcons),
points: (spec) => makeSet('points', { ...spec }, buildPoints),
lines: (spec) => makeSet('lines', { ...spec }, buildLines),
shapes: (spec) => makeSet('shapes', { ...spec }, buildShapes),
models: (spec) => makeSet('models', { ...spec }, buildModels),
};
// --- Globe, camera, query --------------------------------------------------
// --- Globe -----------------------------------------------------------------
const globe = {
async setBase(base) {
@@ -549,75 +107,14 @@ export async function createCesiumSceneRenderer({ container, msaa = 4 }) {
isSettled: () => scene.globe.tilesLoaded,
};
const cameraPort = {
setView(spec) {
const d = spec.destination;
if (d && 'rect' in d)
throw new Error('the scene renderer takes position destinations only');
const o = spec.orientation ?? {};
camera.setView({
...(d ? { destination: toCartesian(d) } : {}),
orientation: {
heading: o.heading ?? 0,
pitch: o.pitch ?? -Math.PI / 2,
roll: o.roll ?? 0,
},
});
scene.requestRender();
},
pose() {
const v = (c) => ({ x: c.x, y: c.y, z: c.z });
return {
position: v(camera.positionWC),
direction: v(camera.directionWC),
up: v(camera.upWC),
right: v(camera.rightWC),
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
height: camera.positionCartographic.height,
fovy: camera.frustum.fovy,
aspectRatio: camera.frustum.aspectRatio,
near: camera.frustum.near,
far: camera.frustum.far,
frameIsIdentity: Cesium.Matrix4.equals(
camera.transform,
Cesium.Matrix4.IDENTITY,
),
};
},
};
const query = {
pick(x, y) {
const picked = scene.pick(new Cesium.Cartesian2(x, y));
const id = picked?.id;
return id && typeof id.set === 'string'
? { ...id, surface: false }
: null;
},
project(position) {
const s = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
toCartesian(position),
);
return s ? { x: s.x, y: s.y } : null;
},
pickEllipsoid(x, y) {
const c = camera.pickEllipsoid(new Cesium.Cartesian2(x, y));
return c ? { x: c.x, y: c.y, z: c.z } : null;
},
};
// --- Renderer --------------------------------------------------------------
async function whenSettled({ timeoutMs = 20000 } = {}) {
const models = () =>
[...sets.values()].filter((r) => r.kind === 'models' && r.host);
const models = ports.modelSets;
const end = performance.now() + timeoutMs;
let quiet = 0;
while (performance.now() < end) {
await nextPostRender();
await ports.nextPostRender();
if (models().some((r) => r.host.failed > 0))
return { state: 'failed', blockers: ['models'] };
const loading = models().some((r) => r.host.loading > 0);
@@ -637,19 +134,14 @@ export async function createCesiumSceneRenderer({ container, msaa = 4 }) {
canvas: scene.canvas,
frame,
globe,
draw,
camera: cameraPort,
query,
async capture({ type = 'png' } = {}) {
scene.render(widget.clock.currentTime);
return new Promise((resolve) =>
scene.canvas.toBlob(resolve, `image/${type}`),
);
},
draw: ports.draw,
camera: ports.camera,
query: ports.query,
capture: ports.capture,
whenSettled,
resize: () => widget.resize(),
destroy() {
for (const off of removers) off();
ports.dispose();
widget.destroy();
},
};