From ae13a56f6dc729b73cd1836d5c0e6ce1f585f16a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 22:56:24 +0400 Subject: [PATCH] Load the Cesium SDK and its widget styles only when a Cesium globe first starts --- build/vite.js | 4 +- scripts/package-boundaries.json | 2 + src/annotations/worldAnnotationRenderer.js | 4 +- src/app/loadCesium.js | 52 ++++++++++++++ src/app/loadCesium.test.mjs | 84 ++++++++++++++++++++++ src/app/scene.js | 3 + src/cameraHandoff.test.mjs | 4 +- src/cyberSonarGpu.js | 17 ++--- src/data/bhoteKoshiEvent.js | 14 ++-- src/data/bhoteKoshiLocator.js | 24 +++---- src/data/geoImportPins.js | 18 ++--- src/data/modelVisualAnchor.js | 15 +--- src/data/render/cesiumProjection.js | 9 +-- src/globe/cesium/camera.js | 6 +- src/globe/cesium/frames.js | 3 +- src/globe/cesium/input.js | 26 +++---- src/globe/cesium/movers.js | 15 ++-- src/layers/directions/index.js | 16 ++--- src/layers/firms/render/cesium.js | 3 +- src/layers/gnss/render/cesium.js | 19 +++-- src/layers/streetLevel/view.js | 14 ++-- src/layers/submarineCables/policy.js | 4 +- src/layers/traffic/footprint.js | 8 ++- src/layers/traffic/policy.js | 12 ++-- src/layers/traffic/retention.js | 8 +-- src/layers/traffic/state.js | 3 +- src/maps/catalog.js | 6 +- 27 files changed, 269 insertions(+), 124 deletions(-) create mode 100644 src/app/loadCesium.js create mode 100644 src/app/loadCesium.test.mjs diff --git a/build/vite.js b/build/vite.js index 351139fd3..ac00a403a 100644 --- a/build/vite.js +++ b/build/vite.js @@ -186,7 +186,9 @@ export function createBrowserViteConfig({ apiResponseHeadersPlugin(), // Next, so no provider route answers a Host the server does not allow. hostCheckPlugin(), - cesium(), + // Keep the SDK asset serving/copying and external imports, but let the + // scene load the script and stylesheet only when Cesium is requested. + { ...cesium(), transformIndexHtml: undefined }, // Application views are Vue single-file components. vue(VUE_PLUGIN_OPTIONS), ...plugins, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 311e070dc..16a94865d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1984,6 +1984,7 @@ "traffic-layer": { "exports": ["./layers/traffic"], "modules": [ + "src/layers/shared/color.js", "src/data/cameraSensitivity.js", "src/data/flowMatch.js", "src/data/tomtomTiles.js", @@ -2657,6 +2658,7 @@ "src/app/layers/traffic.js", "src/app/operations.js", "src/app/scene.js", + "src/app/loadCesium.js", "src/sources/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", diff --git a/src/annotations/worldAnnotationRenderer.js b/src/annotations/worldAnnotationRenderer.js index 189d46830..e40ccad0d 100644 --- a/src/annotations/worldAnnotationRenderer.js +++ b/src/annotations/worldAnnotationRenderer.js @@ -46,10 +46,10 @@ const PALETTE = { * * BOTH paints onto terrain AND onto 3D tiles, so one value covers both worlds. */ -const CLASSIFY = Cesium.ClassificationType.BOTH; -const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND; export function createWorldAnnotationRenderer(viewer) { + const CLASSIFY = Cesium.ClassificationType.BOTH; + const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND; const dataSource = new Cesium.CustomDataSource('gev-annotations'); viewer.dataSources.add(dataSource); const osmAnnotations = new Set(); diff --git a/src/app/loadCesium.js b/src/app/loadCesium.js new file mode 100644 index 000000000..0701adba5 --- /dev/null +++ b/src/app/loadCesium.js @@ -0,0 +1,52 @@ +// The production build externalizes Cesium; the dev server imports its ESM +// package. Both load widget CSS only when a native viewer is requested. +export function createCesiumLoader({ + document = globalThis.document, + globals = globalThis, + base = `${import.meta.env?.BASE_URL ?? '/'}cesium/`, + script = import.meta.env?.PROD === true, +} = {}) { + let pending; + return () => { + if (pending) return pending; + globals.CESIUM_BASE_URL ??= base; + const root = globals.CESIUM_BASE_URL.replace(/\/?$/, '/'); + const elements = []; + const load = (tag, url) => + new Promise((resolve, reject) => { + const element = document.createElement(tag); + elements.push(element); + if (tag === 'script') element.src = url; + else { + element.rel = 'stylesheet'; + element.href = url; + } + element.onload = () => { + element.onload = element.onerror = null; + resolve(); + }; + element.onerror = () => reject(new Error(`Could not load ${url}`)); + document.head.append(element); + }); + pending = Promise.all([ + load('link', `${root}Widgets/widgets.css`), + ...(script && !globals.Cesium + ? [load('script', `${root}Cesium.js`)] + : []), + ]).catch((error) => { + for (const element of elements) { + element.onload = element.onerror = null; + element.remove(); + } + pending = null; + throw error; + }); + return pending; + }; +} + +let load; +export function loadCesium() { + load ??= createCesiumLoader(); + return load(); +} diff --git a/src/app/loadCesium.test.mjs b/src/app/loadCesium.test.mjs new file mode 100644 index 000000000..d4e52da13 --- /dev/null +++ b/src/app/loadCesium.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { createCesiumLoader } from './loadCesium.js'; + +function fixture(globals = {}) { + const elements = []; + const document = { + createElement: (tag) => ({ + tag, + remove() { + elements.splice(elements.indexOf(this), 1); + }, + }), + head: { append: (element) => elements.push(element) }, + }; + return { + elements, + globals, + load: createCesiumLoader({ + document, + globals, + base: '/app/cesium/', + script: true, + }), + }; +} + +test('Cesium assets load together only on demand and are shared across starts', async () => { + const { elements, globals, load } = fixture(); + assert.equal(elements.length, 0); + const first = load(); + assert.equal(load(), first); + assert.equal(globals.CESIUM_BASE_URL, '/app/cesium/'); + assert.deepEqual( + elements.map((e) => e.href ?? e.src), + ['/app/cesium/Widgets/widgets.css', '/app/cesium/Cesium.js'], + ); + elements.forEach((e) => e.onload()); + await first; + await load(); + assert.equal(elements.length, 2); +}); + +test('a failed Cesium asset load can be retried and preserves the configured base', async () => { + const { elements, globals, load } = fixture({ CESIUM_BASE_URL: '/custom/' }); + const failed = load(); + elements[1].onerror(); + await assert.rejects(failed, /Could not load/); + assert.equal(elements.length, 0); + const retried = load(); + assert.equal(globals.CESIUM_BASE_URL, '/custom/'); + assert.equal(elements[1].src, '/custom/Cesium.js'); + elements.forEach((e) => e.onload()); + await retried; +}); + +test('the standalone startup graph can be imported before the Cesium SDK exists', () => { + // Empty SDK exports make any boot-time native constructor or enum read fail. + // mgrs is CJS in Node; its browser named export is unrelated to this gate. + const script = ` + import { registerHooks } from 'node:module'; + registerHooks({ resolve(specifier, context, next) { + const code = specifier === 'cesium' + ? 'export const Axis=undefined,ScreenSpaceEventType=undefined,BoundingSphere=undefined,Color=undefined,PostProcessStage=undefined;' + : specifier === 'mgrs' ? 'export function forward() {}' : null; + return code ? { url: 'data:text/javascript,' + encodeURIComponent(code), shortCircuit: true } : next(specifier, context); + }}); + await import('./src/standalone/application.js'); + const { registerNavaraLayerRenderers } = await import('./src/app/navaraMode.js'); + await registerNavaraLayerRenderers(); + const { createStandaloneCatalog } = await import('./src/standalone/catalog.js'); + const lifetime = new AbortController(); + const catalog = createStandaloneCatalog({ engineKind: 'navara', signal: lifetime.signal }); + if (catalog.layers.length !== 33) throw new Error('Incomplete cold Navara catalog'); + lifetime.abort(); + `; + const result = spawnSync( + process.execPath, + ['--import=./build/registerVue.js', '--input-type=module', '-e', script], + { encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/src/app/scene.js b/src/app/scene.js index 5609d7790..1d6664720 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -17,6 +17,7 @@ import { import { describeError } from './errors.js'; import { createSceneEngine } from './globeEngine.js'; import { onLocaleChange, t } from '../i18n/index.js'; +import { loadCesium } from './loadCesium.js'; /** * Construct the application globe using the caller's local configuration. @@ -139,6 +140,8 @@ async function createRenderer({ } if (engineKind !== 'cesium') throw new TypeError(`Unknown globe engine: ${engineKind}`); + await loadCesium(); + signal.throwIfAborted(); loaderStatus.textContent = t('Configuring viewer...'); // Provider attribution stays visible, including clean-view and recording. const creditContainer = document.createElement('div'); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 79497fd1a..b73036295 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -1,4 +1,6 @@ import { LayerBindings } from './ui/layerBindings.js'; +import { ScreenSpaceEventType } from 'cesium'; +import { FIRMS_LEFT_CLICK } from './layers/firms/render/cesium.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js'; @@ -416,7 +418,7 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () => ); assert.match(vesselFocus, /requestWorldFocus\(\{/); assert.match(firms, /const clickType\s*=\s*layerState\._renderer\?\.clickType\s*\?\?\s*config\.leftClickEventType/); - assert.match(firms, /FIRMS_LEFT_CLICK = Cesium\.ScreenSpaceEventType\.LEFT_CLICK/); + assert.equal(FIRMS_LEFT_CLICK, ScreenSpaceEventType.LEFT_CLICK); const fireClick = body( firms, /_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, clickType\);/, diff --git a/src/cyberSonarGpu.js b/src/cyberSonarGpu.js index bb4aff4d6..ff4ffb94b 100644 --- a/src/cyberSonarGpu.js +++ b/src/cyberSonarGpu.js @@ -9,14 +9,7 @@ export const CYBER_SONAR_CESIUM_VERSION = '1.138.0'; // Follow every public DrawCommand setter, including pick/shadow/culling flags. // Copying native.dirty would keep derived pipelines dirty forever: the scene // consumes our command, so it never clears that flag on the native command. -const commandProperties = Object.entries( - Object.getOwnPropertyDescriptors(Cesium.DrawCommand.prototype), -) - .filter( - ([key, descriptor]) => - descriptor.set && key !== 'shaderProgram' && key !== 'uniformMap', - ) - .map(([key]) => key); +let commandProperties; export function syncCyberSonarCommand( native, @@ -25,6 +18,14 @@ export function syncCyberSonarCommand( uniforms, labelBackground, ) { + commandProperties ??= Object.entries( + Object.getOwnPropertyDescriptors(Cesium.DrawCommand.prototype), + ) + .filter( + ([key, descriptor]) => + descriptor.set && key !== 'shaderProgram' && key !== 'uniformMap', + ) + .map(([key]) => key); if (!record.command) { record.command = new Cesium.DrawCommand(); record.uniformMap = { diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 25d29d99b..2bd89f80b 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -47,8 +47,8 @@ const SCENE_PATH_RENDER_HOLD = 'bhote-koshi-scene-path-travel'; const SCENE_MEDIA_MIN_PLAYBACK_SECONDS = 6; const SCENE_MEDIA_EXIT_SECONDS = 0.65; const BHOTE_EVENT_ACCENT = WORLD_OVERLAY_STYLE.accent; -const FLOOD_COLOR = Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68); -const SURGE_COLOR = Cesium.Color.fromCssColorString('#e8b47c'); +const FLOOD_COLOR = () => Cesium.Color.fromCssColorString('#d8954e').withAlpha(0.68); +const SURGE_COLOR = () => Cesium.Color.fromCssColorString('#e8b47c'); const CINEMATIC_INPUT_EVENTS = Object.freeze(['pointerdown', 'wheel']); const CINEMATIC_VIEW_SPECS = Object.freeze([ { @@ -1077,8 +1077,8 @@ export function createBhoteKoshiEventLayer({ let _cinematicKeyframes = []; let _cinematicActive = false; let _cinematicReplayArmed = false; - let _cinematicPose = { target: new Cesium.Cartesian3() }; - let _cinematicOffset = new Cesium.HeadingPitchRange(); + let _cinematicPose = { target: { x: 0, y: 0, z: 0 } }; + let _cinematicOffset = { heading: 0, pitch: 0, range: 0 }; let _lastCinematicProgress = null; let _cameraInputTarget = null; let _evidenceClickTarget = null; @@ -2570,11 +2570,11 @@ export function createBhoteKoshiEventLayer({ const tileSafe = usesPhotorealFloodSurface(); _floodSurfaceMode = tileSafe ? 'tiles' : 'terrain'; const haloMaterial = new Cesium.PolylineDashMaterialProperty({ - color: FLOOD_COLOR.withAlpha(0.38), + color: FLOOD_COLOR().withAlpha(0.38), dashLength: 18, }); const coreMaterial = new Cesium.PolylineGlowMaterialProperty({ - color: FLOOD_COLOR.withAlpha(0.96), + color: FLOOD_COLOR().withAlpha(0.96), glowPower: 0.24, taperPower: 0.7, }); @@ -2619,7 +2619,7 @@ export function createBhoteKoshiEventLayer({ position: Cesium.Cartesian3.ZERO, point: { pixelSize: 11, - color: SURGE_COLOR, + color: SURGE_COLOR(), outlineColor: Cesium.Color.BLACK.withAlpha(0.85), outlineWidth: 3, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index 9c169446f..1282748d8 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -144,10 +144,10 @@ const NEARBY_CITIES = Object.freeze([ placement: 'right-lower', }), ]); -const BORDER_COLOR = Cesium.Color.fromCssColorString('#20e7f2'); +const BORDER_COLOR = () => Cesium.Color.fromCssColorString('#20e7f2'); const BORDER_ACCENT = '#20e7f2'; const INCIDENT_ACCENT = '#ffad55'; -const INCIDENT_COLOR = Cesium.Color.fromCssColorString(INCIDENT_ACCENT); +const INCIDENT_COLOR = () => Cesium.Color.fromCssColorString(INCIDENT_ACCENT); const DEFAULT_OVERLAY_HOST = Object.freeze({ setEntries: setOverlayEntries, @@ -518,7 +518,7 @@ export function createBhoteKoshiLocatorLayer({ sequenceStartedAt, visibleAfterMs = 0, ) { - addCallout(dataSource, INCIDENT, INCIDENT_COLOR, { + addCallout(dataSource, INCIDENT, INCIDENT_COLOR(), { sequenceStartedAt, visibleAfterMs, }); @@ -552,12 +552,12 @@ export function createBhoteKoshiLocatorLayer({ false, ), color: new Cesium.CallbackProperty((_time, result) => { - const color = Cesium.Color.clone(INCIDENT_COLOR, result); + const color = Cesium.Color.clone(INCIDENT_COLOR(), result); color.alpha = 0.16 * (1 - pulseProgress()); return color; }, false), outlineColor: new Cesium.CallbackProperty((_time, result) => { - const color = Cesium.Color.clone(INCIDENT_COLOR, result); + const color = Cesium.Color.clone(INCIDENT_COLOR(), result); color.alpha = isVisible() ? 0.08 + 0.78 * (1 - pulseProgress()) : 0; return color; }, false), @@ -695,7 +695,7 @@ export function createBhoteKoshiLocatorLayer({ labelPlacement = 'right', { showAnchor = true } = {}, ) { - addCallout(dataSource, NEPAL, BORDER_COLOR, { + addCallout(dataSource, NEPAL, BORDER_COLOR(), { labelPlacement, sequenceStartedAt, showAnchor, @@ -810,12 +810,12 @@ export function createBhoteKoshiLocatorLayer({ false, ), color: new Cesium.CallbackProperty((_time, result) => { - const color = Cesium.Color.clone(INCIDENT_COLOR, result); + const color = Cesium.Color.clone(INCIDENT_COLOR(), result); color.alpha = 0.16 * (1 - pulseProgress()); return color; }, false), outlineColor: new Cesium.CallbackProperty((_time, result) => { - const color = Cesium.Color.clone(INCIDENT_COLOR, result); + const color = Cesium.Color.clone(INCIDENT_COLOR(), result); color.alpha = 0.08 + 0.78 * (1 - pulseProgress()); return color; }, false), @@ -880,7 +880,7 @@ export function createBhoteKoshiLocatorLayer({ positions: animatedPositions, width: 3.5, clampToGround: true, - material: BORDER_COLOR.withAlpha(0.96), + material: BORDER_COLOR().withAlpha(0.96), classificationType: Cesium.ClassificationType.BOTH, }, }); @@ -905,7 +905,7 @@ export function createBhoteKoshiLocatorLayer({ show: new Cesium.CallbackProperty(() => now() >= drawStartedAt, false), polygon: { hierarchy, - material: BORDER_COLOR.withAlpha(0.055), + material: BORDER_COLOR().withAlpha(0.055), classificationType: Cesium.ClassificationType.BOTH, }, }); @@ -915,7 +915,7 @@ export function createBhoteKoshiLocatorLayer({ positions: animatedPositions, width: 5, clampToGround: true, - material: BORDER_COLOR.withAlpha(0.12), + material: BORDER_COLOR().withAlpha(0.12), classificationType: Cesium.ClassificationType.BOTH, }, }); @@ -925,7 +925,7 @@ export function createBhoteKoshiLocatorLayer({ positions: animatedPositions, width: 2.5, clampToGround: true, - material: BORDER_COLOR.withAlpha(0.96), + material: BORDER_COLOR().withAlpha(0.96), classificationType: Cesium.ClassificationType.BOTH, }, }); diff --git a/src/data/geoImportPins.js b/src/data/geoImportPins.js index 1828b5df7..ac0a24a03 100644 --- a/src/data/geoImportPins.js +++ b/src/data/geoImportPins.js @@ -21,17 +21,17 @@ const STYLE = [ 'imageSubRegion', ]; -class ImportedBillboards extends Cesium.BillboardCollection { - update(frameState) { - // Height queries should find the map, never another imported marker. - // Ordinary click picks and rendered screenshots still include the pins. - if (frameState.passes.pick && frameState.passes.offscreen) return; - super.update(frameState); - } -} - /** Own static imported pins without a per-frame Entity visualizer walk. */ export function createImportedPins(dataSource, viewer) { + class ImportedBillboards extends Cesium.BillboardCollection { + update(frameState) { + // Height queries should find the map, never another imported marker. + // Ordinary click picks and rendered screenshots still include the pins. + if (frameState.passes.pick && frameState.passes.offscreen) return; + super.update(frameState); + } + } + const all = dataSource.entities.values.slice(); const now = Cesium.JulianDate.now(); const pins = all.filter((entity) => entity.billboard && entity.position); diff --git a/src/data/modelVisualAnchor.js b/src/data/modelVisualAnchor.js index 2dd2302d7..3eedb2b22 100644 --- a/src/data/modelVisualAnchor.js +++ b/src/data/modelVisualAnchor.js @@ -1,4 +1,3 @@ -import { modelAxis } from './render/cesiumConstants.js'; import * as geo from '../geo/index.js'; /** * Model-space visual centres measured from the shipped GLBs' scene-space AABBs. @@ -71,17 +70,9 @@ export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({ '/models/atr72.glb': Object.freeze([12.9785, -1.2935, 0]), }); -/** @constant Cesium's OWN axis correction for a glTF loaded with the - * `Model.fromGltfAsync` defaults (upAxis Y, forwardAxis Z), assembled from its - * exported constants rather than restated as numbers here. `ModelSceneGraph` - * renders with `modelMatrix x components.transform x axisCorrection`, and the - * trail anchor has to ride the same chain or it lands on a different axis than - * the aircraft it is supposed to be attached to. */ -const AXIS_CORRECTION = geo.matrix4.multiplyTransformation( - modelAxis.Y_UP_TO_Z_UP, - modelAxis.Z_UP_TO_X_UP, - geo.matrix4.create(), -); +// glTF Y-up/Z-forward to the model frame: Cesium's Y_UP_TO_Z_UP × +// Z_UP_TO_X_UP, shared with Navara before the Cesium SDK is loaded. +const AXIS_CORRECTION = [0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1]; const _anchorScratch = geo.cartesian3.create(); const _chainScratch = geo.matrix4.create(); diff --git a/src/data/render/cesiumProjection.js b/src/data/render/cesiumProjection.js index 3075904c2..1f53c148e 100644 --- a/src/data/render/cesiumProjection.js +++ b/src/data/render/cesiumProjection.js @@ -6,11 +6,12 @@ export function worldToWindowCoordinates(scene, position, result) { result, ); } -const occluder = new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - new Cesium.Cartesian3(), -); +let occluder; export function horizonOccluder(camera) { + occluder ??= new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, + new Cesium.Cartesian3(), + ); occluder.cameraPosition = camera.positionWC; return occluder; } diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index cf247c3e6..bbc19f40f 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -9,11 +9,11 @@ import { toCesium2, toCesium3, toGeo3 } from './convert.js'; import { createCesiumFollow } from './follow.js'; const { math } = geo; -const EASINGS = { +const easings = () => ({ cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT, sinusoidalInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT, linear: Cesium.EasingFunction.LINEAR_NONE, -}; +}); // Cesium reports heading and roll in [0, 2π]; the contract wants [0, 2π). function wrapAngle(angle) { @@ -43,7 +43,7 @@ const cesiumDestination = (destination) => ) : toCesium3(destination); -const cesiumEasing = (easing) => (easing ? EASINGS[easing] : undefined); +const cesiumEasing = (easing) => (easing ? easings()[easing] : undefined); // A HeadingPitchRange or a Cartesian offset, as Cesium's lookAt takes them. const cesiumOffset = (offset) => diff --git a/src/globe/cesium/frames.js b/src/globe/cesium/frames.js index 2d2feee60..35692dd09 100644 --- a/src/globe/cesium/frames.js +++ b/src/globe/cesium/frames.js @@ -4,7 +4,7 @@ import * as Cesium from 'cesium'; import { bridgeEvent } from './events.js'; -const scratchGregorian = new Cesium.GregorianDate(); +let scratchGregorian; // JulianDate.toDate(time).getTime() without the Date: frame events must not // allocate. A raise without a time (not Cesium's own) reads the clock. @@ -27,6 +27,7 @@ function unixMilliseconds(scene, time) { * @returns {{ frames: object, destroy: () => void }} */ export function createCesiumFrames(viewer) { + scratchGregorian ??= new Cesium.GregorianDate(); const { scene } = viewer; const bridges = ['preUpdate', 'preRender', 'postRender'].map((name) => [ name, diff --git a/src/globe/cesium/input.js b/src/globe/cesium/input.js index 7783e82f1..57a8b5a84 100644 --- a/src/globe/cesium/input.js +++ b/src/globe/cesium/input.js @@ -6,19 +6,6 @@ import { normalizeInputOptions } from '../contract.js'; import { createPickDelivery } from '../pickDelivery.js'; import { toGeo2 } from './convert.js'; -const TYPES = { - click: Cesium.ScreenSpaceEventType.LEFT_CLICK, - doubleClick: Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, - move: Cesium.ScreenSpaceEventType.MOUSE_MOVE, - down: Cesium.ScreenSpaceEventType.LEFT_DOWN, - up: Cesium.ScreenSpaceEventType.LEFT_UP, -}; -const KEYS = { - ctrl: Cesium.KeyboardEventModifier.CTRL, - shift: Cesium.KeyboardEventModifier.SHIFT, - alt: Cesium.KeyboardEventModifier.ALT, -}; - // Cesium reuses its event objects; listeners get geo copies. const toEvent = (event) => { const result = {}; @@ -44,6 +31,19 @@ export function createCesiumInput( scheduleFrame = (run) => requestAnimationFrame(run), } = {}, ) { + const TYPES = { + click: Cesium.ScreenSpaceEventType.LEFT_CLICK, + doubleClick: Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, + move: Cesium.ScreenSpaceEventType.MOUSE_MOVE, + down: Cesium.ScreenSpaceEventType.LEFT_DOWN, + up: Cesium.ScreenSpaceEventType.LEFT_UP, + }; + const KEYS = { + ctrl: Cesium.KeyboardEventModifier.CTRL, + shift: Cesium.KeyboardEventModifier.SHIFT, + alt: Cesium.KeyboardEventModifier.ALT, + }; + const { scene } = viewer; let handler = null; let destroyed = false; diff --git a/src/globe/cesium/movers.js b/src/globe/cesium/movers.js index 9597d8ce6..8d2bfc7bb 100644 --- a/src/globe/cesium/movers.js +++ b/src/globe/cesium/movers.js @@ -8,12 +8,12 @@ import * as Cesium from 'cesium'; import { createMoverRegistry } from '../movers.js'; -const scratchPosition = new Cesium.Cartesian3(); -const scratchColor = new Cesium.Color(); -const scratchQuaternion = new Cesium.Quaternion(); -const scratchRotation = new Cesium.Matrix3(); +let scratchPosition; +let scratchColor; +let scratchQuaternion; +let scratchRotation; // Model.modelMatrix and Model.color copy what they are given. -const scratchModelMatrix = new Cesium.Matrix4(); +let scratchModelMatrix; /** The atlas cells as canvases, by key (a DOM canvas or image source). */ async function cropAtlasCells(atlas, keys) { @@ -287,6 +287,11 @@ export function createCesiumMovers( viewer, { requestRender = () => viewer.scene.requestRender(), factories = {} } = {}, ) { + scratchPosition ??= new Cesium.Cartesian3(); + scratchColor ??= new Cesium.Color(); + scratchQuaternion ??= new Cesium.Quaternion(); + scratchRotation ??= new Cesium.Matrix3(); + scratchModelMatrix ??= new Cesium.Matrix4(); const make = { ...DEFAULT_FACTORIES, ...factories }; const { scene } = viewer; let root = null; diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index b538b07d6..537bcdafd 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -68,10 +68,10 @@ export function presentRoutingFailure(error) { /** Route colour — the annotation palette's cyan, so voice routes and Directions match. */ export const DIRECTIONS_ROUTE_COLOR = '#39d0ff'; -const MARKER_A_COLOR = Cesium.Color.fromCssColorString('#5dff9f'); -const MARKER_B_COLOR = Cesium.Color.fromCssColorString('#ff6b6b'); -const STEP_COLOR = Cesium.Color.WHITE.withAlpha(0.95); -const STEP_OUTLINE = Cesium.Color.fromCssColorString(DIRECTIONS_ROUTE_COLOR); +const MARKER_A_COLOR = () => Cesium.Color.fromCssColorString('#5dff9f'); +const MARKER_B_COLOR = () => Cesium.Color.fromCssColorString('#ff6b6b'); +const STEP_COLOR = () => Cesium.Color.WHITE.withAlpha(0.95); +const STEP_OUTLINE = () => Cesium.Color.fromCssColorString(DIRECTIONS_ROUTE_COLOR); const STEP_PIXEL_SIZE = 8; const STEP_SELECTED_PIXEL_SIZE = 13; /** Route request timeout (ms) — the proxy itself gives OSRM 12 s. */ @@ -1020,10 +1020,10 @@ export function createDirectionsLayer({ services, source, createNativeRenderer } if (!_viewer && !_native) return; if (letter === 'a') { removeEntity(_markerA); - _markerA = markerEntity('A', point, MARKER_A_COLOR); + _markerA = markerEntity('A', point, MARKER_A_COLOR()); } else { removeEntity(_markerB); - _markerB = markerEntity('B', point, MARKER_B_COLOR); + _markerB = markerEntity('B', point, MARKER_B_COLOR()); } } @@ -1127,9 +1127,9 @@ export function createDirectionsLayer({ services, source, createNativeRenderer } _stepPoints.add({ id: `directions:step:${index}`, position: Cesium.Cartesian3.fromDegrees(step.lon, step.lat, 0), - color: STEP_COLOR, + color: STEP_COLOR(), pixelSize: STEP_PIXEL_SIZE, - outlineColor: STEP_OUTLINE, + outlineColor: STEP_OUTLINE(), outlineWidth: 2, show: false, // The shared ground floor is a coarse cell, and on the photoreal stack diff --git a/src/layers/firms/render/cesium.js b/src/layers/firms/render/cesium.js index 0ccb20449..c40caad6a 100644 --- a/src/layers/firms/render/cesium.js +++ b/src/layers/firms/render/cesium.js @@ -4,7 +4,8 @@ import { horizonOccluder, } from '../../../data/render/cesiumProjection.js'; -export const FIRMS_LEFT_CLICK = Cesium.ScreenSpaceEventType.LEFT_CLICK; +// Cesium ScreenSpaceEventType.LEFT_CLICK; available before the SDK loads. +export const FIRMS_LEFT_CLICK = 2; export const projectFirmsWindow = worldToWindowCoordinates; export const firmsHorizonOccluder = horizonOccluder; diff --git a/src/layers/gnss/render/cesium.js b/src/layers/gnss/render/cesium.js index dccea9b27..8e1e3c554 100644 --- a/src/layers/gnss/render/cesium.js +++ b/src/layers/gnss/render/cesium.js @@ -1,17 +1,6 @@ import * as Cesium from 'cesium'; import { GNSS_LEVEL_COLORS } from '../records.js'; -/** - * GroundPrimitive allowPicking:false suppresses ids but still submits stencil - * commands in Cesium's pick pass, erasing picks of overlapping ground geometry. - * This fill participates only in rendering, so sibling polygons remain pickable. - */ -class GnssGroundFill extends Cesium.GroundPrimitive { - update(frameState) { - if (!frameState.passes.pick) super.update(frameState); - } -} - /** Degrees at the centre of the current view, or null before the camera settles. */ export function defaultGnssViewAnchor(viewer) { const canvas = viewer?.scene?.canvas; @@ -44,6 +33,14 @@ export function createGnssCesiumRenderer({ viewer, viewAnchor = defaultGnssViewAnchor, }) { + // GroundPrimitive allowPicking:false still submits stencil commands in + // the pick pass. Keep this fill from erasing overlapping geometry's picks. + class GnssGroundFill extends Cesium.GroundPrimitive { + update(frameState) { + if (!frameState.passes.pick) super.update(frameState); + } + } + const primitives = new Cesium.PrimitiveCollection(); primitives.id = 'gnss-integrity'; primitives.show = false; diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index b20ca72a0..c573c138e 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -210,11 +210,7 @@ export function viewFocus( * (NYC −22 m), and an occluder at 0 m would hide the cones around it. */ const HORIZON_CULL_DEPTH_M = 1000; -const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid( - Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M, - Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M, - Cesium.Ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M, -); +let HORIZON_CULL_ELLIPSOID; /** * Hide billboards and points behind the horizon: they skip the depth test, so @@ -223,12 +219,18 @@ const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid( * @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options */ export function createHorizonCull({ getViewer, items, onChange }) { - const occluder = new Cesium.EllipsoidalOccluder(HORIZON_CULL_ELLIPSOID); + let occluder; let from = null; let stopListening = null; /** Show what is in front of the horizon, hide the rest; the count seen. */ function cull(list, position) { + HORIZON_CULL_ELLIPSOID ??= new Cesium.Ellipsoid( + Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M, + Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M, + Cesium.Ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M, + ); + occluder ??= new Cesium.EllipsoidalOccluder(HORIZON_CULL_ELLIPSOID); occluder.cameraPosition = position; let seen = 0; let changed = false; diff --git a/src/layers/submarineCables/policy.js b/src/layers/submarineCables/policy.js index 89c18ca8c..dbf454249 100644 --- a/src/layers/submarineCables/policy.js +++ b/src/layers/submarineCables/policy.js @@ -86,11 +86,11 @@ export const CABLE_GLOBE_STACK_IDS = Object.freeze( export const MARKER_COLLECTION_EXPECTATIONS = Object.freeze([ Object.freeze({ key: '_billboardCollection', - type: Cesium.BillboardCollection, + get type() { return Cesium.BillboardCollection; }, }), Object.freeze({ key: '_pointCollection', - type: Cesium.PointPrimitiveCollection, + get type() { return Cesium.PointPrimitiveCollection; }, }), ]); diff --git a/src/layers/traffic/footprint.js b/src/layers/traffic/footprint.js index 0b1d59019..9179d09a0 100644 --- a/src/layers/traffic/footprint.js +++ b/src/layers/traffic/footprint.js @@ -68,7 +68,7 @@ export function createReticleFootprint() { const pixels = Array.from({ length: 25 }, () => geo.cartesian2.create()); const hits = Array.from({ length: 25 }, () => geo.cartesian3.create()); const carto = geo.cartographic.create(); - const ray = new Cesium.Ray(); + let ray; return (viewer, altitude, engine) => { const camera = engine?.camera ?? viewer?.camera; if (!camera?.getPickRay && !camera?.pickRay) return null; @@ -91,7 +91,9 @@ export function createReticleFootprint() { p = pixels[i]; p.x = left + (side * col) / 4; p.y = top + (side * row) / 4; - const hit = camera.pickEllipsoid(p, Cesium.Ellipsoid.WGS84, hits[i]); + const hit = engine + ? camera.pickEllipsoid(p) + : camera.pickEllipsoid(p, Cesium.Ellipsoid.WGS84, hits[i]); let dx, dy; if (hit) { geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84, carto); @@ -100,7 +102,7 @@ export function createReticleFootprint() { dlon -= 360 * Math.round(dlon / 360); dx = dlon * 111.195 * Math.cos(nadir.latitude); } else { - const picked = engine ? camera.pickRay(p) : camera.getPickRay(p, ray) || ray; + const picked = engine ? camera.pickRay(p) : camera.getPickRay(p, (ray ??= new Cesium.Ray())) || ray; if (!picked) continue; const d = picked.direction; dx = diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js index c20b15e41..f355b2819 100644 --- a/src/layers/traffic/policy.js +++ b/src/layers/traffic/policy.js @@ -1,4 +1,4 @@ -import * as Cesium from 'cesium'; +import { rgbaFromCss } from '../shared/color.js'; /** * @file Street Traffic selects TomTom, OpenMapTiles or Hybrid motor roads, @@ -112,9 +112,9 @@ export const SIZE_BY_TYPE = { */ export const FLOW_BUCKET_COLORS = { - free: Cesium.Color.fromCssColorString('#2ecc71').withAlpha(0.9), - slow: Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.9), - jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9), + free: rgbaFromCss('#2ecc71').withAlpha(0.9), + slow: rgbaFromCss('#f0b23e').withAlpha(0.9), + jam: rgbaFromCss('#e05252').withAlpha(0.9), }; // ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ──────── @@ -140,12 +140,12 @@ export const HEAT_JAM_PULSE_ALPHA = 0.2; /** @const {Cesium.Color} Jam corridor color (bucket red, alpha pulsed live). */ -export const HEAT_JAM_COLOR = Cesium.Color.fromCssColorString('#e05252'); +export const HEAT_JAM_COLOR = rgbaFromCss('#e05252'); /** @const {Cesium.Color} Slow corridor color (bucket amber, faint + static). */ export const HEAT_SLOW_COLOR = - Cesium.Color.fromCssColorString('#f0b23e').withAlpha(0.2); + rgbaFromCss('#f0b23e').withAlpha(0.2); /** @const {number} Far-distance scale floor for jam dots (shipped: 0.3). */ diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index 926abaff4..3e548d56c 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -12,10 +12,10 @@ export function roadIdentity(road) { /** Retain one road and its dot population across tile and surface publications. */ export function createRetention({ state, parts }) { const records = new Map(); - const heightStep = new Cesium.Cartesian3(); - const screenFrom = new Cesium.Cartesian2(); - const screenTo = new Cesium.Cartesian2(); - const eye = new Cesium.Cartesian3(); + const heightStep = { x: 0, y: 0, z: 0 }; + const screenFrom = { x: 0, y: 0 }; + const screenTo = { x: 0, y: 0 }; + const eye = { x: 0, y: 0, z: 0 }; function projectSafely(scene, point, screen) { if (scene.project) return Boolean(scene.project(point, screen)); if (scene.camera.viewMatrix) { diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index fc0e38dc0..76e5082e3 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -1,5 +1,4 @@ import { normalizeRoadMode } from './roadModes.js'; -import * as Cesium from 'cesium'; import { FLOW_BUCKET_COLORS } from './policy.js'; export function createState({ services }) { @@ -294,7 +293,7 @@ export function createState({ services }) { /** Reusable scratch Cartesian3 to avoid per-frame allocation / GC pressure */ - layerState._scratchLerp = new Cesium.Cartesian3(); + layerState._scratchLerp = { x: 0, y: 0, z: 0 }; // ─── Animation ───────────────────────────────────────────── diff --git a/src/maps/catalog.js b/src/maps/catalog.js index 8934e63b3..85dbae559 100644 --- a/src/maps/catalog.js +++ b/src/maps/catalog.js @@ -1,4 +1,4 @@ -import * as Cesium from 'cesium'; +// Ion world imagery asset IDs; the catalog is available before the SDK loads. export const MAP_STACKS = [ { id: 'photoreal', @@ -12,7 +12,7 @@ export const MAP_STACKS = [ label: 'Bing Aerial', // i18n-ignore: imagery product name shortLabel: 'Aerial', kind: 'ion', - style: Cesium.IonWorldImageryStyle.AERIAL, + style: 2, requiresIon: true, }, { @@ -20,7 +20,7 @@ export const MAP_STACKS = [ label: 'Bing Labels', // i18n-ignore: imagery product name shortLabel: 'Labels', kind: 'ion', - style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS, + style: 3, requiresIon: true, }, {