mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
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:
1 parent
03f2e2a9b9
commit
a5143f4467
7 files changed
+770
-561
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user