From a5143f4467d7cab62b9e09c749d812caa7f3fdc6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:50:48 -0800 Subject: [PATCH] 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. --- scripts/format-scope.json | 2 +- scripts/package-boundaries.json | 3 +- src/app/scene.js | 5 +- .../cesium/appRenderer.js} | 34 +- .../cesium/appRenderer.test.mjs} | 170 +++++- src/render/cesium/ports.js | 575 ++++++++++++++++++ src/render/cesium/sceneRenderer.js | 542 +---------------- 7 files changed, 770 insertions(+), 561 deletions(-) rename src/{app/renderer.js => render/cesium/appRenderer.js} (69%) rename src/{app/renderer.test.mjs => render/cesium/appRenderer.test.mjs} (60%) create mode 100644 src/render/cesium/ports.js diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 00b764450..49c2353a2 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cc651edd9..44c33afc0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/app/scene.js b/src/app/scene.js index 951eac947..e3f090dc0 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -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'; diff --git a/src/app/renderer.js b/src/render/cesium/appRenderer.js similarity index 69% rename from src/app/renderer.js rename to src/render/cesium/appRenderer.js index 44f43f321..84da62a5d 100644 --- a/src/app/renderer.js +++ b/src/render/cesium/appRenderer.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} + * @typedef {Pick} * 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(); diff --git a/src/app/renderer.test.mjs b/src/render/cesium/appRenderer.test.mjs similarity index 60% rename from src/app/renderer.test.mjs rename to src/render/cesium/appRenderer.test.mjs index 8fe1b1c16..b3d37aecb 100644 --- a/src/app/renderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -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`); diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js new file mode 100644 index 000000000..4ed2403ee --- /dev/null +++ b/src/render/cesium/ports.js @@ -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(); + }, + }; +} diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index 17376eb7c..69b956326 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -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} 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(); }, };