diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index d7bf0d881..3c4d6e4b3 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -163,6 +163,18 @@ a Cesium throw for missing depth-texture support, and every caller treats it as one. Cesium answers `now()` synchronously from the scene, so it costs what the direct call did. +## Cesium internals + +Core reaches private Cesium fields only through +`src/render/cesium/internals.js`, one named helper per field (the main and +offscreen pick views, a billboard's collection, a cluster's point collection, +the widget's render-loop latch, the WebGL context and whether a camera flight +is under way). `internals.test.mjs` fails when the installed Cesium stops +declaring one of them, and when a private field is reached anywhere else. +Imagery that rations its tile requests states the policy as a hook with +`withTileRequestHook(provider, { requestImage, credit })` from +`src/render/cesium/tileRequests.js`, which leaves the provider untouched. + ## Standalone wiring `src/main.js` reads the existing browser configuration and starts diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c125ff31c..81892edba 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1414,7 +1414,7 @@ and a Denver coordinate underground. Search now resolves the application's ground-floor elevation (bounded wait, same ellipsoidal datum) before framing and warms the cell; after landing, `src/cameraGroundGuard.js` samples the rendered surface under the target and the camera once tiles stream in and lifts the eye -to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls and direct camera flights), layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners. `scripts/qa-context-arrivals.mjs` types Camp Mabry +to 120 m clearance, holding heading and pitch. Navigation ownership changes (Director, tracking, keyboard/UI controls), any other caller moving the camera or starting a flight before a retry, layer/app teardown, and pointer/wheel gestures cancel the check and remove its listeners; the guard compares the pose it started on rather than wrapping camera methods. `scripts/qa-context-arrivals.mjs` types Camp Mabry and a Denver coordinate and requires at least 60 m clearance. ## Layer panel ownership @@ -2396,9 +2396,9 @@ which embeds the MAP_KEY. > so a turned-off layer's entities stayed reachable (Data Centers kept 116 > MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`, > requests one frame through `governorRequestRender` after every layer -> disable and then trims those arrays. It reads private Cesium fields; -> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames -> them or changes which passes it truncates. +> disable and then trims those arrays. It reads private Cesium fields through +> `src/render/cesium/internals.js`; `src/sceneCommandRelease.test.mjs` pins +> them and fails if Cesium renames them or changes which passes it truncates. > **2026-08-08 — performance waves 1+2:** the app idles via an explicit render > governor (`src/renderGovernor.js` — hold/release from every per-frame diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2bff60a1c..15a0305ef 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -48,6 +48,10 @@ "src/input/sensorPick.js", "src/input/dispatcher.js", "src/queries/heights.js", + "src/render/cesium/internals.js", + "src/render/cesium/internals.test.mjs", + "src/render/cesium/tileRequests.js", + "src/render/cesium/tileRequests.test.mjs", "src/queries/heights.test.mjs", "docs/CODE-BOUNDARIES.md", "src/main.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 481cba9c5..9c2c253bd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1370,7 +1370,8 @@ "src/input/sensorPick.js", "src/data/scenePick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/internals.js" ], "external": ["cesium"] }, @@ -2146,7 +2147,8 @@ "src/data/scenePick.js", "src/input/sensorPick.js", "src/data/pickRegistry.js", - "src/input/dispatcher.js" + "src/input/dispatcher.js", + "src/render/cesium/internals.js" ], "external": ["cesium"] }, @@ -3333,7 +3335,9 @@ "src/data/entityRecords.js", "src/input/sensorPick.js", "src/input/dispatcher.js", - "src/queries/heights.js" + "src/queries/heights.js", + "src/render/cesium/internals.js", + "src/render/cesium/tileRequests.js" ], "external": [ "hls.js", @@ -3959,7 +3963,9 @@ "src/data/entityRecords.js", "src/input/sensorPick.js", "src/input/dispatcher.js", - "src/queries/heights.js" + "src/queries/heights.js", + "src/render/cesium/tileRequests.js", + "src/render/cesium/internals.js" ], "external": [ "hls.js", @@ -4047,7 +4053,8 @@ "src/data/placeNames.js", "src/data/local_data/us_census_places/files.js", "src/data/local_data/wof_neighborhoods/files.js", - "src/queries/heights.js" + "src/queries/heights.js", + "src/render/cesium/internals.js" ], "external": [ "cesium", @@ -4656,7 +4663,8 @@ "src/i18n/locales/zh-Hant.json", "src/input/sensorPick.js", "src/input/dispatcher.js", - "src/queries/heights.js" + "src/queries/heights.js", + "src/render/cesium/internals.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -4713,7 +4721,8 @@ "src/data/scenePick.js", "src/data/pickRegistry.js", "src/input/dispatcher.js", - "src/queries/heights.js" + "src/queries/heights.js", + "src/render/cesium/internals.js" ], "external": ["cesium", "pbf"] }, diff --git a/src/cameraGroundGuard.js b/src/cameraGroundGuard.js index 65dd607c2..6ad64622b 100644 --- a/src/cameraGroundGuard.js +++ b/src/cameraGroundGuard.js @@ -17,6 +17,7 @@ import * as Cesium from 'cesium'; import { ownCameraArrival } from './data/cameraArrival.js'; import { heightsFor } from './queries/heights.js'; +import { cameraFlightActive } from './render/cesium/internals.js'; /** Minimum eye height above the rendered surface for a usable view. */ export const MIN_EYE_CLEARANCE_M = 120; @@ -25,6 +26,11 @@ const GUARD_ATTEMPTS = 8; const GUARD_INTERVAL_MS = 600; /** Ignore sub-metre noise rather than nudging the camera forever. */ const GUARD_EPSILON_M = 2; +/** Eye travel (m) and turn (cosine) that mean another owner moved the camera. */ +const MOVED_M = 0.01; +const TURNED_COS = 1 - 1e-9; + +const groundScratch = new Cesium.Cartesian3(); const probeScratch = new Cesium.Cartographic(); @@ -52,6 +58,46 @@ function sampleSurfaceM(scene, cells) { return { heightM, sampled }; } +/** The eye's ground point (height 0), its height, and which way it looks. */ +function poseOf(camera) { + const carto = + camera?.positionWC && Cesium.Cartographic.fromCartesian(camera.positionWC); + if (!carto || !camera.directionWC || !camera.upWC) return null; + return { + ground: Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude), + height: carto.height, + direction: Cesium.Cartesian3.clone(camera.directionWC), + up: Cesium.Cartesian3.clone(camera.upWC), + }; +} + +/** + * Whether another owner has moved the camera off `pose`: across the ground, + * down, or around. A pure rise is not one: Cesium's terrain collision lifts a + * buried eye straight up as tiles load, which is the case the guard exists + * for, so `pose` follows the highest eye seen and the guard keeps measuring. + */ +function moved(camera, pose) { + const carto = Cesium.Cartographic.fromCartesian(camera.positionWC); + if (!carto) return true; + const ground = Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + 0, + undefined, + groundScratch, + ); + if ( + Cesium.Cartesian3.distance(ground, pose.ground) > MOVED_M || + carto.height < pose.height - MOVED_M || + Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < TURNED_COS || + Cesium.Cartesian3.dot(camera.upWC, pose.up) < TURNED_COS + ) + return true; + pose.height = Math.max(pose.height, carto.height); + return false; +} + /** * How far the camera must rise to clear the ground, given a measured surface. * Pure so the policy is testable without a scene. @@ -75,8 +121,10 @@ export function groundClearanceDeficitM( * Watch an arrival and lift the camera once the real surface can be measured. * * Stops quietly on a clear view, exhausted attempts, the user taking the - * controls (pointer or wheel on the canvas), or `isStale()` reporting that a - * newer arrival owns the camera. + * controls (pointer or wheel on the canvas), `isStale()` reporting that a + * newer arrival owns the camera, or any other caller moving the camera or + * starting a flight in the meantime: the guard only ever corrects the pose it + * was started on. * @param {Cesium.Viewer} viewer Live viewer. * @param {{lat: number, lon: number}} at Destination being framed. * @param {{clearanceM?: number, attempts?: number, intervalMs?: number, @@ -94,6 +142,10 @@ export function guardCameraAboveGround(viewer, at, options = {}) { if (!viewer?.scene || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon)) return () => {}; + // Without a readable pose the guard could not tell a later owner's move. + const arrival = poseOf(viewer.camera); + if (!arrival) return () => {}; + let timer = null; let done = false; const canvas = viewer.scene.canvas; @@ -111,30 +163,6 @@ export function guardCameraAboveGround(viewer, at, options = {}) { release(); }; release = ownCameraArrival(viewer, stop); - // Direct camera API callers (including duration-zero flights) must yield - // before moving the eye. Restore the exact method ownership on completion. - for (const name of [ - 'flyTo', - 'flyToBoundingSphere', - 'setView', - 'lookAt', - 'cancelFlight', - ]) { - const camera = viewer.camera; - const original = camera[name]; - if (typeof original !== 'function') continue; - const descriptor = Object.getOwnPropertyDescriptor(camera, name); - const wrapped = function (...args) { - stop(); - return original.apply(this, args); - }; - camera[name] = wrapped; - removers.push(() => { - if (camera[name] !== wrapped) return; - if (descriptor) Object.defineProperty(camera, name, descriptor); - else delete camera[name]; - }); - } const removeTracking = viewer.trackedEntityChanged?.addEventListener(stop); if (removeTracking) removers.push(removeTracking); documentRef?.addEventListener?.('keydown', stop, true); @@ -146,8 +174,10 @@ export function guardCameraAboveGround(viewer, at, options = {}) { const attempt = () => { if (done) return; if (isStale() || viewer.isDestroyed?.()) return stop(); - remaining -= 1; const camera = viewer.camera; + // Another caller owns the camera now; lifting would undo its move. + if (cameraFlightActive(camera) || moved(camera, arrival)) return stop(); + remaining -= 1; const carto = camera.positionCartographic; // The eye must clear both the framed target and the ground beneath it. const below = { diff --git a/src/cameraGroundGuard.test.mjs b/src/cameraGroundGuard.test.mjs index 5c8f53030..7303cbad6 100644 --- a/src/cameraGroundGuard.test.mjs +++ b/src/cameraGroundGuard.test.mjs @@ -59,10 +59,17 @@ function fakeViewer({ cameraHeightM, surfaceM }) { }, camera: { positionCartographic: position, + positionWC: Cesium.Cartographic.toCartesian(position), + directionWC: new Cesium.Cartesian3(0, 0, -1), + upWC: new Cesium.Cartesian3(0, 1, 0), heading: 0.5, pitch: -0.55, roll: 0, - flyTo: (options) => flights.push(options), + // As Cesium does: a flight is under way until it completes. + flyTo(options) { + flights.push(options); + this._currentFlight = {}; + }, }, }; } @@ -122,8 +129,16 @@ function handoffFixture(t) { viewer.trackedEntityChanged = new Cesium.Event(); viewer.camera.cancelFlight = () => {}; viewer.camera.lookAtTransform = () => {}; - viewer.camera.flyToBoundingSphere = () => {}; - viewer.camera.setView = () => {}; + viewer.camera.flyToBoundingSphere = function () { + this._currentFlight = {}; + }; + viewer.camera.setView = function () { + this.positionWC = Cesium.Cartesian3.add( + this.positionWC, + new Cesium.Cartesian3(0, 0, 50), + new Cesium.Cartesian3(), + ); + }; const originalFly = viewer.camera.flyTo; const navigation = new NavigationController({ viewer, @@ -142,6 +157,8 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI', 'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) { test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => { const { viewer, navigation, originalFly } = handoffFixture(t); + const methods = ['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'] + .map((name) => viewer.camera[name]); if (handoff === 'Director') { SceneDirector.prototype._claimCameraOwnership.call({ viewer, styleManager: { runImmediateNavigation: (noun, fn) => @@ -161,11 +178,50 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI', if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false); else await lifecycle.destroyLayer('test'); } else viewer.camera[handoff]({}); + // A direct flyTo is itself one recorded flight; the guard adds none. const priorFlights = viewer.flights.length; t.mock.timers.tick(10000); assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly'); - assert.equal(viewer.camera.flyTo, originalFly, 'direct-call hook is removed'); + assert.equal(viewer.camera.flyTo, originalFly); + assert.deepEqual( + ['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'] + .map((name) => viewer.camera[name]), + methods, + 'the guard never replaces camera methods', + ); assert.equal(viewer.trackedEntityChanged.numberOfListeners, 0); navigation.destroy(); }); } + +test('the guard yields to a camera moved without any camera method', async () => { + const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 }); + guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 }); + // A cockpit or follow frame writes the pose directly. + viewer.camera.directionWC = new Cesium.Cartesian3(1, 0, 0); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(viewer.flights.length, 0, 'the moved view is not corrected'); +}); + +test('Cesium lifting a buried eye straight up is not another owner', async () => { + const viewer = fakeViewer({ cameraHeightM: 104, surfaceM: 171 }); + guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 }); + // Terrain collision raises the eye to just above the loaded ground. + const raised = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 172); + viewer.camera.positionCartographic = raised; + viewer.camera.positionWC = Cesium.Cartographic.toCartesian(raised); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(viewer.flights.length, 1, 'the guard still lifts the eye'); + const lifted = Cesium.Cartographic.fromCartesian(viewer.flights[0].destination); + assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5); +}); + +test('another owner lowering the eye is not corrected', async () => { + const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 }); + guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 }); + const lowered = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 150); + viewer.camera.positionCartographic = lowered; + viewer.camera.positionWC = Cesium.Cartographic.toCartesian(lowered); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(viewer.flights.length, 0); +}); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index e74577ac6..83cbdf726 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -1,6 +1,7 @@ import { approxDistanceKm as _approxDistanceKm } from './recordPolicy.js'; import { displayedKinematics } from '../../data/motionModel.js'; import * as Cesium from 'cesium'; +import { closeRenderLoopLatch } from '../../render/cesium/internals.js'; import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; import { modelVisualAnchor } from '../../data/modelVisualAnchor.js'; import { @@ -922,8 +923,7 @@ export function createQueries({ // while manual evidence renders can begin without waiting on VSYNC. return new Promise((resolve) => setTimeout(() => { - if (flightState._viewer?._cesiumWidget) - flightState._viewer._cesiumWidget._renderLoopRunning = false; + closeRenderLoopLatch(flightState._viewer); resolve({ ok: true, nowMs: startMs }); }, 0), ); diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js index d38c79d78..bd82dba3d 100644 --- a/src/layers/radio/rendering.js +++ b/src/layers/radio/rendering.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { clusterPointCollection as clusterPointsOf } from '../../render/cesium/internals.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; import { MARKER_LIFT_M, @@ -81,7 +82,7 @@ export function createRendering({ } function clusterPointCollection() { - return layerState._dataSource?.clustering?._clusterPointCollection || null; + return clusterPointsOf(layerState._dataSource?.clustering); } function publishRadioOverlayEntries() { diff --git a/src/layers/recentImagery/rendering.js b/src/layers/recentImagery/rendering.js index b241d0f52..316e07345 100644 --- a/src/layers/recentImagery/rendering.js +++ b/src/layers/recentImagery/rendering.js @@ -7,6 +7,7 @@ * small semaphore of our own; Cesium's global request limits stay untouched. */ import * as Cesium from 'cesium'; +import { withTileRequestHook } from '../../render/cesium/tileRequests.js'; import { PRODUCTS, gibsTemplate } from './model.js'; const SLOT_IDS = ['a', 'b']; @@ -27,7 +28,7 @@ const boxKey = (box) => [box.west, box.south, box.east, box.north].map(Number).join(','); /** - * Gate a provider's `requestImage` through the semaphore. Returning + * Gate a provider's tile requests through the semaphore. Returning * `undefined` tells Cesium to retry on a later frame, but under * `requestRenderMode` there is no later frame unless something asks — and a * deferral must never ask for one itself: the retried tile would defer again @@ -37,31 +38,31 @@ const boxKey = (box) => * deferred goes through the one coalesced delayed retry. */ function admitTileRequests(provider, admission, requestRender, retry) { - const original = provider.requestImage.bind(provider); - provider.requestImage = (x, y, level, request) => { - if (admission.inFlight >= admission.limit) return undefined; - admission.inFlight += 1; - const release = () => { - admission.inFlight -= 1; - }; - let promise; - try { - promise = original(x, y, level, request); - } catch (error) { - release(); - throw error; - } - if (!promise) { - release(); - retry.schedule(); - return undefined; - } - return Promise.resolve(promise).finally(() => { - release(); - requestRender(TILE_RENDER_REASON); - }); - }; - return provider; + return withTileRequestHook(provider, { + requestImage: (_tile, issue) => { + if (admission.inFlight >= admission.limit) return undefined; + admission.inFlight += 1; + const release = () => { + admission.inFlight -= 1; + }; + let promise; + try { + promise = issue(); + } catch (error) { + release(); + throw error; + } + if (!promise) { + release(); + retry.schedule(); + return undefined; + } + return Promise.resolve(promise).finally(() => { + release(); + requestRender(TILE_RENDER_REASON); + }); + }, + }); } /** diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index d4223c8d3..e018bf3b6 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { billboardCollectionOf } from '../../render/cesium/internals.js'; import { SELECTED_CARD_REFRESH_MS, TRANSIT_SELECTED_OVERLAY_SOURCE_ID, @@ -130,9 +131,9 @@ export function createSelection({ state, services, parts }) { x: position.x, y: position.y, collection: - picked?.primitive?._billboardCollection === state._animatedMarkers + billboardCollectionOf(picked?.primitive) === state._animatedMarkers ? 'animated' - : picked?.primitive?._billboardCollection === state._markers + : billboardCollectionOf(picked?.primitive) === state._markers ? 'stationary' : null, }; diff --git a/src/layers/transit/testing.js b/src/layers/transit/testing.js index 06bd315a1..bc39ecc72 100644 --- a/src/layers/transit/testing.js +++ b/src/layers/transit/testing.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { webglContext } from '../../render/cesium/internals.js'; import { recordFix, updatePlayback, attachFloorToPlace } from './movement.js'; import { seek, setRate, mergeHistory } from '../../data/contactPlayback.js'; import { getRegisteredTransitFeed } from '../../data/transitFeeds.js'; @@ -166,7 +167,7 @@ export function createTesting({ state, parts }) { stride = 12, data = new Float64Array(capacity * stride); const previousPositions = new Float64Array(state._vehicles.size * 3); - const gl = scene.context._gl; + const gl = webglContext(scene); let active = false, upload = 0, cpu = 0, diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index 629101693..9dc7deab0 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -2,6 +2,7 @@ import { weatherTileUrl } from './source.js'; import { orderWeatherImagery } from './imageryOrder.js'; import { imageryHostStatus } from './imageryHost.js'; import { createRasterTileProvider } from './rasterTiles.js'; +import { withTileRequestHook } from '../../render/cesium/tileRequests.js'; import { createWeatherShell } from './shellRendering.js'; import { weatherDisplay, escapeCreditText } from './display.js'; import { msg } from '../../i18n/index.js'; @@ -385,7 +386,7 @@ function createGlobeRendering({ ); setCredit(frame); const credit = frame.credit; - const provider = global + const source = global ? createRasterTileProvider({ cesium, texture, @@ -408,49 +409,46 @@ function createGlobeRendering({ enablePickFeatures: false, credit, }); - // Cesium reads a provider's credit every frame; a refreshed manifest - // can replace it without restaging the imagery. - Object.defineProperty(provider, 'credit', { - configurable: true, - enumerable: true, - get: () => frame.credit, - }); - const requestImage = provider.requestImage.bind(provider); - provider.requestImage = (x, y, level, request) => { - if (frame.closed) return undefined; - const result = requestImage(x, y, level, request); - const tileKey = `${level}/${x}/${y}`; - if (!result) { - // Scheduler admission is part of readiness, not a successful tile. - frame.deferred.add(tileKey); - frame.lastActivity = now(); - return result; - } - frame.deferred.delete(tileKey); - frame.pending++; - frame.lastActivity = now(); - if (request) frame.requests.add(request); - return Promise.resolve(result) - .then((image) => { - // Cesium decodes tiles as ImageBitmaps already flipped, since WebGL - // ignores UNPACK_FLIP_Y for them; a canvas upload flips again. - if (!frame.closed && snapshot.product === 'clouds-regional') - image = processInfraredImage(image, infrared, createCanvas, { - flipY: - typeof ImageBitmap !== 'undefined' && - image instanceof ImageBitmap, - }); - frame.retries.delete(tileKey); - frame.loaded++; - return image; - }) - .finally(() => { - frame.pending--; + const provider = withTileRequestHook(source, { + // Cesium reads a provider's credit every frame; a refreshed manifest + // can replace it without restaging the imagery. + credit: () => frame.credit, + requestImage: ({ x, y, level, request }, issue) => { + if (frame.closed) return undefined; + const result = issue(); + const tileKey = `${level}/${x}/${y}`; + if (!result) { + // Scheduler admission is part of readiness, not a successful tile. + frame.deferred.add(tileKey); frame.lastActivity = now(); - frame.requests.delete(request); - if (!frame.closed) viewer.scene.requestRender(); - }); - }; + return result; + } + frame.deferred.delete(tileKey); + frame.pending++; + frame.lastActivity = now(); + if (request) frame.requests.add(request); + return Promise.resolve(result) + .then((image) => { + // Cesium decodes tiles as ImageBitmaps already flipped, since WebGL + // ignores UNPACK_FLIP_Y for them; a canvas upload flips again. + if (!frame.closed && snapshot.product === 'clouds-regional') + image = processInfraredImage(image, infrared, createCanvas, { + flipY: + typeof ImageBitmap !== 'undefined' && + image instanceof ImageBitmap, + }); + frame.retries.delete(tileKey); + frame.loaded++; + return image; + }) + .finally(() => { + frame.pending--; + frame.lastActivity = now(); + frame.requests.delete(request); + if (!frame.closed) viewer.scene.requestRender(); + }); + }, + }); frame.offError = provider.errorEvent.addEventListener((error) => { if (frame.closed) return; const status = error?.error?.statusCode; diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index a42485ed0..f65298e04 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -100,16 +100,26 @@ function renderingHarness(options = {}) { const providers = []; const postRender = event(); let renderRequests = 0; + // `providers` lists what Cesium is handed, in order. The layer may hand it + // a hook over this provider, so per-test state lives in one box both see. class Provider { constructor(options) { this.options = options; this.tilingScheme = new GeographicTilingScheme(); this.errorEvent = event(); - this.response = null; - providers.push(this); + this.box = { response: null, requestArgs: undefined }; + } + get response() { + return this.box.response; + } + set response(value) { + this.box.response = value; + } + get requestArgs() { + return this.box.requestArgs; } requestImage(...args) { - this.requestArgs = args; + this.box.requestArgs = args; return this.response?.promise; } } @@ -2345,7 +2355,7 @@ for (const product of ['radar', 'clouds-regional', 'lightning']) { positionCartographic: { height: 60_000 }, }; const pending = h.rendering.setFrame({ ...snapshot, product }, times[0]); - const options = h.providers[0].options; + const options = collection.get(0).imageryProvider.options; assert.equal(options.tileWidth, 256); assert.equal(options.tileHeight, 256); assert.equal(options.maximumLevel, 6); diff --git a/src/render/cesium/internals.js b/src/render/cesium/internals.js new file mode 100644 index 000000000..dd4ff2d0b --- /dev/null +++ b/src/render/cesium/internals.js @@ -0,0 +1,86 @@ +/** + * @module render/cesium/internals + * @description Every read or write of a private Cesium field, in one place. + * + * Core otherwise uses Cesium's public API. Each helper here names one private + * field Core depends on and says why; `internals.test.mjs` fails when the + * installed Cesium no longer declares one of them, and a source scan fails + * when a private field is reached anywhere else. + */ + +/** + * The scene's main view, whose per-pass command arrays outlive a frame. + * Used by `sceneCommandRelease.js` to drop references to destroyed commands. + * @param {object} scene Cesium Scene. + * @returns {object|undefined} Cesium `View`. + */ +export function defaultView(scene) { + return scene?._defaultView; +} + +/** + * The offscreen view `sampleHeight` and `pickFromRay` render into. + * @param {object} scene Cesium Scene. + * @returns {object|undefined} Cesium `View`. + */ +export function pickOffscreenView(scene) { + return scene?._picking?._pickOffscreenView; +} + +/** + * A view's command-extent records, each holding the command it last drew. + * @param {object} view Cesium `View`. + * @returns {Array<{command: object}>} + */ +export function commandExtents(view) { + return view?._commandExtents || []; +} + +/** + * The collection a picked billboard belongs to; Cesium's pick result names + * the billboard, not its collection. + * @param {object} billboard Cesium Billboard. + * @returns {object|undefined} Cesium BillboardCollection. + */ +export function billboardCollectionOf(billboard) { + return billboard?._billboardCollection; +} + +/** + * The point collection an `EntityCluster` draws its cluster markers into. + * @param {object} clustering Cesium EntityCluster. + * @returns {object|null} Cesium PointPrimitiveCollection. + */ +export function clusterPointCollection(clustering) { + return clustering?._clusterPointCollection || null; +} + +/** + * Close the widget's pending render-loop latch after `useDefaultRenderLoop` + * is set false, so a callback already queued sees the loop as stopped and + * manual renders start without waiting a vsync. + * @param {object} viewer Cesium Viewer. + */ +export function closeRenderLoopLatch(viewer) { + const widget = viewer?._cesiumWidget; + if (widget) widget._renderLoopRunning = false; +} + +/** + * The scene's WebGL context, for diagnostics that read the GPU directly. + * @param {object} scene Cesium Scene. + * @returns {WebGLRenderingContext|WebGL2RenderingContext|undefined} + */ +export function webglContext(scene) { + return scene?.context?._gl; +} + +/** + * Whether a camera flight is under way. Cesium clears it before calling a + * flight's `complete`, so it reads false inside that callback. + * @param {object} camera Cesium Camera. + * @returns {boolean} + */ +export function cameraFlightActive(camera) { + return camera?._currentFlight != null; +} diff --git a/src/render/cesium/internals.test.mjs b/src/render/cesium/internals.test.mjs new file mode 100644 index 000000000..6331163c0 --- /dev/null +++ b/src/render/cesium/internals.test.mjs @@ -0,0 +1,140 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { + billboardCollectionOf, + cameraFlightActive, + closeRenderLoopLatch, + clusterPointCollection, + commandExtents, + defaultView, + pickOffscreenView, + webglContext, +} from './internals.js'; + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', +); +const require = createRequire(import.meta.url); +const packageSource = (name) => + path.join(path.dirname(require.resolve(`${name}/package.json`)), 'Source'); +const ENGINE = packageSource('@cesium/engine'); +const WIDGETS = packageSource('@cesium/widgets'); + +test('every private field Core reaches is still declared by the installed Cesium', () => { + const fields = [ + [ENGINE, 'Scene/Scene.js', '_defaultView'], + [ENGINE, 'Scene/Scene.js', '_picking'], + [ENGINE, 'Scene/Picking.js', '_pickOffscreenView'], + [ENGINE, 'Scene/View.js', '_commandExtents'], + [ENGINE, 'Scene/Billboard.js', '_billboardCollection'], + [ENGINE, 'DataSources/EntityCluster.js', '_clusterPointCollection'], + [ENGINE, 'Widget/CesiumWidget.js', '_renderLoopRunning'], + [WIDGETS, 'Viewer/Viewer.js', '_cesiumWidget'], + [ENGINE, 'Renderer/Context.js', '_gl'], + [ENGINE, 'Scene/Camera.js', '_currentFlight'], + ]; + const missing = fields.filter( + ([root, file, field]) => + !new RegExp(`this\\.${field}\\s*=`).test( + fs.readFileSync(path.join(root, file), 'utf8'), + ), + ); + assert.deepEqual(missing, []); +}); + +test('the helpers read and write exactly those fields', () => { + const view = { _commandExtents: [{ command: {} }] }; + assert.equal(defaultView({ _defaultView: view }), view); + assert.equal( + pickOffscreenView({ _picking: { _pickOffscreenView: view } }), + view, + ); + assert.equal(commandExtents(view), view._commandExtents); + assert.deepEqual(commandExtents(undefined), []); + const collection = {}; + assert.equal( + billboardCollectionOf({ _billboardCollection: collection }), + collection, + ); + assert.equal( + clusterPointCollection({ _clusterPointCollection: collection }), + collection, + ); + assert.equal(clusterPointCollection(undefined), null); + const widget = { _renderLoopRunning: true }; + closeRenderLoopLatch({ _cesiumWidget: widget }); + assert.equal(widget._renderLoopRunning, false); + closeRenderLoopLatch({}); + const gl = {}; + assert.equal(webglContext({ context: { _gl: gl } }), gl); + assert.equal(cameraFlightActive({ _currentFlight: {} }), true); + assert.equal(cameraFlightActive({ _currentFlight: undefined }), false); +}); + +test('private Cesium fields are reached only through internals.js', () => { + // A private field is one Cesium assigns as `this._name`. Core's own objects + // use underscore fields too, and some share a name with one of Cesium's, + // so a name Core itself defines (on `this`, a state object or as a key) is + // Core's; any other `._name` from Cesium's set is a private Cesium read. + const walk = (dir, filter) => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(full, filter); + return filter(entry.name) ? [full] : []; + }); + const cesiumPrivate = new Set(); + for (const file of [ + ...walk(ENGINE, (name) => name.endsWith('.js')), + ...walk(WIDGETS, (name) => name.endsWith('.js')), + ]) + for (const [, name] of fs + .readFileSync(file, 'utf8') + .matchAll(/this\._([A-Za-z]\w*)/g)) + cesiumPrivate.add(name); + + const INTERNALS = 'src/render/cesium/internals.js'; + const sources = walk(path.join(ROOT, 'src'), (name) => + /\.(js|vue)$/.test(name), + ) + .map((file) => path.relative(ROOT, file)) + .filter((file) => file !== INTERNALS) + .map((file) => [ + file, + fs + .readFileSync(path.join(ROOT, file), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''), + ]); + const coreOwn = new Set(); + const definitions = [ + /\b(?:this|\w*[sS]tate|owner|self|parts|anno|entry|record|rec|cal|label|controller)\._([A-Za-z]\w*)\s*=(?!=)/g, + /^\s*(?:static\s+)?(?:async\s+)?_([A-Za-z]\w*)\s*[(=:;]/gm, + /[{,]\s*_([A-Za-z]\w*)\s*:/g, + ]; + for (const [, code] of sources) + for (const pattern of definitions) + for (const [, name] of code.matchAll(pattern)) coreOwn.add(name); + + // A Cesium object's private field is flagged whatever Core names its own + // fields: on a receiver that is a Cesium object, or by bracket access. + const CESIUM_RECEIVER = + /\b_?(?:viewer|scene|camera|globe|context|frameState|clustering|tileset|imageryProvider|terrainProvider|screenSpaceCameraController|cesiumWidget|imageryLayers|primitives|dataSources|entities|clock)\??\._([A-Za-z]\w*)\b/g; + const BRACKET = /\[\s*['"`]_([A-Za-z]\w*)['"`]\s*\]/g; + const offenders = []; + for (const [file, code] of sources) { + for (const [, name] of code.matchAll(/\._([A-Za-z]\w*)\b/g)) + if (cesiumPrivate.has(name) && !coreOwn.has(name)) + offenders.push(`${file} ._${name}`); + for (const pattern of [CESIUM_RECEIVER, BRACKET]) + for (const [, name] of code.matchAll(pattern)) + if (cesiumPrivate.has(name)) offenders.push(`${file} ._${name}`); + } + assert.deepEqual([...new Set(offenders)], []); +}); diff --git a/src/render/cesium/tileRequests.js b/src/render/cesium/tileRequests.js new file mode 100644 index 000000000..afc8b8b6e --- /dev/null +++ b/src/render/cesium/tileRequests.js @@ -0,0 +1,50 @@ +/** + * @module render/cesium/tileRequests + * @description Admission hooks for an imagery provider's tile requests. + * + * A layer that rations its tiles (a request budget, readiness accounting, + * dropping a frame that is no longer shown) states that policy as a hook + * instead of replacing the provider's `requestImage`. The provider itself is + * left untouched: the returned provider delegates everything to it through + * its prototype and routes only tile requests, and optionally the credit, + * through the hook. + * + * Fields Cesium writes onto a provider land on the returned one, so a + * provider that reads such a field itself (a time-dynamic WMTS provider's + * `_reload`) is not suited; the URL-template and raster providers that use + * hooks today read none. + */ + +/** + * @typedef {object} TileRequest + * @property {number} x + * @property {number} y + * @property {number} level + * @property {object} [request] Cesium `Request`, when Cesium passed one. + */ + +/** + * @param {object} provider A Cesium imagery provider (or one built by Core). + * @param {object} hooks + * @param {(tile: TileRequest, issue: () => Promise|undefined) => Promise|undefined} [hooks.requestImage] + * Decides each request. `issue()` asks the provider; returning `undefined` + * defers the tile, and Cesium asks again on a later frame. + * @param {() => object} [hooks.credit] The credit Cesium shows for the layer, + * read every frame, so it can change without restaging the imagery. + * @returns {object} A provider to hand to `ImageryLayer`. + */ +export function withTileRequestHook(provider, { requestImage, credit } = {}) { + const hooked = Object.create(provider); + if (requestImage) + hooked.requestImage = (x, y, level, request) => + requestImage({ x, y, level, request }, () => + provider.requestImage(x, y, level, request), + ); + if (credit) + Object.defineProperty(hooked, 'credit', { + configurable: true, + enumerable: true, + get: credit, + }); + return hooked; +} diff --git a/src/render/cesium/tileRequests.test.mjs b/src/render/cesium/tileRequests.test.mjs new file mode 100644 index 000000000..96f028426 --- /dev/null +++ b/src/render/cesium/tileRequests.test.mjs @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { withTileRequestHook } from './tileRequests.js'; + +class Provider { + constructor() { + this._tilingScheme = { name: 'geographic' }; + this.calls = []; + } + get tilingScheme() { + return this._tilingScheme; + } + get credit() { + return 'stock'; + } + requestImage(x, y, level, request) { + this.calls.push([x, y, level, request]); + return Promise.resolve({ x, y, level }); + } +} + +test('the hook decides each tile request and issues it on the provider', async () => { + const provider = new Provider(); + const seen = []; + const hooked = withTileRequestHook(provider, { + requestImage: (tile, issue) => { + seen.push(tile); + return tile.level > 3 ? undefined : issue(); + }, + }); + const request = {}; + assert.deepEqual(await hooked.requestImage(1, 2, 3, request), { + x: 1, + y: 2, + level: 3, + }); + assert.equal(hooked.requestImage(1, 2, 4), undefined, 'deferred'); + assert.deepEqual(seen, [ + { x: 1, y: 2, level: 3, request }, + { x: 1, y: 2, level: 4, request: undefined }, + ]); + assert.deepEqual(provider.calls, [[1, 2, 3, request]]); +}); + +test('the provider itself is left untouched and still read through', () => { + const provider = new Provider(); + const own = Object.getOwnPropertyNames(provider); + let credit = 'first'; + const hooked = withTileRequestHook(provider, { + requestImage: (_tile, issue) => issue(), + credit: () => credit, + }); + assert.deepEqual(Object.getOwnPropertyNames(provider), own); + assert.equal(provider.requestImage, Provider.prototype.requestImage); + assert.equal(provider.credit, 'stock'); + assert.equal(hooked.tilingScheme, provider.tilingScheme); + assert.equal(hooked.credit, 'first'); + credit = 'refreshed'; + assert.equal(hooked.credit, 'refreshed', 'read every time'); +}); + +test('without hooks the provider answers as it is', () => { + const provider = new Provider(); + const hooked = withTileRequestHook(provider); + hooked.requestImage(0, 0, 0); + assert.equal(provider.calls.length, 1); + assert.equal(hooked.credit, 'stock'); +}); diff --git a/src/sceneCommandRelease.js b/src/sceneCommandRelease.js index 4d8594401..ba3d76b98 100644 --- a/src/sceneCommandRelease.js +++ b/src/sceneCommandRelease.js @@ -24,6 +24,12 @@ * tail itself this module can go. */ +import { + commandExtents, + defaultView, + pickOffscreenView, +} from './render/cesium/internals.js'; + /** Drop command references a view no longer draws. */ function trimView(view, { keepCurrent }) { for (const frustum of view?.frustumCommandsList || []) { @@ -35,8 +41,7 @@ function trimView(view, { keepCurrent }) { } } if (!keepCurrent) - for (const extent of view?._commandExtents || []) - extent.command = undefined; + for (const extent of commandExtents(view)) extent.command = undefined; } /** @@ -45,8 +50,8 @@ function trimView(view, { keepCurrent }) { * @param {object} scene Cesium Scene, right after a render. */ export function trimStaleSceneCommands(scene) { - trimView(scene._defaultView, { keepCurrent: true }); - trimView(scene._picking?._pickOffscreenView, { keepCurrent: false }); + trimView(defaultView(scene), { keepCurrent: true }); + trimView(pickOffscreenView(scene), { keepCurrent: false }); } /**