From b8560e5b26e2793495982ff5a4b1ed206bfe60c5 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:12:18 +0400 Subject: [PATCH] refactor(transit): isolate native rendering from playback --- scripts/format-scope.json | 2 + scripts/package-boundaries.json | 12 +- scripts/qa-transit-browser.mjs | 10 + scripts/qa-transit-heading.mjs | 11 +- scripts/qa-transit-recovery.mjs | 9 +- scripts/qa-transit.mjs | 14 +- src/app/layers/transit.js | 12 +- src/data/transit.js | 11 +- src/layers/transit/height.js | 28 +-- src/layers/transit/index.js | 3 +- src/layers/transit/ingestion.js | 4 +- src/layers/transit/lifecycle.js | 98 +++++----- src/layers/transit/lifecycle.test.mjs | 19 +- src/layers/transit/render/cesium.js | 216 ++++++++++++++++++++++ src/layers/transit/render/cesium.test.mjs | 58 ++++++ src/layers/transit/rendering.js | 69 ++----- src/layers/transit/selection.js | 81 +------- src/layers/transit/state.js | 1 + src/layers/transit/testing.js | 32 ++-- src/layers/transit/trails.js | 5 +- src/layers/transit/viewport.js | 26 ++- 21 files changed, 465 insertions(+), 256 deletions(-) create mode 100644 src/layers/transit/render/cesium.js create mode 100644 src/layers/transit/render/cesium.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f29f8662e..cda156d71 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a03e9eecf..50315291d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] }, diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index eb7b59985..b70f0f27a 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -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); diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs index a16bab9bc..ffd0b0616 100644 --- a/scripts/qa-transit-heading.mjs +++ b/scripts/qa-transit-heading.mjs @@ -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`, diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index fbf53cf54..4e642db66 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -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)); diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index ef2e208c4..6e83a60d8 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -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' }); diff --git a/src/app/layers/transit.js b/src/app/layers/transit.js index 367fc425e..9a204aabd 100644 --- a/src/app/layers/transit.js +++ b/src/app/layers/transit.js @@ -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, diff --git a/src/data/transit.js b/src/data/transit.js index 1d33d4ae7..f31b3a32e 100644 --- a/src/data/transit.js +++ b/src/data/transit.js @@ -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; diff --git a/src/layers/transit/height.js b/src/layers/transit/height.js index 2e2945a88..ea0b0d927 100644 --- a/src/layers/transit/height.js +++ b/src/layers/transit/height.js @@ -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(), }); diff --git a/src/layers/transit/index.js b/src/layers/transit/index.js index 171e0fb82..0e3657479 100644 --- a/src/layers/transit/index.js +++ b/src/layers/transit/index.js @@ -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); diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index cb610267e..6ed4ec2da 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -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 diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 11b670519..669d4c293 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -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(); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 5536d4e9e..1b6de4fcd 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -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]; diff --git a/src/layers/transit/render/cesium.js b/src/layers/transit/render/cesium.js new file mode 100644 index 000000000..2cd3ff05d --- /dev/null +++ b/src/layers/transit/render/cesium.js @@ -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; + }, + }; +} diff --git a/src/layers/transit/render/cesium.test.mjs b/src/layers/transit/render/cesium.test.mjs new file mode 100644 index 000000000..139a76fc1 --- /dev/null +++ b/src/layers/transit/render/cesium.test.mjs @@ -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(); +}); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index a871c8d14..f4d7ed016 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -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; diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index cd7d33968..07c7feed5 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -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); } diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 3f9b77243..9f0313908 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -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. */ diff --git a/src/layers/transit/testing.js b/src/layers/transit/testing.js index 06bd315a1..0eca6d5ad 100644 --- a/src/layers/transit/testing.js +++ b/src/layers/transit/testing.js @@ -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; diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index 50130954c..43fa53d0d 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -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() { diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index dac806d12..e2712cf81 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -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);