refactor(transit): isolate native rendering from playback

This commit is contained in:
German Khokhlov committed 2026-10-29 17:12:18 +04:00
1 parent 694616d5c2
commit b8560e5b26
21 files changed
+465 -256

No files matched your search

+2
View File
@@ -828,6 +828,8 @@
"src/layers/transit/policy.js",
"src/layers/transit/queries.js",
"src/layers/transit/rendering.js",
"src/layers/transit/render/cesium.js",
"src/layers/transit/render/cesium.test.mjs",
"src/layers/transit/selection.js",
"src/layers/transit/state.js",
"src/layers/transit/testing.js",
+6 -6
View File
@@ -2459,6 +2459,7 @@
"src/data/transitPresetStyle.js",
"src/layers/transit/controls.js",
"src/layers/transit/height.js",
"src/layers/transit/render/cesium.js",
"src/layers/transit/index.js",
"src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js",
@@ -3172,6 +3173,7 @@
"src/data/transitPresetStyle.js",
"src/layers/transit/controls.js",
"src/layers/transit/height.js",
"src/layers/transit/render/cesium.js",
"src/layers/transit/index.js",
"src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js",
@@ -3848,6 +3850,8 @@
"src/data/transitProxy.js",
"src/layers/transit/controls.js",
"src/layers/transit/height.js",
"src/layers/shared/color.js",
"src/layers/transit/render/cesium.js",
"src/layers/transit/index.js",
"src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js",
@@ -3903,14 +3907,13 @@
"transit-layer": {
"exports": ["./layers/transit"],
"modules": [
"src/data/iconOrientation.js",
"src/data/render/cesiumProjection.js",
"src/data/inputOwnership.js",
"src/data/transitFeeds.js",
"src/data/transitIcons.js",
"src/data/transitPresetStyle.js",
"src/layers/transit/controls.js",
"src/layers/transit/height.js",
"src/layers/shared/color.js",
"src/layers/transit/index.js",
"src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js",
@@ -3921,10 +3924,8 @@
"src/layers/transit/state.js",
"src/layers/transit/testing.js",
"src/layers/transit/viewport.js",
"src/data/cameraSensitivity.js",
"src/data/contactPlayback.js",
"src/data/contactTrailRenderer.js",
"src/data/render/cesiumContactTrailRenderer.js",
"src/data/transitProxy.js",
"src/data/gtfsRealtime.js",
"src/layers/transit/trails.js",
@@ -3952,8 +3953,7 @@
"src/geo/check.js",
"src/geo/debug.js",
"src/geo/developerError.js",
"src/globe/contract.js",
"src/globe/cesium/engineFromViewer.js"
"src/globe/contract.js"
],
"external": ["cesium", "pbf"]
},
+10
View File
@@ -1,3 +1,13 @@
export function transitQaBase(value) {
if (!value) throw new Error('Transit QA requires an explicit QA_BASE_URL');
const url = new URL(value);
if (
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(url.port)
)
throw new Error('Transit QA may not use a reserved port');
return url.href.replace(/\/$/, '');
}
/** Bound page promises as well as CDP itself; labels identify a stalled section. */
export function boundPageEvaluations(page, defaultTimeoutMs = 30000) {
const evaluate = page.evaluate.bind(page);
+10 -1
View File
@@ -1,15 +1,24 @@
import puppeteer from 'puppeteer';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { transitQaBase } from './qa-transit-browser.mjs';
/** Prove parked and moving headings in the real scene, without live feeds. */
export async function runTransitHeadingRegression(base, check) {
base = transitQaBase(base);
const browser = await puppeteer.launch({
headless: true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox', '--disable-background-timer-throttling'],
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--use-angle=metal',
'--enable-gpu',
],
});
try {
const page = await browser.newPage();
await blockPaidQaMaps(page);
await page.setViewport({ width: 1280, height: 800 });
await page.goto(
`${base}/?welcome=0#lat=42.3601&lon=-71.0589&alt=1200&heading=0&pitch=-90`,
+7 -2
View File
@@ -2,21 +2,26 @@
import puppeteer from 'puppeteer';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { transitQaBase } from './qa-transit-browser.mjs';
const base = process.env.QA_BASE_URL || 'http://localhost:4305';
const base = transitQaBase(process.env.QA_BASE_URL);
const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: process.env.QA_HEADFUL === '1' ? false : true,
headless: true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: [
'--use-angle=metal',
'--enable-gpu',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
try {
const page = await browser.newPage();
await blockPaidQaMaps(page);
await page.setViewport({ width: 1920, height: 1080 });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
+10 -4
View File
@@ -6,6 +6,7 @@ import {
restoreRetainedTransit,
sampleTransitPixels,
readTransitFleetStyle,
transitQaBase,
} from './qa-transit-browser.mjs';
import {
BOSTON,
@@ -22,7 +23,7 @@ import {
/**
* Browser proof for the Transit layer against a running app.
*
* npm run qa:transit (defaults to http://localhost:4173)
* QA_BASE_URL=http://localhost:4305 npm run qa:transit
* QA_BASE_URL=http://localhost:4305 node scripts/qa-transit.mjs
*
* Drives the real layer toggle and the real CONTEXT chooser, watches the real
@@ -36,6 +37,7 @@ import {
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js';
@@ -50,9 +52,9 @@ import {
reduceFleet,
} from '../src/layers/transit/qaMetrics.js';
const BASE = process.env.QA_BASE_URL || 'http://localhost:4173';
const BASE = transitQaBase(process.env.QA_BASE_URL);
const SHOTS = process.env.QA_SHOTS || 'qa-shots/transit';
const TAG = process.env.QA_TAG || 'keyed';
const TAG = process.env.QA_TAG || 'keyless';
/** Which sections to run: all, or a comma list of cities,playback,disable,context. */
const SECTIONS = new Set((process.env.QA_SECTIONS || 'all').split(','));
const runs = (section) => SECTIONS.has('all') || SECTIONS.has(section);
@@ -260,12 +262,14 @@ historyProbe.clear();
await mkdir(SHOTS, { recursive: true });
const browser = await puppeteer.launch({
headless: process.env.QA_HEADFUL === '1' ? false : true,
headless: true,
protocolTimeout: 150000,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: [
'--no-sandbox',
'--use-angle=metal',
'--enable-gpu',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1920,1080',
@@ -273,6 +277,7 @@ const browser = await puppeteer.launch({
],
});
const page = await browser.newPage();
await blockPaidQaMaps(page);
boundPageEvaluations(page);
await page.setViewport({ width: 1920, height: 1080 });
const pageErrors = [];
@@ -1018,6 +1023,7 @@ try {
if (runs('history')) {
console.log('\n== retained history reload ==');
const recovery = await browser.newPage();
await blockPaidQaMaps(recovery);
boundPageEvaluations(recovery);
try {
await recovery.goto(view(BOSTON), { waitUntil: 'domcontentloaded' });
+11 -1
View File
@@ -1,4 +1,6 @@
import { createTransitLayer } from '../../layers/transit/index.js';
import { createTransitCesiumRenderer } from '../../layers/transit/render/cesium.js';
import { rendererFor } from './renderers.js';
import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
@@ -22,12 +24,20 @@ import {
* @param {{surface: object}} options
* @returns {object}
*/
export function createApplicationTransit({ surface, source }) {
export function createApplicationTransit({
surface,
source,
rendererFactory = ({ engine, ...context }) =>
engine?.kind === 'navara'
? rendererFor('transit', engine, context)
: createTransitCesiumRenderer(context),
}) {
if (!surface?.groundFloor || !surface?.meshFloor)
throw new TypeError('Transit needs the application surface services');
const { groundFloor, meshFloor } = surface;
return createTransitLayer({
source,
rendererFactory,
services: {
render,
sprites,
+9 -2
View File
@@ -1,4 +1,5 @@
import { createTransitLayer } from '../layers/transit/index.js';
import { createTransitLayer as createLayer } from '../layers/transit/index.js';
import { createTransitCesiumRenderer } from '../layers/transit/render/cesium.js';
import * as render from '../renderGovernor.js';
import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js';
@@ -13,6 +14,13 @@ import {
} from './groundFloor.js';
import { sampleMeshFloorCells } from './meshFloorSampler.js';
export function createTransitLayer(options) {
return createLayer({
rendererFactory: createTransitCesiumRenderer,
...options,
});
}
const layer = createTransitLayer({
services: {
render,
@@ -51,6 +59,5 @@ export {
transitDetectionMetric,
transitVehicleKey,
vehicleFixAgeMs,
createTransitLayer,
} from '../layers/transit/index.js';
export default layer;
+6 -22
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import {
FLOOR_REREAD_ATTEMPTS,
FLOOR_REREAD_MS,
@@ -18,13 +17,10 @@ export function createHeight({ state, services, parts }) {
coarseFloorCoord,
GROUND_FLOOR_LIFT_M,
} = services.ground;
// The application's rendered-mesh sampler. Optional so a layer built on a
// bare floor stub still runs; the application always supplies it.
const sampleMeshFloorCells = services.mesh?.sampleMeshFloorCells || null;
/** The camera's subpoint in degrees, for the sampler's proximity gate. */
function viewerSubpoint() {
const carto = state._viewer?.camera?.positionCartographic;
const carto = parts.renderer?.cameraCartographic();
if (!carto) return { viewerLat: undefined, viewerLon: undefined };
return {
viewerLat: (carto.latitude * 180) / Math.PI,
@@ -33,7 +29,7 @@ export function createHeight({ state, services, parts }) {
}
function cameraAltitude() {
const carto = state._viewer?.camera?.positionCartographic;
const carto = parts.renderer?.cameraCartographic();
return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
}
@@ -53,7 +49,6 @@ export function createHeight({ state, services, parts }) {
* @param {number} lon
* @returns {{cell: string, height: number|null, prior: number|null}}
*/
const terrainPoint = new Cesium.Cartographic();
function requestHeight(lat, lon, entry = null) {
const coarse = coarseFloorCoord(lat, lon);
const cell = `${coarse.lat},${coarse.lon}`;
@@ -63,20 +58,9 @@ export function createHeight({ state, services, parts }) {
return { cell, height: fixtureFloor, prior: null };
// On a terrain globe, use the loaded surface under the marker. This is
// synchronous cache work and does not launch a terrain request.
const globe = state._viewer?.scene?.globe;
if (globe?.show === true) {
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
const rendered = globe.getHeight?.(terrainPoint);
const flat =
state._viewer?.terrainProvider instanceof
Cesium.EllipsoidTerrainProvider;
if (Number.isFinite(rendered) || flat)
return {
cell,
height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M,
prior: null,
};
}
const rendered = parts.renderer?.terrainHeight(lat, lon);
if (Number.isFinite(rendered))
return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null };
const floor = cachedGroundFloor(lat, lon);
if (Number.isFinite(floor)) {
return { cell, height: floor + GROUND_FLOOR_LIFT_M, prior: null };
@@ -204,7 +188,7 @@ export function createHeight({ state, services, parts }) {
for (let i = 0; i < dem.length; i += 8)
warmGroundFloor(dem.slice(i, i + 8));
for (let i = 0; i < mesh.length; i += 8)
sampleMeshFloorCells?.(state._viewer?.scene, mesh.slice(i, i + 8), {
parts.renderer?.sampleMeshFloorCells(mesh.slice(i, i + 8), {
excludeObjects: markers,
...viewerSubpoint(),
});
+2 -1
View File
@@ -27,6 +27,7 @@ import { TRANSIT_POLL_MS } from './policy.js';
export function createTransitLayer({
services,
source = createTransitSource(),
rendererFactory,
}) {
if (!services?.overlays || !services?.render || !services?.sprites) {
throw new TypeError(
@@ -43,7 +44,7 @@ export function createTransitLayer({
}
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
const context = { state, services, parts, source, rendererFactory };
parts.trails = createTrails(context);
parts.height = createHeight(context);
parts.selection = createSelection(context);
+1 -3
View File
@@ -48,7 +48,6 @@ export const ORDER_REJECTS_BEFORE_RESET = 3;
*/
export function createIngestion({ state, services, parts, source }) {
const { governorRequestRender } = services.render;
const { registerDynamicCredit, transitFeedCredit } = services.credits;
function feedStatus(feedId) {
let status = state._feedStatus.get(feedId);
@@ -397,8 +396,7 @@ export function createIngestion({ state, services, parts, source }) {
`[Data:Transit] ${feed.id}: ${rejectedFixes} fix(es) implied impossible travel and were not animated`,
);
}
if (state._viewer)
registerDynamicCredit(state._viewer, transitFeedCredit(feed));
parts.renderer?.addCredit(feed);
if (
state._selectedKey &&
state._vehicles.get(state._selectedKey)?.feedId === feed.id
+45 -53
View File
@@ -1,13 +1,5 @@
import { destroyTrack } from '../../data/contactPlayback.js';
import * as Cesium from 'cesium';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
} from '../../data/cameraSensitivity.js';
import {
CAMERA_PERCENTAGE_CHANGED,
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
} from './policy.js';
import { TRANSIT_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
/**
@@ -18,12 +10,13 @@ import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
* @param {object} context
* @returns {object}
*/
export function createLifecycle({ state, services, parts }) {
const {
registerSpriteCollection,
unregisterSpriteCollection,
restoreSpriteOrder,
} = services.sprites;
export function createLifecycle({
state,
services,
parts,
source,
rendererFactory,
}) {
const { registerPickOwner, unregisterPickOwner } = services.picking;
const { releaseContinuousRender } = services.render;
@@ -38,17 +31,13 @@ export function createLifecycle({ state, services, parts }) {
* default while Bikeshare was still relying on a fine one was exactly the
* bug that produced.
*/
function borrowCameraSensitivity(viewer) {
claimCameraSensitivity(
viewer?.camera,
'transit',
CAMERA_PERCENTAGE_CHANGED,
);
function borrowCameraSensitivity() {
parts.renderer.claimCamera();
}
/** Give the claim up. The camera keeps whatever anyone else still asks for. */
function returnCameraSensitivity(viewer) {
releaseCameraSensitivity(viewer?.camera, 'transit');
function returnCameraSensitivity() {
parts.renderer?.releaseCamera();
}
/**
@@ -124,20 +113,29 @@ export function createLifecycle({ state, services, parts }) {
* Create the point collection. Called once at bootstrap.
* @param {Cesium.Viewer} viewer
*/
init(viewer) {
init(viewer, options = {}) {
if (parts.renderer)
throw new Error('Transit layer is already initialized');
if (typeof rendererFactory !== 'function')
throw new TypeError('A transit renderer factory is required');
const renderer = rendererFactory({
state,
services,
parts,
source,
engine: options.engine,
});
state._viewer = viewer;
state._markers = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
viewer.scene.primitives.add(state._markers);
registerSpriteCollection('transit', state._markers);
state._animatedMarkers = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
viewer.scene.primitives.add(state._animatedMarkers);
registerSpriteCollection('transit-motion', state._animatedMarkers);
state._animatedMarkers.show = false;
state._markers.show = false;
parts.renderer = renderer;
try {
renderer.attach(viewer);
[state._markers, state._animatedMarkers] = renderer.createMarkers();
} catch (error) {
renderer.destroy();
parts.renderer = null;
state._viewer = null;
throw error;
}
state._enabled = false;
state._activeFeeds.clear();
state._feedStatus.clear();
@@ -159,7 +157,7 @@ export function createLifecycle({ state, services, parts }) {
state._rotationPose = null;
state._rotationDirty = false;
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false);
restoreSpriteOrder(viewer);
parts.renderer.restoreOrder();
bindStyleEvents();
console.log(
`[Data:Transit] Initialized with ${TRANSIT_ENABLED_FEEDS.length} GTFS-RT feeds`,
@@ -185,17 +183,19 @@ export function createLifecycle({ state, services, parts }) {
parts.selection.installClickHandler(viewer);
registerPickOwner('transit', (pickedId) => state._vehicles.has(pickedId));
if (!state._cameraChangedAttached) {
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
state._cameraChangedRemove = parts.renderer.onCameraChanged(
parts.viewport.onCameraChanged,
);
borrowCameraSensitivity(viewer);
state._cameraChangedAttached = true;
}
if (!state._preRenderRemove) {
state._preRenderRemove = viewer.scene.preRender.addEventListener(
state._preRenderRemove = parts.renderer.onPreRender(
parts.rendering.onPreRender,
);
}
parts.viewport.runProximityCheck();
restoreSpriteOrder(viewer);
parts.renderer.restoreOrder();
},
/**
@@ -222,9 +222,8 @@ export function createLifecycle({ state, services, parts }) {
parts.selection.removeClickHandler();
unregisterPickOwner('transit');
if (state._cameraChangedAttached) {
viewer.camera.changed.removeEventListener(
parts.viewport.onCameraChanged,
);
state._cameraChangedRemove?.();
state._cameraChangedRemove = null;
returnCameraSensitivity(viewer);
state._cameraChangedAttached = false;
}
@@ -281,16 +280,9 @@ export function createLifecycle({ state, services, parts }) {
*/
destroy(viewer) {
this.disable(viewer);
if (state._markers) {
unregisterSpriteCollection('transit', state._markers);
viewer?.scene?.primitives?.remove(state._markers);
state._markers = null;
}
if (state._animatedMarkers) {
unregisterSpriteCollection('transit-motion', state._animatedMarkers);
viewer?.scene?.primitives?.remove(state._animatedMarkers);
state._animatedMarkers = null;
}
parts.renderer?.destroy();
parts.renderer = null;
state._markers = state._animatedMarkers = null;
state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID);
parts.height.clear();
unbindStyleEvents();
+18 -1
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTransitLayer } from './index.js';
import { createTransitCesiumRenderer } from './render/cesium.js';
import {
FEED_EVICT_AFTER_MS,
FEED_STALE_AFTER_MS,
@@ -264,6 +265,7 @@ function harness(
/** Every call the floor cycle makes to the rendered-mesh sampler. */
const meshCalls = [];
const layer = createTransitLayer({
rendererFactory: createTransitCesiumRenderer,
source,
services: {
ground,
@@ -937,6 +939,7 @@ test('destroy releases every shared registration this layer took', async (t) =>
test('two layers built from the factory share no state', async (t) => {
const app = harness(t);
const second = createTransitLayer({
rendererFactory: createTransitCesiumRenderer,
services: {
ground: groundStub(() => 12),
render: {
@@ -2922,6 +2925,13 @@ test('mesh refinement reaches all 100 retained DEM cells within five poll budget
},
},
parts: {
renderer: {
cameraCartographic: () => state._viewer.camera.positionCartographic,
terrainHeight: () => undefined,
sampleMeshFloorCells(points) {
for (const point of points) probed.add(point.cell);
},
},
trails: {
prepareEntry(e, collect) {
if (collect)
@@ -3931,6 +3941,9 @@ test('parked transit reprojects held and reported courses at the bounded camera
_rotationDirty: false,
};
const noop = () => {};
const renderer = createTransitCesiumRenderer({ state, services: {} });
renderer.attach(state._viewer);
t.after(() => renderer.destroy());
const rendering = createRendering({
state,
services: {
@@ -3940,7 +3953,11 @@ test('parked transit reprojects held and reported courses at the bounded camera
releaseContinuousRender: noop,
},
},
parts: { height: { nearGround: () => false }, trails: { update: noop } },
parts: {
renderer,
height: { nearGround: () => false },
trails: { update: noop },
},
});
rendering.onPreRender();
const before = [held.marker.rotation, reportedOnly.marker.rotation];
+216
View File
@@ -0,0 +1,216 @@
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
cameraPoseSignature,
perspectiveProjectedRotation,
} from '../../../data/iconOrientation.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
} from '../../../data/cameraSensitivity.js';
import { createContactTrailRenderer } from '../../../data/render/cesiumContactTrailRenderer.js';
import { CAMERA_PERCENTAGE_CHANGED } from '../policy.js';
/** Native resource owner; feed/playback/selection policy stays in the layer. */
export function createTransitCesiumRenderer({ state, services }) {
let viewer = null;
const banks = [];
const removers = new Set();
const terrainPoint = new Cesium.Cartographic();
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
const sphere = new Cesium.BoundingSphere(undefined, 5);
const cameraPosition = geo.cartesian3.create();
const own = (remove) => {
const release = () => {
if (removers.delete(release)) remove();
};
removers.add(release);
return release;
};
return {
attach(host) {
viewer = host;
},
get ready() {
return Boolean(viewer);
},
createMarkers() {
try {
for (const id of ['transit', 'transit-motion']) {
const collection = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
banks.push({ id, collection });
viewer.scene.primitives.add(collection);
services.sprites.registerSpriteCollection(id, collection);
collection.show = false;
}
return banks.map(({ collection }) => collection);
} catch (error) {
this.destroy();
throw error;
}
},
restoreOrder: () => services.sprites.restoreSpriteOrder(viewer),
cameraCartographic: () => viewer?.camera?.positionCartographic,
viewRectangle: () =>
viewer?.camera?.computeViewRectangle?.(viewer.scene.globe?.ellipsoid),
cameraSignature: () =>
viewer?.scene?.camera ? cameraPoseSignature(viewer.scene.camera) : null,
rotation: (position, course, previous) =>
perspectiveProjectedRotation(viewer.scene, position, course, previous),
visibilityTester() {
const camera = viewer?.camera;
if (!camera) return null;
const position =
camera.positionWC ||
geo.ellipsoid.WGS84.cartographicToCartesian(
camera.positionCartographic,
cameraPosition,
);
occluder.cameraPosition = position;
const frustum = camera.frustum?.computeCullingVolume(
position,
camera.directionWC,
camera.upWC,
);
const planes = frustum?.planes;
return {
hasFrustum: Boolean(frustum),
test(point) {
sphere.center = point;
let inFrustum = true;
if (planes) {
for (const p of planes) {
if (
p.x * point.x + p.y * point.y + p.z * point.z + p.w <
-sphere.radius
) {
inFrustum = false;
break;
}
}
} else if (frustum)
inFrustum =
frustum.computeVisibility(sphere) !== Cesium.Intersect.OUTSIDE;
return { horizon: occluder.isPointVisible(point), inFrustum };
},
};
},
claimCamera() {
claimCameraSensitivity(
viewer?.camera,
'transit',
CAMERA_PERCENTAGE_CHANGED,
);
},
releaseCamera() {
releaseCameraSensitivity(viewer?.camera, 'transit');
},
onCameraChanged: (fn) => own(viewer.camera.changed.addEventListener(fn)),
onPreRender: (fn) => own(viewer.scene.preRender.addEventListener(fn)),
onPostRender: (fn) => own(viewer.scene.postRender.addEventListener(fn)),
terrainHeight(lat, lon) {
const globe = viewer?.scene?.globe;
if (globe?.show !== true) return undefined;
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
const rendered = globe.getHeight?.(terrainPoint);
return viewer?.terrainProvider instanceof Cesium.EllipsoidTerrainProvider
? 0
: rendered;
},
sampleMeshFloorCells: (points, options) =>
services.mesh?.sampleMeshFloorCells?.(viewer?.scene, points, options),
addCredit(feed) {
if (viewer)
services.credits.registerDynamicCredit(
viewer,
services.credits.transitFeedCredit(feed),
);
},
createTrailRenderer: () => createContactTrailRenderer(viewer.scene),
bindClick(listener, pointerFree) {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((click) => {
if (!pointerFree()) return;
const scene = viewer.scene;
const position = Cesium.Cartesian2.clone(click.position);
const style = state._stylePreset;
const stage = scene.postProcessStages?.getStageByName?.(
`godsEyeView_${style}`,
);
if (
stage?.enabled &&
(style === 'thermal' || style === 'surveillance')
) {
const intensity = stage.uniforms.intensity;
const canvas = scene.canvas,
w = canvas.clientWidth,
h = canvas.clientHeight;
let u = position.x / w,
v = 1 - position.y / h;
if (style === 'surveillance') {
const x = u * 2 - 1,
y = v * 2 - 1,
r2 = x * x + y * y;
const d = 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
u = (x * d + 1) / 2;
v = (y * d + 1) / 2;
}
const grid = 1 + (stage.uniforms.pixelation - 1) * intensity;
u +=
((Math.floor((u * canvas.width) / grid) * grid) / canvas.width -
u) *
intensity;
v +=
((Math.floor((v * canvas.height) / grid) * grid) / canvas.height -
v) *
intensity;
position.x = u * w;
position.y = (1 - v) * h;
}
const picked = scene.pick(position);
listener({
id: typeof picked?.id === 'string' ? picked.id : null,
primitiveId:
typeof picked?.primitive?.id === 'string'
? picked.primitive.id
: null,
x: position.x,
y: position.y,
collection:
picked?.primitive?._billboardCollection === state._animatedMarkers
? 'animated'
: picked?.primitive?._billboardCollection === state._markers
? 'stationary'
: null,
});
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
const remove = own(() => handler.destroy());
return {
destroy: remove,
getInputAction: (type) => handler.getInputAction(type),
};
},
frameProbe() {
return {
canvas: viewer.scene.canvas,
gl: viewer.scene.context._gl,
uploads: banks.map(({ collection }) => ({
target: collection,
method: 'update',
})),
};
},
destroy() {
for (const remove of [...removers]) remove();
this.releaseCamera();
for (const { id, collection } of banks.splice(0)) {
services.sprites.unregisterSpriteCollection(id, collection);
viewer?.scene?.primitives?.remove(collection);
if (!collection.isDestroyed()) collection.destroy();
}
viewer = null;
},
};
}
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTransitCesiumRenderer } from './cesium.js';
test('failed native bank registration releases both acquired collections', () => {
const acquired = [],
registered = new Map();
const primitives = new Cesium.PrimitiveCollection();
const renderer = createTransitCesiumRenderer({
state: {},
services: {
sprites: {
registerSpriteCollection(id, collection) {
acquired.push(collection);
registered.set(id, collection);
if (id === 'transit-motion') throw new Error('registration failed');
},
unregisterSpriteCollection: (id) => registered.delete(id),
},
},
});
renderer.attach({ scene: { primitives } });
assert.throws(() => renderer.createMarkers(), /registration failed/);
assert.equal(primitives.length, 0);
assert.equal(registered.size, 0);
assert.equal(acquired.length, 2);
assert.ok(acquired.every((collection) => collection.isDestroyed()));
renderer.destroy();
primitives.destroy();
});
test('failed scene insertion also destroys the uninserted collection', (t) => {
let acquired;
t.mock.method(Cesium.BillboardCollection.prototype, 'destroy', function () {
acquired = this;
this.isDestroyed = () => true;
});
const renderer = createTransitCesiumRenderer({
state: {},
services: { sprites: { unregisterSpriteCollection() {} } },
});
renderer.attach({
scene: {
primitives: {
add() {
throw new Error('insertion failed');
},
remove() {
return false;
},
},
},
});
assert.throws(() => renderer.createMarkers(), /insertion failed/);
assert.ok(acquired?.isDestroyed());
renderer.destroy();
});
+14 -55
View File
@@ -1,9 +1,5 @@
import * as Cesium from 'cesium';
import { Rgba, rgbaFromCss } from '../shared/color.js';
import * as geo from '../../geo/index.js';
import {
cameraPoseSignature,
perspectiveProjectedRotation,
} from '../../data/iconOrientation.js';
import {
updatePlayback,
applyDisplayCourse,
@@ -33,7 +29,7 @@ import {
export const MODE_CESIUM_COLORS = Object.fromEntries(
Object.entries(TRANSIT_MODE_COLORS).map(([mode, css]) => [
mode,
Cesium.Color.fromCssColorString(css),
rgbaFromCss(css),
]),
);
@@ -198,12 +194,7 @@ export function createRendering({ state, services, parts }) {
const key = `${transitStyleProfile(style)}:${mode}`;
let color = _presetColors.get(key);
if (!color) {
color = new Cesium.Color(
rgba[0] / 255,
rgba[1] / 255,
rgba[2] / 255,
rgba[3],
);
color = new Rgba(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3]);
_presetColors.set(key, color);
}
return color;
@@ -303,11 +294,10 @@ export function createRendering({ state, services, parts }) {
* changed somebody's heading.
*/
function refreshRotations(now) {
const scene = state._viewer?.scene;
if (!scene?.camera) return;
if (!parts.renderer?.ready) return;
if (now - state._rotationAt < ROTATION_REFRESH_MS) return;
state._rotationAt = now;
const pose = cameraPoseSignature(scene.camera);
const pose = parts.renderer.cameraSignature();
const cameraChanged =
state._rotationPose !== pose ||
state._rotationRevision !== state._cameraRevision;
@@ -345,8 +335,7 @@ export function createRendering({ state, services, parts }) {
// camera-basis helper aircraft use is orthographic — fine at altitude,
// and wrong by twenty-odd degrees for a street-level contact away from
// the centre of an obliquely pitched view, which is most of them.
entry.marker.rotation = perspectiveProjectedRotation(
scene,
entry.marker.rotation = parts.renderer.rotation(
entry.marker.position,
course,
entry.marker.rotation,
@@ -424,8 +413,7 @@ export function createRendering({ state, services, parts }) {
const selected = state._vehicles.get(state._selectedKey);
if (selected && state._visible.has(selected)) {
selected.marker.rotation = Number.isFinite(selected.courseDeg)
? perspectiveProjectedRotation(
state._viewer.scene,
? parts.renderer.rotation(
selected.marker.position,
selected.courseDeg,
selected.marker.rotation,
@@ -436,9 +424,6 @@ export function createRendering({ state, services, parts }) {
syncRenderHold();
}
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
const sphere = new Cesium.BoundingSphere(undefined, 5);
const cameraPosition = geo.cartesian3.create();
const candidatePosition = geo.cartesian3.create();
/** Visibility work runs off the frame path, coalesced at four sweeps/second. */
function requestVisibility() {
@@ -461,22 +446,8 @@ export function createRendering({ state, services, parts }) {
}
state._visibilityAt = now;
state._visibilityDirty = false;
const viewer = state._viewer,
camera = viewer?.camera;
if (!camera) return 0;
const position =
camera.positionWC ||
geo.ellipsoid.WGS84.cartographicToCartesian(
camera.positionCartographic,
cameraPosition,
);
occluder.cameraPosition = position;
const frustum = camera.frustum?.computeCullingVolume(
position,
camera.directionWC,
camera.upWC,
);
const planes = frustum?.planes;
const visibilityTest = parts.renderer?.visibilityTester();
if (!visibilityTest) return 0;
let changed = false;
for (const entry of state._vehicles.values()) {
if (!entry.marker) continue;
@@ -487,7 +458,7 @@ export function createRendering({ state, services, parts }) {
entry.sample ||= {};
updatePlayback(entry, now, performance.now());
}
sphere.center =
const position =
state._visible.has(entry) || !entry.sample
? entry.marker.position
: cartesianFor(
@@ -500,24 +471,12 @@ export function createRendering({ state, services, parts }) {
// view. The current frustum is authoritative; the horizon still rejects
// the far side when the rectangle is missing or stale.
const bounds = vehicleNearView(state._viewBounds, entry);
const horizon = occluder.isPointVisible(sphere.center);
let inFrustum = true;
if (planes) {
for (let i = 0; i < planes.length; i++) {
const p = planes[i],
c = sphere.center;
if (p.x * c.x + p.y * c.y + p.z * c.z + p.w < -sphere.radius) {
inFrustum = false;
break;
}
}
} else if (frustum)
inFrustum =
frustum.computeVisibility(sphere) !== Cesium.Intersect.OUTSIDE;
const visible = (frustum || bounds) && horizon && inFrustum;
const { horizon, inFrustum } = visibilityTest.test(position);
const visible =
(visibilityTest.hasFrustum || bounds) && horizon && inFrustum;
const visibility = (entry.visibility ||= {});
visibility.bounds = bounds;
visibility.boundsApplied = !frustum;
visibility.boundsApplied = !visibilityTest.hasFrustum;
visibility.occluder = horizon;
visibility.frustum = inFrustum;
visibility.noSample = noSample;
+9 -72
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import {
SELECTED_CARD_REFRESH_MS,
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
@@ -114,79 +113,17 @@ export function createSelection({ state, services, parts }) {
function installClickHandler(viewer) {
if (state._clickHandler) return;
state._clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
state._clickHandler.setInputAction((click) => {
// Ambient selection never competes for a pointer a tool has claimed:
// placing a draw vertex on top of a bus must not also select the bus.
if (!isPointerFree()) return;
const scene = viewer.scene;
// Pick buffers precede post-processing. Map a sensor pixel back through
// the active shader's barrel distortion and quantization before picking.
const position = Cesium.Cartesian2.clone(click.position);
const style = state._stylePreset;
const stage = scene.postProcessStages?.getStageByName?.(
`godsEyeView_${style}`,
);
if (stage?.enabled && (style === 'thermal' || style === 'surveillance')) {
const intensity = stage.uniforms.intensity;
const canvas = scene.canvas,
w = canvas.clientWidth,
h = canvas.clientHeight;
let u = position.x / w,
v = 1 - position.y / h;
if (style === 'surveillance') {
const x = u * 2 - 1,
y = v * 2 - 1,
r2 = x * x + y * y;
const d = 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
u = (x * d + 1) / 2;
v = (y * d + 1) / 2;
}
const grid = 1 + (stage.uniforms.pixelation - 1) * intensity;
u +=
((Math.floor((u * canvas.width) / grid) * grid) / canvas.width - u) *
intensity;
v +=
((Math.floor((v * canvas.height) / grid) * grid) / canvas.height -
v) *
intensity;
position.x = u * w;
position.y = (1 - v) * h;
}
const picked = scene.pick(position);
state._lastPickForTest = {
id: typeof picked?.id === 'string' ? picked.id : null,
primitiveId:
typeof picked?.primitive?.id === 'string'
? picked.primitive.id
: null,
x: position.x,
y: position.y,
collection:
picked?.primitive?._billboardCollection === state._animatedMarkers
? 'animated'
: picked?.primitive?._billboardCollection === state._markers
? 'stationary'
: null,
};
if (picked) {
const primitiveId = picked.primitive?.id;
if (
typeof primitiveId === 'string' &&
state._vehicles.has(primitiveId)
) {
selectVehicle(primitiveId);
return;
}
if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) {
selectVehicle(picked.id);
return;
}
state._clickHandler = parts.renderer.bindClick((picked) => {
state._lastPickForTest = picked;
const key = state._vehicles.has(picked.primitiveId)
? picked.primitiveId
: picked.id;
if (typeof key === 'string' && state._vehicles.has(key)) {
selectVehicle(key);
return;
}
if (state._selectedKey) clearSelection();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}, isPointerFree);
document.addEventListener('keydown', onKeyDown);
}
+1
View File
@@ -37,6 +37,7 @@ export function createState({ services }) {
state._generation = 0;
state._cameraChangedAttached = false;
state._cameraChangedRemove = null;
state._cameraDebounceTimer = null;
state._altitudeGateOpen = false;
/** Camera sensitivity as we found it, so disable() can hand it back. */
+16 -16
View File
@@ -1,4 +1,4 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { recordFix, updatePlayback, attachFloorToPlace } from './movement.js';
import { seek, setRate, mergeHistory } from '../../data/contactPlayback.js';
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
@@ -97,8 +97,8 @@ export function createTesting({ state, parts }) {
entry.displayPaths.set(fixes[i].seq, {
toSeq: fixes[i + 1].seq,
positions: [
Cesium.Cartesian3.fromDegrees(fixes[i].lon, fixes[i].lat, 20),
Cesium.Cartesian3.fromDegrees(
geo.cartesian3.fromDegrees(fixes[i].lon, fixes[i].lat, 20),
geo.cartesian3.fromDegrees(
fixes[i + 1].lon,
fixes[i + 1].lat,
20,
@@ -139,8 +139,8 @@ export function createTesting({ state, parts }) {
entry.displayPaths.set(fixes[i].seq, {
toSeq: fixes[i + 1].seq,
positions: [
Cesium.Cartesian3.fromDegrees(fixes[i].lon, fixes[i].lat, 20),
Cesium.Cartesian3.fromDegrees(
geo.cartesian3.fromDegrees(fixes[i].lon, fixes[i].lat, 20),
geo.cartesian3.fromDegrees(
fixes[i + 1].lon,
fixes[i + 1].lat,
20,
@@ -161,12 +161,12 @@ export function createTesting({ state, parts }) {
/** Measure inside Cesium postRender with fixed typed storage; no sample objects per frame. */
_measureTransitFramesForTest(durationMs = 12000) {
stopProbe?.();
const scene = state._viewer.scene;
const probe = parts.renderer.frameProbe();
const capacity = 20000,
stride = 12,
data = new Float64Array(capacity * stride);
const previousPositions = new Float64Array(state._vehicles.size * 3);
const gl = scene.context._gl;
const gl = probe.gl;
let active = false,
upload = 0,
cpu = 0,
@@ -195,9 +195,9 @@ export function createTesting({ state, parts }) {
gl[name] = original;
});
}
for (const collection of [state._markers, state._animatedMarkers]) {
const original = collection.update;
collection.update = function (frame) {
for (const { target, method } of probe.uploads) {
const original = target[method];
target[method] = function (frame) {
active = true;
try {
return original.call(this, frame);
@@ -206,11 +206,11 @@ export function createTesting({ state, parts }) {
}
};
saved.push(() => {
collection.update = original;
target[method] = original;
});
}
state._preRenderRemove?.();
const removePre = scene.preRender.addEventListener(() => {
const removePre = parts.renderer.onPreRender(() => {
upload = 0;
const start = performance.now();
parts.rendering.onPreRender();
@@ -224,7 +224,7 @@ export function createTesting({ state, parts }) {
removePost?.();
for (const restore of saved) restore();
if (state._enabled)
state._preRenderRemove = scene.preRender.addEventListener(
state._preRenderRemove = parts.renderer.onPreRender(
parts.rendering.onPreRender,
);
stopProbe = null;
@@ -247,12 +247,12 @@ export function createTesting({ state, parts }) {
resolve({
rows,
dpr: devicePixelRatio,
width: scene.canvas.clientWidth,
height: scene.canvas.clientHeight,
width: probe.canvas.clientWidth,
height: probe.canvas.clientHeight,
});
};
stopProbe = finish;
removePost = scene.postRender.addEventListener(() => {
removePost = parts.renderer.onPostRender(() => {
const now = performance.now();
if (count >= capacity) return;
const k = count++ * stride;
+2 -3
View File
@@ -6,7 +6,6 @@ import {
FIX_FLAGS,
mergeHistory,
} from '../../data/contactPlayback.js';
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
import { fixDistanceM } from './movement.js';
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
@@ -33,8 +32,8 @@ export function createTrails({ state, services, parts, source }) {
fadeTimer = null,
revision = 0;
function ensureRenderer() {
if (!renderer && state._viewer)
renderer = createContactTrailRenderer(state._viewer.scene);
if (!renderer && parts.renderer?.ready)
renderer = parts.renderer.createTrailRenderer();
return renderer;
}
function style() {
+12 -14
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import {
ACTIVATION_ENTER_ALTITUDE_M,
@@ -18,23 +17,25 @@ import { transitFeedsInRange } from '../../data/transitFeeds.js';
export function createViewport({ state, services, parts }) {
const { governorRequestRender } = services.render;
function getCameraAltitude(viewer = state._viewer) {
const carto = viewer?.camera?.positionCartographic;
function getCameraAltitude() {
const carto = parts.renderer?.cameraCartographic();
return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
}
function getCameraCenterLatLon(viewer = state._viewer) {
const rect = viewer?.camera?.computeViewRectangle?.(
viewer.scene.globe?.ellipsoid,
);
function getCameraCenterLatLon() {
const rect = parts.renderer?.viewRectangle();
if (rect) {
const center = Cesium.Rectangle.center(rect);
const east = rect.east < rect.west ? rect.east + Math.PI * 2 : rect.east;
const center = {
latitude: (rect.south + rect.north) / 2,
longitude: geo.math.negativePiToPi((rect.west + east) / 2),
};
return {
lat: geo.math.toDegrees(center.latitude),
lon: geo.math.toDegrees(center.longitude),
};
}
const carto = viewer?.camera?.positionCartographic;
const carto = parts.renderer?.cameraCartographic();
if (carto) {
return {
lat: geo.math.toDegrees(carto.latitude),
@@ -54,10 +55,7 @@ export function createViewport({ state, services, parts }) {
* animating. Cheap, and only on a settled camera — never per frame.
*/
function refreshViewBounds() {
const viewer = state._viewer;
const rect = viewer?.camera?.computeViewRectangle?.(
viewer.scene.globe?.ellipsoid,
);
const rect = parts.renderer?.viewRectangle();
state._viewBounds = rect
? inflateViewBounds({
south: geo.math.toDegrees(rect.south),
@@ -76,7 +74,7 @@ export function createViewport({ state, services, parts }) {
}
function runProximityCheck() {
if (!state._enabled || !state._viewer) return;
if (!state._enabled || !parts.renderer?.ready) return;
refreshViewBounds();
const altitude = getCameraAltitude();
state._altitudeGateOpen = altitudeGateOpen(altitude);