From b8305de14bf07fce0bc67c458133571739a32c7c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 17:15:21 +0400 Subject: [PATCH] Run the Navara paths without the Cesium SDK, and load it on demand for the features that still build on it Every Cesium read that a Navara page made was surveyed with the strict acceptance (GEV_QA_CESIUM_TRAP=record). Math on those paths now uses src/geo: screen annotations, weather and wind center readings, cyclone picks, scene data pack polygons and images (new navaraGeometry.js), the awareness subject, the orbit offset of camera verbs, voice frame_overhead, the agent world packet and Directions colours and ground picks. The celestial ring's Sun and Moon come from a bit-identical port of Cesium's Simon1994 model, JulianDate and TEME rotation (src/geo/simon1994.js, julianDate.js, transforms.js); a Cesium globe still applies its ICRF rotation once loaded. Traffic, Street Level, the Nepal event and locator and geo import build on Cesium's math or entity model on either globe; their enable (or the import) now loads the SDK first through src/app/loadCesium.js, synchronously when it is already there. A production page guards the global so a missed read is reported and starts the load. No module reads Cesium while it loads (scripts/check-cesium-load-time.mjs, run by npm test). Cesium.js is still loaded by index.html here. --- scripts/check-cesium-load-time.mjs | 179 ++++++ scripts/check-cesium-load-time.test.mjs | 42 ++ scripts/package-boundaries.json | 121 +++- src/agent/worldPacket.js | 8 +- src/annotations/annotationEngine.js | 9 +- src/annotations/screenAnnotationRenderer.js | 25 +- src/annotations/worldAnnotationRenderer.js | 4 +- src/app/loadCesium.js | 134 ++++ src/app/loadCesium.test.mjs | 151 +++++ src/cameraHandoff.test.mjs | 4 +- src/cameraVerbs.js | 8 +- src/celestialRing.js | 8 +- src/celestialRing/ephemeris.js | 28 + src/celestialRing/render/cesium.js | 33 +- src/cyberSonarGpu.js | 17 +- src/data/bhoteKoshiEvent.js | 27 +- src/data/bhoteKoshiLocator.js | 31 +- src/data/geoFileImport.js | 4 + src/data/geoImportPins.js | 18 +- src/data/modelVisualAnchor.js | 15 +- src/data/render/cesiumConstants.js | 10 +- src/data/render/cesiumProjection.js | 9 +- src/data/trackingClickGesture.js | 2 +- src/geo/index.js | 2 + src/geo/julianDate.js | 193 ++++++ src/geo/simon1994.js | 605 +++++++++++++++++++ src/geo/simon1994.test.mjs | 61 ++ src/geo/transforms.js | 61 +- 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/awareness/anchor.test.mjs | 6 +- src/layers/awareness/subject.js | 13 +- src/layers/cyclones/index.js | 17 +- src/layers/directions/index.js | 26 +- src/layers/firms/render/cesium.js | 3 +- src/layers/gnss/render/cesium.js | 19 +- src/layers/streetLevel/index.js | 23 +- src/layers/streetLevel/view.js | 14 +- src/layers/submarineCables/policy.js | 8 +- src/layers/traffic/footprint.js | 8 +- src/layers/traffic/lifecycle.js | 63 +- src/layers/traffic/policy.js | 13 +- src/layers/traffic/retention.js | 8 +- src/layers/traffic/state.js | 3 +- src/layers/weather/index.js | 40 +- src/layers/wind/edgeCases.test.mjs | 20 +- src/layers/wind/index.js | 10 +- src/layers/wind/index.test.mjs | 5 +- src/layers/wind/inspection.js | 13 +- src/layers/wind/inspection.test.mjs | 18 +- src/main.js | 4 + src/maps/catalog.js | 6 +- src/scenes/dataPacks/navara.js | 29 +- src/scenes/dataPacks/navaraGeometry.js | 141 +++++ src/scenes/dataPacks/navaraGeometry.test.mjs | 81 +++ src/voice/gevActions.js | 5 +- 58 files changed, 2130 insertions(+), 325 deletions(-) create mode 100644 scripts/check-cesium-load-time.mjs create mode 100644 scripts/check-cesium-load-time.test.mjs create mode 100644 src/app/loadCesium.js create mode 100644 src/app/loadCesium.test.mjs create mode 100644 src/celestialRing/ephemeris.js create mode 100644 src/geo/julianDate.js create mode 100644 src/geo/simon1994.js create mode 100644 src/geo/simon1994.test.mjs create mode 100644 src/scenes/dataPacks/navaraGeometry.js create mode 100644 src/scenes/dataPacks/navaraGeometry.test.mjs diff --git a/scripts/check-cesium-load-time.mjs b/scripts/check-cesium-load-time.mjs new file mode 100644 index 000000000..8701475a2 --- /dev/null +++ b/scripts/check-cesium-load-time.mjs @@ -0,0 +1,179 @@ +// The production build replaces every `cesium` import with the global that +// /cesium/Cesium.js defines, and that script loads only when a Cesium globe +// first starts (src/app/loadCesium.js). A module that reads a Cesium binding +// while it loads would therefore throw on a page that starts on Navara. This +// check lists such reads: a reference to a binding imported from `cesium`, or +// to the `Cesium` global, that runs when its module is evaluated (top level, +// class heritage, static members, immediately invoked functions) rather than +// inside a function that runs later. +// +// node scripts/check-cesium-load-time.mjs [--json] + +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { parse } from '@babel/parser'; + +const root = new URL('..', import.meta.url); + +/** Files under src/ that the app ships (tests and test support excluded). */ +export function sourceFiles() { + return execFileSync('git', ['ls-files', 'src'], { + cwd: root, + encoding: 'utf8', + }) + .split('\n') + .filter( + (path) => + /\.(?:js|mjs)$/.test(path) && + !/\.test\.m?js$/.test(path) && + !path.startsWith('src/testSupport/') && + !path.includes('/testing/'), + ); +} + +const FUNCTION_TYPES = new Set([ + 'FunctionDeclaration', + 'FunctionExpression', + 'ArrowFunctionExpression', + 'ObjectMethod', + 'ClassMethod', + 'ClassPrivateMethod', +]); + +/** Names a module binds to `cesium` imports. */ +function cesiumBindings(program) { + const names = new Set(); + for (const node of program.body) { + if (node.type !== 'ImportDeclaration' || node.source.value !== 'cesium') + continue; + for (const specifier of node.specifiers) names.add(specifier.local.name); + } + return names; +} + +function isReference(node, parent, key) { + if (node.type !== 'Identifier') return false; + if (!parent) return true; + if ( + (parent.type === 'MemberExpression' || + parent.type === 'OptionalMemberExpression') && + key === 'property' && + !parent.computed + ) + return false; + if ( + (parent.type === 'ObjectProperty' || + parent.type === 'ClassProperty' || + parent.type === 'ObjectMethod' || + parent.type === 'ClassMethod') && + key === 'key' && + !parent.computed + ) + return false; + if ( + parent.type === 'ImportSpecifier' || + parent.type === 'ImportNamespaceSpecifier' || + parent.type === 'ImportDefaultSpecifier' || + parent.type === 'ExportSpecifier' + ) + return false; + if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement') + return false; + return true; +} + +/** + * Load-time references in one module. `deferred` is true below a function + * body that runs later; an immediately invoked function and a class's static + * members and heritage run at load and stay not deferred. + */ +export function loadTimeReferences(code, path = '') { + const program = parse(code, { + sourceType: 'module', + plugins: ['classProperties', 'classStaticBlock', 'importAttributes'], + }).program; + const names = cesiumBindings(program); + const found = []; + // A re-export from `cesium` becomes a read of the global's property when + // the module loads. + for (const node of program.body) + if ( + (node.type === 'ExportNamedDeclaration' || + node.type === 'ExportAllDeclaration') && + node.source?.value === 'cesium' + ) + found.push({ + path, + line: node.loc.start.line, + name: 'export from cesium', + }); + const hasGlobal = /\bCesium\b/.test(code); + if (!names.size && !hasGlobal) return found; + // A `Cesium` global reference counts when the module does not bind it. + const watched = (name) => + names.has(name) || (name === 'Cesium' && !names.has('Cesium')); + const visit = (node, parent, key, deferred) => { + if (!node || typeof node.type !== 'string') return; + if (node.type === 'ImportDeclaration') return; + if (!deferred && isReference(node, parent, key) && watched(node.name)) + found.push({ path, line: node.loc.start.line, name: node.name }); + let childDeferred = deferred; + if (FUNCTION_TYPES.has(node.type)) { + const invoked = + parent && + (parent.type === 'CallExpression' || parent.type === 'NewExpression') && + key === 'callee'; + childDeferred = deferred || !invoked; + } + if ( + (node.type === 'ClassProperty' || node.type === 'ClassPrivateProperty') && + !node.static + ) + childDeferred = true; + for (const [childKey, value] of Object.entries(node)) { + if ( + childKey === 'loc' || + childKey === 'leadingComments' || + childKey === 'trailingComments' || + childKey === 'innerComments' + ) + continue; + // Computed keys and parameter defaults of a deferred function: its key + // is evaluated with the enclosing scope, its body later. + const keyDeferred = + FUNCTION_TYPES.has(node.type) && childKey === 'key' + ? deferred + : childDeferred; + if (Array.isArray(value)) + for (const child of value) visit(child, node, childKey, keyDeferred); + else if (value && typeof value === 'object') + visit(value, node, childKey, keyDeferred); + } + }; + visit(program, null, null, false); + return found; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const found = []; + for (const path of sourceFiles()) { + const code = readFileSync(new URL(path, root), 'utf8'); + try { + found.push(...loadTimeReferences(code, path)); + } catch (error) { + console.error(`${path}: ${error.message}`); + process.exitCode = 1; + } + } + if (process.argv.includes('--json')) + console.log(JSON.stringify(found, null, 2)); + else + for (const { path, line, name } of found) + console.log(`${path}:${line} reads ${name} while the module loads`); + if (found.length) { + console.error( + `${found.length} load-time Cesium read(s); a page that starts on Navara has no Cesium global yet.`, + ); + process.exitCode = 1; + } else console.log('No module reads Cesium while it loads.'); +} diff --git a/scripts/check-cesium-load-time.test.mjs b/scripts/check-cesium-load-time.test.mjs new file mode 100644 index 000000000..283cb7dc6 --- /dev/null +++ b/scripts/check-cesium-load-time.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { loadTimeReferences, sourceFiles } from './check-cesium-load-time.mjs'; + +const reads = (code) => + loadTimeReferences(code).map(({ line, name }) => `${line}:${name}`); + +test('reads that run while a module loads are found; reads inside later code are not', () => { + assert.deepEqual( + reads(`import * as Cesium from 'cesium'; +const color = Cesium.Color.RED; +export function later() { return Cesium.Color.BLUE; } +const lazy = () => Cesium.Math.PI; +class Shape { static axis = Cesium.Axis.Y; field = Cesium.Axis.Z; draw() { return Cesium; } } +(() => Cesium.VERSION)(); +export const fallback = (sdk = Cesium) => sdk;`), + ['2:Cesium', '5:Cesium', '6:Cesium'], + ); + assert.deepEqual( + reads(`import { Cartesian3 as C3, Color } from 'cesium'; +export const ORIGIN = C3.ZERO; +export function white() { return Color.WHITE; }`), + ['2:C3'], + ); + assert.deepEqual(reads(`export { Axis } from 'cesium';`), [ + '1:export from cesium', + ]); + assert.deepEqual( + reads(`window.__CESIUM__ = Cesium;`), + ['1:Cesium'], + 'the global counts where the module does not import it', + ); + assert.deepEqual(reads(`const geo = { Cesium: 1 }; geo.Cesium;`), []); +}); + +test('no shipped module reads Cesium while it loads', () => { + const found = sourceFiles().flatMap((path) => + loadTimeReferences(readFileSync(path, 'utf8'), path), + ); + assert.deepEqual(found, []); +}); diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 311e070dc..ffc3c506c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -889,6 +889,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1163,6 +1165,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1317,6 +1321,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1413,6 +1419,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1494,6 +1502,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1546,6 +1556,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1608,6 +1620,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1640,6 +1654,8 @@ "src/geo/quaternion.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/layers/shared/color.js" ], "external": [] @@ -1717,6 +1733,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1774,6 +1792,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1834,6 +1854,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1906,6 +1928,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1972,6 +1996,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -1984,6 +2010,7 @@ "traffic-layer": { "exports": ["./layers/traffic"], "modules": [ + "src/layers/shared/color.js", "src/data/cameraSensitivity.js", "src/data/flowMatch.js", "src/data/tomtomTiles.js", @@ -2002,6 +2029,7 @@ "src/layers/traffic/ingestion.js", "src/sources/requestDeadline.js", "src/layers/traffic/lifecycle.js", + "src/app/loadCesium.js", "src/layers/traffic/model.js", "src/layers/traffic/policy.js", "src/layers/traffic/rendering.js", @@ -2047,6 +2075,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -2102,6 +2132,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -2176,6 +2208,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -2194,6 +2228,7 @@ ], "modules": [ "src/layers/streetLevel/index.js", + "src/app/loadCesium.js", "src/layers/streetLevel/credits.js", "src/layers/streetLevel/providers/mapillary/index.js", "src/layers/streetLevel/viewerHost.js", @@ -2264,6 +2299,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -2315,6 +2352,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -2378,6 +2417,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -2425,6 +2466,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -2657,6 +2700,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", @@ -2678,6 +2722,7 @@ "src/cctvFocusPolicy.js", "src/cctvFocusRequest.js", "src/celestialRing.js", + "src/celestialRing/ephemeris.js", "src/cockpitCloudEffects.js", "src/cockpitMath.js", "src/cockpitTracking.js", @@ -3646,6 +3691,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -3721,6 +3768,7 @@ "src/layers/traffic/render/navara.js", "src/layers/streetLevel/render/navara.js", "src/scenes/dataPacks/navara.js", + "src/scenes/dataPacks/navaraGeometry.js", "src/layers/shared/render/navaraStory.js", "src/layers/bhoteKoshi/render/navara.js", "src/layers/weather/render/navara.js", @@ -3845,6 +3893,7 @@ "src/sources/sourceSlot.js", "src/cctvFocusRequest.js", "src/celestialRing.js", + "src/celestialRing/ephemeris.js", "src/cockpitMath.js", "src/data/aircraftClass.js", "src/data/aircraftEmergency.js", @@ -4108,6 +4157,7 @@ "src/layers/traffic/ingestion.js", "src/sources/requestDeadline.js", "src/layers/traffic/lifecycle.js", + "src/app/loadCesium.js", "src/layers/traffic/model.js", "src/layers/traffic/policy.js", "src/layers/traffic/rendering.js", @@ -4431,6 +4481,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -4552,6 +4604,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -4817,6 +4871,7 @@ "src/data/distanceFormat.js", "src/i18n/marker.js", "src/layers/directions/index.js", + "src/layers/shared/color.js", "src/data/pointerOwnerNames.js", "src/layers/directions/source.js", "src/data/routeSteps.js", @@ -4833,7 +4888,25 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/geo/julianDate.js", + "src/geo/simon1994.js", + "src/geo/developerError.js", + "src/geo/check.js", + "src/geo/math.js", + "src/geo/cartesian2.js", + "src/geo/cartographic.js", + "src/geo/cartesian3.js", + "src/geo/matrix3.js", + "src/geo/quaternion.js", + "src/geo/matrix4.js", + "src/geo/ellipsoid.js", + "src/geo/transforms.js", + "src/geo/ellipsoidGeodesic.js", + "src/geo/boundingSphere.js", + "src/geo/debug.js", + "src/geo/scaleToGeodeticSurface.js", + "src/geo/index.js" ], "external": ["cesium"] }, @@ -5094,6 +5167,7 @@ "src/data/headingDegrees.js", "src/cyberSonar.js", "src/celestialRing.js", + "src/celestialRing/ephemeris.js", "src/data/approxDistance.js", "src/data/cameraSensitivity.js", "src/data/contactPlayback.js", @@ -5203,6 +5277,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -5273,6 +5349,8 @@ "src/geo/matrix4.js", "src/geo/quaternion.js", "src/geo/transforms.js", + "src/geo/simon1994.js", + "src/geo/julianDate.js", "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", @@ -5429,6 +5507,7 @@ "src/geo/math.js", "src/sources/httpBody.js", "src/data/geoFileImport.js", + "src/app/loadCesium.js", "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -5523,7 +5602,25 @@ "src/tools/globePanel.js", "src/tools/queries/panelRequest.js", "src/geoDistance.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/geo/boundingSphere.js", + "src/geo/cartesian2.js", + "src/geo/cartesian3.js", + "src/geo/cartographic.js", + "src/geo/check.js", + "src/geo/debug.js", + "src/geo/developerError.js", + "src/geo/ellipsoid.js", + "src/geo/ellipsoidGeodesic.js", + "src/geo/index.js", + "src/geo/julianDate.js", + "src/geo/math.js", + "src/geo/matrix3.js", + "src/geo/matrix4.js", + "src/geo/quaternion.js", + "src/geo/scaleToGeodeticSurface.js", + "src/geo/simon1994.js", + "src/geo/transforms.js" ], "external": ["satellite.js"] }, @@ -5681,7 +5778,25 @@ "src/contextModePolicy.js", "src/loadingFeedback.js", "src/data/installationFeedback.js", - "src/i18n/marker.js" + "src/i18n/marker.js", + "src/geo/boundingSphere.js", + "src/geo/cartesian2.js", + "src/geo/cartesian3.js", + "src/geo/cartographic.js", + "src/geo/check.js", + "src/geo/debug.js", + "src/geo/developerError.js", + "src/geo/ellipsoid.js", + "src/geo/ellipsoidGeodesic.js", + "src/geo/index.js", + "src/geo/julianDate.js", + "src/geo/math.js", + "src/geo/matrix3.js", + "src/geo/matrix4.js", + "src/geo/quaternion.js", + "src/geo/scaleToGeodeticSurface.js", + "src/geo/simon1994.js", + "src/geo/transforms.js" ], "external": ["cesium"] } diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js index 99924183c..01aee280e 100644 --- a/src/agent/worldPacket.js +++ b/src/agent/worldPacket.js @@ -1,4 +1,4 @@ -import * as Cesium from 'cesium'; +import * as geo from '../geo/index.js'; import { feedProvenanceEnvelope, layerSnapshot, @@ -110,7 +110,7 @@ export function getWorldPacket(options = {}) { if (!dataManager || typeof dataManager.getAll !== 'function') throw new TypeError('World Packet requires a data manager'); - const cartographic = Cesium.Cartographic.fromCartesian( + const cartographic = geo.cartographic.fromCartesian( engine?.camera.livePosition ?? viewer.camera.positionWC, ); // Read the roster once. Voice must not rewalk disabled layers: preserving the @@ -127,8 +127,8 @@ export function getWorldPacket(options = {}) { ok: true, action: 'get_current_view_state', camera: { - latitude: Cesium.Math.toDegrees(cartographic.latitude), - longitude: Cesium.Math.toDegrees(cartographic.longitude), + latitude: geo.math.toDegrees(cartographic.latitude), + longitude: geo.math.toDegrees(cartographic.longitude), heightM: cartographic.height, }, style: styleManager?.activeStyle || 'normal', diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 2be5562f1..77852b469 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -24,8 +24,13 @@ import { formatDistanceText } from '../data/distanceFormat.js'; // Dev convenience: expose the app's Cesium instance for console/preview probing // (single shared module instance — avoids dual-Cesium state bugs when testing). -if (typeof window !== 'undefined' && !window.__CESIUM__) - window.__CESIUM__ = Cesium; +// A getter, because the production build's Cesium global loads only with the +// first Cesium globe. +if (typeof window !== 'undefined' && !('__CESIUM__' in window)) + Object.defineProperty(window, '__CESIUM__', { + configurable: true, + get: () => Cesium, + }); /** * Annotation engine — the voice agent's "whiteboard" over the 3D world. diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index 864a0aced..0f3208834 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import * as geo from '../geo/index.js'; import { getOverlayPaintRect } from '../overlays/worldOverlay.js'; import { getActiveTrackedReadoutId, @@ -90,8 +91,8 @@ export function createScreenAnnotationRenderer( let published = ''; // Only the renderer whose board the store shows may change or remove it. const shown = () => store.board?.owner === owner; - const scratch = new Cesium.Cartesian2(); - const scratchDir = new Cesium.Cartesian3(); + const scratch = geo.cartesian2.create(); + const scratchDir = geo.cartesian3.create(); // Per-point ground height cache. Marks must sit on the real surface, not at // sea level — otherwise in elevated cities (Austin ~150 m) they project // underground and parallax sinks them at oblique angles. Sampled once the @@ -141,11 +142,9 @@ export function createScreenAnnotationRenderer( } try { if (view.clampToHeightSupported()) { - const c = view.clampToHeight( - Cesium.Cartesian3.fromDegrees(lon, lat, 0), - ); + const c = view.clampToHeight(geo.cartesian3.fromDegrees(lon, lat, 0)); if (c) { - const h = Cesium.Cartographic.fromCartesian(c).height; + const h = geo.cartographic.fromCartesian(c).height; if (Number.isFinite(h) && h > -430 && h < 9000) { heightCache.set(key, { h, settled: true, gen: projGen }); trimHeightCache(); @@ -359,14 +358,10 @@ export function createScreenAnnotationRenderer( // Anchor at the real surface height (cached + validated), so marks sit on // the ground/building instead of at sea level. The settle-once cache keeps // it stable (no per-frame jitter that earlier broke nearby projections). - const world = Cesium.Cartesian3.fromDegrees( - lon, - lat, - groundHeight(lon, lat), - ); + const world = geo.cartesian3.fromDegrees(lon, lat, groundHeight(lon, lat)); // Reject points behind the camera (they produce wrapped/extreme coords). - Cesium.Cartesian3.subtract(world, view.cameraPosition(), scratchDir); - if (Cesium.Cartesian3.dot(scratchDir, view.cameraDirection()) <= 0) + geo.cartesian3.subtract(world, view.cameraPosition(), scratchDir); + if (geo.cartesian3.dot(scratchDir, view.cameraDirection()) <= 0) return null; // Reject points beyond the globe horizon. if (!view.horizonVisible(world)) return null; @@ -392,7 +387,7 @@ export function createScreenAnnotationRenderer( const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1) const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop - const _scratchTrackedWin = new Cesium.Cartesian2(); + const _scratchTrackedWin = geo.cartesian2.create(); // Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance. function nearFarValue(nfs, dist) { @@ -428,7 +423,7 @@ export function createScreenAnnotationRenderer( if (sbd) scale = nearFarValue( sbd, - Cesium.Cartesian3.distance(view.cameraPosition(), world), + geo.cartesian3.distance(view.cameraPosition(), world), ); const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2; 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..f3589e0b1 --- /dev/null +++ b/src/app/loadCesium.js @@ -0,0 +1,134 @@ +// The production build externalizes Cesium: every `cesium` import reads the +// global that /cesium/Cesium.js defines, and the page loads that script only +// when something needs it. The dev server and Node import Cesium's ES module +// package instead, so there the SDK is always there and only the widget +// styles load. A Navara page that never needs Cesium never loads either. +// +// - `viewer()`: a Cesium globe is about to start: the SDK and the widget +// styles; +// - `sdk()`: a feature that still builds on Cesium's entity model (the Nepal +// event and locator, imported KML) is used on Navara: the SDK alone. +export function createCesiumLoader({ + document = globalThis.document, + globals = globalThis, + base = `${import.meta.env?.BASE_URL ?? '/'}cesium/`, + script = import.meta.env?.PROD === true, +} = {}) { + let sdkPending = null; + let stylesPending = null; + const root = () => { + globals.CESIUM_BASE_URL ??= base; + return globals.CESIUM_BASE_URL.replace(/\/?$/, '/'); + }; + // A failed load leaves no element behind and can be retried. + const load = (tag, url) => { + const element = document.createElement(tag); + if (tag === 'script') element.src = url; + else { + element.rel = 'stylesheet'; + element.href = url; + } + return new Promise((resolve, reject) => { + element.onload = () => { + element.onload = element.onerror = null; + resolve(); + }; + element.onerror = () => { + element.onload = element.onerror = null; + element.remove(); + reject(new Error(`Could not load ${url}`)); + }; + document.head.append(element); + }); + }; + // Read without running a getter: a page may define the global as one. + const ready = () => + !script || + Object.getOwnPropertyDescriptor(globals, 'Cesium')?.value != null; + const sdk = () => { + if (ready()) return Promise.resolve(); + sdkPending ??= load('script', `${root()}Cesium.js`).catch((error) => { + sdkPending = null; + throw error; + }); + return sdkPending; + }; + const styles = () => { + stylesPending ??= load('link', `${root()}Widgets/widgets.css`).catch( + (error) => { + stylesPending = null; + throw error; + }, + ); + return stylesPending; + }; + return { + ready, + sdk, + viewer: () => Promise.all([styles(), sdk()]).then(() => undefined), + }; +} + +let loader; +const shared = () => (loader ??= createCesiumLoader()); + +/** Before a Cesium globe starts: the SDK and its widget styles. */ +export function loadCesium() { + return shared().viewer(); +} + +/** Before a feature that still builds on Cesium's entity model runs on Navara. */ +export function loadCesiumSdk() { + return shared().sdk(); +} + +/** Whether `cesium` imports can be read now, without waiting. */ +export function cesiumSdkReady() { + return shared().ready(); +} + +/** + * Run `start` now when the SDK is there, else once it has loaded (a promise, + * nothing when `signal` aborted meanwhile): a layer enable that is + * synchronous everywhere but on a Navara page without the SDK yet. + */ +export function whenCesiumSdk(start, signal) { + if (cesiumSdkReady()) return start(); + return loadCesiumSdk().then(() => (signal?.aborted ? undefined : start())); +} + +/** + * Production page, once at boot: until Cesium.js has run, a read of the + * `Cesium` global (what every `cesium` import reads in this build) logs where + * it came from and starts loading the SDK. A path that still needs Cesium on + * a Navara page then fails once, visibly, and works when it runs again, + * instead of failing for the rest of the session. Cesium.js's own assignment + * turns the global back into a plain value. A global the page already has + * (the strict QA trap, a host's own SDK) is left alone. + */ +export function guardCesiumGlobal({ + globals = globalThis, + script = import.meta.env?.PROD === true, + report = (stack) => + console.error('[Cesium] read before the SDK loaded', stack), + load = loadCesiumSdk, +} = {}) { + if (!script || Object.getOwnPropertyDescriptor(globals, 'Cesium')) return; + let reports = 0; + Object.defineProperty(globals, 'Cesium', { + configurable: true, + get() { + if (reports++ < 10) report(new Error('Cesium read').stack); + load().catch(() => {}); + return undefined; + }, + set(value) { + Object.defineProperty(globals, 'Cesium', { + value, + writable: true, + configurable: true, + enumerable: true, + }); + }, + }); +} diff --git a/src/app/loadCesium.test.mjs b/src/app/loadCesium.test.mjs new file mode 100644 index 000000000..bf7d0631f --- /dev/null +++ b/src/app/loadCesium.test.mjs @@ -0,0 +1,151 @@ +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 = {}, { script = true } = {}) { + const elements = []; + const document = { + createElement: (tag) => ({ + tag, + remove() { + elements.splice(elements.indexOf(this), 1); + }, + }), + head: { append: (element) => elements.push(element) }, + }; + return { + elements, + globals, + loader: createCesiumLoader({ + document, + globals, + base: '/app/cesium/', + script, + }), + }; +} + +test('a Cesium globe loads the SDK and its styles together, once, on demand', async () => { + const { elements, globals, loader } = fixture(); + assert.equal(elements.length, 0); + const first = loader.viewer(); + 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'], + ); + const again = loader.viewer(); + assert.equal(elements.length, 2, 'a second request shares the first'); + elements.forEach((e) => e.onload()); + await first; + await again; + await loader.viewer(); + assert.equal(elements.length, 2); +}); + +test('a feature on Navara loads the SDK alone, and a later globe adds only the styles', async () => { + const { elements, loader } = fixture(); + const sdk = loader.sdk(); + assert.deepEqual( + elements.map((e) => e.src ?? e.href), + ['/app/cesium/Cesium.js'], + ); + elements[0].onload(); + await sdk; + const viewer = loader.viewer(); + assert.deepEqual( + elements.map((e) => e.src ?? e.href), + ['/app/cesium/Cesium.js', '/app/cesium/Widgets/widgets.css'], + ); + elements[1].onload(); + await viewer; +}); + +test('the SDK is not loaded where the page already has it or imports its module', async () => { + const present = fixture({ Cesium: {} }); + await present.loader.sdk(); + assert.equal(present.elements.length, 0); + const getter = {}; + Object.defineProperty(getter, 'Cesium', { get: () => ({}) }); + const trapped = fixture(getter); + trapped.loader.sdk(); + assert.equal(trapped.elements.length, 1, 'a getter is not read as the SDK'); + const dev = fixture({}, { script: false }); + await dev.loader.sdk(); + assert.equal(dev.elements.length, 0); +}); + +test('a failed Cesium asset load can be retried and preserves the configured base', async () => { + const { elements, globals, loader } = fixture({ + CESIUM_BASE_URL: '/custom/', + }); + const failed = loader.viewer(); + elements[1].onerror(); + await assert.rejects(failed, /Could not load/); + assert.deepEqual( + elements.map((e) => e.href), + ['/custom/Widgets/widgets.css'], + 'the failed script is removed', + ); + const retried = loader.viewer(); + 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); +}); + +test('a read before the SDK loads is reported and starts the load; the SDK then reads plainly', async () => { + const { guardCesiumGlobal } = await import('./loadCesium.js'); + const globals = {}; + const reports = []; + let loads = 0; + guardCesiumGlobal({ + globals, + script: true, + report: (stack) => reports.push(stack), + load: async () => loads++, + }); + assert.equal(globals.Cesium, undefined); + assert.equal(reports.length, 1); + assert.equal(loads, 1); + const sdk = { Cartesian3: {} }; + globals.Cesium = sdk; + assert.equal(globals.Cesium, sdk); + assert.equal(Object.getOwnPropertyDescriptor(globals, 'Cesium').value, sdk); + assert.equal(reports.length, 1); + const trapped = { Cesium: 'host' }; + guardCesiumGlobal({ globals: trapped, script: true }); + assert.equal(trapped.Cesium, 'host', 'an existing global is left alone'); + const dev = {}; + guardCesiumGlobal({ globals: dev, script: false }); + assert.equal(Object.getOwnPropertyDescriptor(dev, 'Cesium'), undefined); +}); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 746e77944..86555c3f6 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/cameraVerbs.js b/src/cameraVerbs.js index 8b23d6a40..3f7e8efe9 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1786,11 +1786,13 @@ function orbitOffset(position, heading, target) { const carto = geo.cartographic.fromCartesian(position); const tCarto = geo.cartographic.fromCartesian(target); const dh = carto.height - tCarto.height; - return new Cesium.HeadingPitchRange( + // A plain heading/pitch/range: Cesium's lookAt reads the fields, and the + // Cesium SDK is not loaded on a Navara page. + return { heading, - -Math.asin(Math.min(1, Math.max(-1, dh / Math.max(1, range)))), + pitch: -Math.asin(Math.min(1, Math.max(-1, dh / Math.max(1, range)))), range, - ); + }; } /** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */ diff --git a/src/celestialRing.js b/src/celestialRing.js index f7122c437..dfd3df4fd 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -2,10 +2,11 @@ import * as geo from './geo/index.js'; import { createCelestialView } from './celestialRing/view.js'; import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; import { - createCelestialEphemeris, + cesiumIcrfToFixed, projectCelestialPoint, flyCelestialViewer, } from './celestialRing/render/cesium.js'; +import { createCelestialEphemeris } from './celestialRing/ephemeris.js'; import { appClock } from './time/clock.js'; import { governorRequestRender } from './renderGovernor.js'; @@ -431,7 +432,10 @@ export class CelestialRing { this._moonFixed = geo.cartesian3.create(); this._toCenter = geo.cartesian3.create(); this._screenCenter = geo.cartesian2.create(); - this._ephemeris = createCelestialEphemeris(); + // A Cesium viewer's ICRF rotation; an engine (Navara) takes the TEME one. + this._ephemeris = createCelestialEphemeris( + viewer?.kind ? {} : { icrfToFixed: cesiumIcrfToFixed }, + ); this._initView(store); this._removePostRender = this.viewer.scene.postRender.addEventListener(() => diff --git a/src/celestialRing/ephemeris.js b/src/celestialRing/ephemeris.js new file mode 100644 index 000000000..3d98cc8d8 --- /dev/null +++ b/src/celestialRing/ephemeris.js @@ -0,0 +1,28 @@ +import * as geo from '../geo/index.js'; + +/** + * Earth-fixed unit directions to the Sun and the Moon at an epoch: Cesium's + * planetary model (geo's bit-identical port), rotated to the Earth with + * `icrfToFixed(epochMs, result)` where it answers (a Cesium globe once its + * ICRF data has loaded) and with the TEME rotation otherwise, as Cesium's + * own fallback does. Needs no Cesium SDK, so it runs on a Navara page. + */ +export function createCelestialEphemeris({ icrfToFixed = null } = {}) { + const sunInertial = geo.cartesian3.create(); + const moonInertial = geo.cartesian3.create(); + const fixedMatrix = geo.matrix3.create(); + return (epochMs, sunFixed, moonFixed) => { + const time = geo.julianDate.fromDate(new Date(epochMs)); + geo.simon1994.computeSunPositionInEarthInertialFrame(time, sunInertial); + geo.simon1994.computeMoonPositionInEarthInertialFrame(time, moonInertial); + const matrix = + icrfToFixed?.(epochMs, fixedMatrix) || + geo.transforms.computeTemeToPseudoFixedMatrix(time, fixedMatrix); + if (!matrix) return false; + geo.matrix3.multiplyByVector(matrix, sunInertial, sunFixed); + geo.matrix3.multiplyByVector(matrix, moonInertial, moonFixed); + geo.cartesian3.normalize(sunFixed, sunFixed); + geo.cartesian3.normalize(moonFixed, moonFixed); + return true; + }; +} diff --git a/src/celestialRing/render/cesium.js b/src/celestialRing/render/cesium.js index d4ff8b432..a181cbd21 100644 --- a/src/celestialRing/render/cesium.js +++ b/src/celestialRing/render/cesium.js @@ -1,30 +1,11 @@ -// Exact legacy planetary model and bare-viewer fallback for CelestialRing. +// Cesium's ICRF rotation and the bare-viewer fallback for CelestialRing. import * as Cesium from 'cesium'; -export function createCelestialEphemeris() { - const sunInertial = new Cesium.Cartesian3(); - const moonInertial = new Cesium.Cartesian3(); - const fixedMatrix = new Cesium.Matrix3(); - return (epochMs, sunFixed, moonFixed) => { - const time = Cesium.JulianDate.fromDate(new Date(epochMs)); - Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame( - time, - sunInertial, - ); - Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame( - time, - moonInertial, - ); - const matrix = - Cesium.Transforms.computeIcrfToFixedMatrix(time, fixedMatrix) || - Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, fixedMatrix); - if (!matrix) return false; - Cesium.Matrix3.multiplyByVector(matrix, sunInertial, sunFixed); - Cesium.Matrix3.multiplyByVector(matrix, moonInertial, moonFixed); - Cesium.Cartesian3.normalize(sunFixed, sunFixed); - Cesium.Cartesian3.normalize(moonFixed, moonFixed); - return true; - }; -} +/** Cesium's ICRF-to-fixed rotation, once its data has loaded; else undefined. */ +export const cesiumIcrfToFixed = (epochMs, result) => + Cesium.Transforms.computeIcrfToFixedMatrix( + Cesium.JulianDate.fromDate(new Date(epochMs)), + result, + ); export const projectCelestialPoint = (scene, position, result) => Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, result); export const flyCelestialViewer = (viewer, options) => 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..c06549f36 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -1,5 +1,6 @@ import { waitForSignal } from '../sources/requestDeadline.js'; import * as Cesium from 'cesium'; +import { cesiumSdkReady, loadCesiumSdk } from '../app/loadCesium.js'; import { isPointerFree } from './inputOwnership.js'; import { clearOverlaySource, @@ -47,8 +48,9 @@ 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 +1079,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 +2572,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 +2621,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, @@ -3540,6 +3542,11 @@ export function createBhoteKoshiEventLayer({ if (!_viewer && _engine) _native = createNativeRenderer(engine); _embeddedMedia?.attachScene?.(viewer, engine); if (!_enabled) return; + // Its enable has loaded the SDK; a restored attachment waits for it too. + if (!cesiumSdkReady()) { + await untilSceneReleased(loadCesiumSdk(), signal); + if (!current()) return; + } // The catalog has rebound the incoming map controller before this call. if (_mapStackController) { _comparisonLease = acquireImageryComparison(_mapStackController, { @@ -3618,6 +3625,12 @@ export function createBhoteKoshiEventLayer({ } async function enable(viewer, { origin = 'local-restore', signal } = {}) { + // The imagery, flood and corridor are Cesium entities on either globe, so + // a Navara page loads the Cesium SDK for them first. + if (!cesiumSdkReady()) { + await loadCesiumSdk(); + throwIfEnableCancelled(signal, true); + } _viewer = viewer; _enabled = true; // Public lifecycle passes signal, not intent origin, to enable. Its atomic diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index dece2920a..1e6ca9ce2 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { cesiumSdkReady, loadCesiumSdk } from '../app/loadCesium.js'; import { clearOverlaySource, setOverlayEntries, @@ -144,10 +145,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, @@ -521,7 +522,7 @@ export function createBhoteKoshiLocatorLayer({ sequenceStartedAt, visibleAfterMs = 0, ) { - addCallout(dataSource, INCIDENT, INCIDENT_COLOR, { + addCallout(dataSource, INCIDENT, INCIDENT_COLOR(), { sequenceStartedAt, visibleAfterMs, }); @@ -555,12 +556,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), @@ -705,7 +706,7 @@ export function createBhoteKoshiLocatorLayer({ labelPlacement = 'right', { showAnchor = true } = {}, ) { - addCallout(dataSource, NEPAL, BORDER_COLOR, { + addCallout(dataSource, NEPAL, BORDER_COLOR(), { labelPlacement, sequenceStartedAt, showAnchor, @@ -820,12 +821,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), @@ -890,7 +891,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, }, }); @@ -915,7 +916,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, }, }); @@ -925,7 +926,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, }, }); @@ -935,7 +936,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, }, }); @@ -1084,6 +1085,12 @@ export function createBhoteKoshiLocatorLayer({ } async function enable(viewer, { signal } = {}) { + // The callouts and flood path are Cesium entities on either globe, so a + // Navara page loads the Cesium SDK for them first. + if (!cesiumSdkReady()) { + await loadCesiumSdk(); + if (signal?.aborted) throw abortError(); + } _viewer = viewer; _enabled = true; _boundaryReady = false; diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index 62b502d4c..1acab1c1c 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -1,5 +1,6 @@ /** Session-local geodata loading with bounded geometry and local-only file references. */ import * as Cesium from 'cesium'; +import { cesiumSdkReady, loadCesiumSdk } from '../app/loadCesium.js'; import { createImportImageBudget } from './geoImportImages.js'; import { createImportedPins } from './geoImportPins.js'; import { msg } from '../i18n/marker.js'; @@ -1416,6 +1417,9 @@ export async function loadGeoFile( throw new GeoImportError( msg('Only GeoJSON, KML, KMZ and GPX files can be imported.'), ); + // The import builds Cesium data sources on either globe: a Navara page + // loads the Cesium SDK first. + if (!cesiumSdkReady()) await boundedImport(loadCesiumSdk(), signal); const stroke = Cesium.Color.fromCssColorString(color); const urls = []; let pins; 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/cesiumConstants.js b/src/data/render/cesiumConstants.js index 44d7054e4..afa1cab2a 100644 --- a/src/data/render/cesiumConstants.js +++ b/src/data/render/cesiumConstants.js @@ -1,5 +1,7 @@ // Native constants shared by the W1 adapters; keep the SDK import here. -export { - Axis as modelAxis, - ScreenSpaceEventType as trackingEventTypes, -} from 'cesium'; +// Read on call: the production build's Cesium global loads with the first +// Cesium globe, after this module has loaded on a Navara start. +import { Axis, ScreenSpaceEventType } from 'cesium'; + +export const modelAxis = () => Axis; +export const trackingEventTypes = () => ScreenSpaceEventType; 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/data/trackingClickGesture.js b/src/data/trackingClickGesture.js index 7ac611893..b623fd27d 100644 --- a/src/data/trackingClickGesture.js +++ b/src/data/trackingClickGesture.js @@ -47,7 +47,7 @@ export function isTrackingClickGesture(gesture = {}) { */ export function bindTrackingClickGesture(handler, onClick, options = {}) { const now = options.now || (() => performance.now()); - const eventTypes = options.eventTypes || trackingEventTypes; + const eventTypes = options.eventTypes || trackingEventTypes(); let pressActive = false; let pressStartedAt = 0; let previousPosition = null; diff --git a/src/geo/index.js b/src/geo/index.js index a5652dae6..c80a164ca 100644 --- a/src/geo/index.js +++ b/src/geo/index.js @@ -16,3 +16,5 @@ export * as transforms from './transforms.js'; export * as ellipsoidGeodesic from './ellipsoidGeodesic.js'; export * as boundingSphere from './boundingSphere.js'; +export * as julianDate from './julianDate.js'; +export * as simon1994 from './simon1994.js'; diff --git a/src/geo/julianDate.js b/src/geo/julianDate.js new file mode 100644 index 000000000..fd37b56c7 --- /dev/null +++ b/src/geo/julianDate.js @@ -0,0 +1,193 @@ +// Ported from CesiumJS 1.138 (Apache-2.0) — JulianDate.js +// +// The part of Cesium's JulianDate that the planetary positions need: a date +// as {dayNumber, secondsOfDay} in International Atomic Time (TAI), made from +// a JavaScript Date through Cesium's leap second table. The operation order +// matches Cesium so the values are bit-identical. + +const SECONDS_PER_DAY = 86400.0; + +// Cesium's JulianDate.leapSeconds: [TAI day, TAI seconds of day, TAI - UTC]. +const LEAP_SECONDS = [ + [2441317, 43210.0, 10], // January 1, 1972 00:00:00 UTC + [2441499, 43211.0, 11], // July 1, 1972 00:00:00 UTC + [2441683, 43212.0, 12], // January 1, 1973 00:00:00 UTC + [2442048, 43213.0, 13], // January 1, 1974 00:00:00 UTC + [2442413, 43214.0, 14], // January 1, 1975 00:00:00 UTC + [2442778, 43215.0, 15], // January 1, 1976 00:00:00 UTC + [2443144, 43216.0, 16], // January 1, 1977 00:00:00 UTC + [2443509, 43217.0, 17], // January 1, 1978 00:00:00 UTC + [2443874, 43218.0, 18], // January 1, 1979 00:00:00 UTC + [2444239, 43219.0, 19], // January 1, 1980 00:00:00 UTC + [2444786, 43220.0, 20], // July 1, 1981 00:00:00 UTC + [2445151, 43221.0, 21], // July 1, 1982 00:00:00 UTC + [2445516, 43222.0, 22], // July 1, 1983 00:00:00 UTC + [2446247, 43223.0, 23], // July 1, 1985 00:00:00 UTC + [2447161, 43224.0, 24], // January 1, 1988 00:00:00 UTC + [2447892, 43225.0, 25], // January 1, 1990 00:00:00 UTC + [2448257, 43226.0, 26], // January 1, 1991 00:00:00 UTC + [2448804, 43227.0, 27], // July 1, 1992 00:00:00 UTC + [2449169, 43228.0, 28], // July 1, 1993 00:00:00 UTC + [2449534, 43229.0, 29], // July 1, 1994 00:00:00 UTC + [2450083, 43230.0, 30], // January 1, 1996 00:00:00 UTC + [2450630, 43231.0, 31], // July 1, 1997 00:00:00 UTC + [2451179, 43232.0, 32], // January 1, 1999 00:00:00 UTC + [2453736, 43233.0, 33], // January 1, 2006 00:00:00 UTC + [2454832, 43234.0, 34], // January 1, 2009 00:00:00 UTC + [2456109, 43235.0, 35], // July 1, 2012 00:00:00 UTC + [2457204, 43236.0, 36], // July 1, 2015 00:00:00 UTC + [2457754, 43237.0, 37], // January 1, 2017 00:00:00 UTC +].map(([dayNumber, secondsOfDay, offset]) => ({ + julianDate: { dayNumber, secondsOfDay }, + offset, +})); + +function setComponents(wholeDays, secondsOfDay, julianDate) { + const extraDays = (secondsOfDay / SECONDS_PER_DAY) | 0; + wholeDays += extraDays; + secondsOfDay -= SECONDS_PER_DAY * extraDays; + + if (secondsOfDay < 0) { + wholeDays--; + secondsOfDay += SECONDS_PER_DAY; + } + + julianDate.dayNumber = wholeDays; + julianDate.secondsOfDay = secondsOfDay; + return julianDate; +} + +function compare(left, right) { + const julianDayNumberDifference = left.dayNumber - right.dayNumber; + if (julianDayNumberDifference !== 0) { + return julianDayNumberDifference; + } + return left.secondsOfDay - right.secondsOfDay; +} + +// Cesium's binarySearch over the leap second table. +function searchLeapSeconds(julianDate) { + let low = 0; + let high = LEAP_SECONDS.length - 1; + while (low <= high) { + const i = ~~((low + high) / 2); + const comparison = compare(LEAP_SECONDS[i].julianDate, julianDate); + if (comparison < 0) { + low = i + 1; + continue; + } + if (comparison > 0) { + high = i - 1; + continue; + } + return i; + } + return ~(high + 1); +} + +function secondsDifference(left, right) { + const dayDifference = (left.dayNumber - right.dayNumber) * SECONDS_PER_DAY; + return dayDifference + (left.secondsOfDay - right.secondsOfDay); +} + +function convertUtcToTai(julianDate) { + let index = searchLeapSeconds(julianDate); + if (index < 0) { + index = ~index; + } + if (index >= LEAP_SECONDS.length) { + index = LEAP_SECONDS.length - 1; + } + let offset = LEAP_SECONDS[index].offset; + if (index > 0) { + const difference = secondsDifference( + LEAP_SECONDS[index].julianDate, + julianDate, + ); + if (difference > offset) { + index--; + offset = LEAP_SECONDS[index].offset; + } + } + addSeconds(julianDate, offset, julianDate); +} + +/** A TAI date from its whole Julian day number and seconds into that day. */ +export function fromTai(julianDayNumber, secondsOfDay) { + const wholeDays = julianDayNumber | 0; + secondsOfDay = secondsOfDay + (julianDayNumber - wholeDays) * SECONDS_PER_DAY; + return setComponents(wholeDays, secondsOfDay, { + dayNumber: 0, + secondsOfDay: 0, + }); +} + +/** Cesium's JulianDate.fromDate: a valid JavaScript Date as a TAI date. */ +export function fromDate(date, result) { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth() + 1; + const day = date.getUTCDate(); + let hour = date.getUTCHours(); + const minute = date.getUTCMinutes(); + const second = date.getUTCSeconds(); + const millisecond = date.getUTCMilliseconds(); + + // Algorithm from page 604 of the Explanatory Supplement to the + // Astronomical Almanac (Seidelmann 1992). + const a = ((month - 14) / 12) | 0; + const b = year + 4800 + a; + let dayNumber = + (((1461 * b) / 4) | 0) + + (((367 * (month - 2 - 12 * a)) / 12) | 0) - + (((3 * (((b + 100) / 100) | 0)) / 4) | 0) + + day - + 32075; + + // JulianDates are noon-based + hour = hour - 12; + if (hour < 0) { + hour += 24; + } + + const secondsOfDay = + second + (hour * 3600.0 + minute * 60.0 + millisecond * 0.001); + + if (secondsOfDay >= 43200.0) { + dayNumber -= 1; + } + + result = setComponents( + dayNumber, + secondsOfDay, + result ?? { dayNumber: 0, secondsOfDay: 0 }, + ); + convertUtcToTai(result); + return result; +} + +/** The date `seconds` later (earlier when negative); `result` is required. */ +export function addSeconds(julianDate, seconds, result) { + return setComponents( + julianDate.dayNumber, + julianDate.secondsOfDay + seconds, + result, + ); +} + +/** The date as a fractional Julian day number. */ +export function totalDays(julianDate) { + return julianDate.dayNumber + julianDate.secondsOfDay / SECONDS_PER_DAY; +} + +/** How many seconds TAI is ahead of UTC at the date. */ +export function computeTaiMinusUtc(julianDate) { + let index = searchLeapSeconds(julianDate); + if (index < 0) { + index = ~index; + --index; + if (index < 0) { + index = 0; + } + } + return LEAP_SECONDS[index].offset; +} diff --git a/src/geo/simon1994.js b/src/geo/simon1994.js new file mode 100644 index 000000000..2d0af7fe4 --- /dev/null +++ b/src/geo/simon1994.js @@ -0,0 +1,605 @@ +// Ported from CesiumJS 1.138 (Apache-2.0) — Simon1994PlanetaryPositions.js +// +// The Sun's and the Moon's positions in the Earth-centred inertial frame +// (Simon et al. 1994), for the celestial ring on a page without the Cesium +// SDK. The operation order matches Cesium so the outputs are bit-identical; +// Cesium's debug range checks are left out (its inputs are its own orbit +// constants). Dates are geo/julianDate.js values. + +import * as Cartesian3 from './cartesian3.js'; +import * as Matrix3 from './matrix3.js'; +import * as CesiumMath from './math.js'; +import * as JulianDate from './julianDate.js'; + +const SECONDS_PER_DAY = 86400.0; +const DAYS_PER_JULIAN_CENTURY = 36525.0; +const RADIANS_PER_ARCSECOND = CesiumMath.RADIANS_PER_DEGREE / 3600.0; + +/** Cesium's `new Matrix3(...)`: row-major arguments, column-major storage. */ +function rowMajor(c0r0, c1r0, c2r0, c0r1, c1r1, c2r1, c0r2, c1r2, c2r2) { + return [c0r0, c0r1, c0r2, c1r0, c1r1, c1r2, c2r0, c2r1, c2r2]; +} + +function computeTdbMinusTtSpice(daysSinceJ2000InTerrestrialTime) { + /* STK Comments ------------------------------------------------------ + * This function uses constants designed to be consistent with + * the SPICE Toolkit from JPL version N0051 (unitim.c) + * M0 = 6.239996 + * M0Dot = 1.99096871e-7 rad/s = 0.01720197 rad/d + * EARTH_ECC = 1.671e-2 + * TDB_AMPL = 1.657e-3 secs + *--------------------------------------------------------------------*/ + + //* Values taken as specified in STK Comments except: 0.01720197 rad/day = 1.99096871e-7 rad/sec + //* Here we use the more precise value taken from the SPICE value 1.99096871e-7 rad/sec converted to rad/day + //* All other constants are consistent with the SPICE implementation of the TDB conversion + //* except where we treat the independent time parameter to be in TT instead of TDB. + //* This is an approximation made to facilitate performance due to the higher prevalance of + //* the TT2TDB conversion over TDB2TT in order to avoid having to iterate when converting to TDB for the JPL ephemeris. + //* Days are used instead of seconds to provide a slight improvement in numerical precision. + + //* For more information see: + //* http://www.cv.nrao.edu/~rfisher/Ephemerides/times.html#TDB + //* ftp://ssd.jpl.nasa.gov/pub/eph/planets/ioms/ExplSupplChap8.pdf + + const g = 6.239996 + 0.0172019696544 * daysSinceJ2000InTerrestrialTime; + return 1.657e-3 * Math.sin(g + 1.671e-2 * Math.sin(g)); +} + +const TdtMinusTai = 32.184; +const J2000d = 2451545; +function taiToTdb(date, result) { + //Converts TAI to TT + result = JulianDate.addSeconds(date, TdtMinusTai, result); + + //Converts TT to TDB + const days = JulianDate.totalDays(result) - J2000d; + result = JulianDate.addSeconds(result, computeTdbMinusTtSpice(days), result); + + return result; +} + +const epoch = JulianDate.fromTai(2451545, 0); //Actually TDB (not TAI) +const MetersPerKilometer = 1000.0; +const RadiansPerDegree = CesiumMath.RADIANS_PER_DEGREE; +const RadiansPerArcSecond = RADIANS_PER_ARCSECOND; +const MetersPerAstronomicalUnit = 1.4959787e11; // IAU 1976 value + +const perifocalToEquatorial = Matrix3.create(); +function elementsToCartesian( + semimajorAxis, + eccentricity, + inclination, + longitudeOfPerigee, + longitudeOfNode, + meanLongitude, + result, +) { + if (inclination < 0.0) { + inclination = -inclination; + longitudeOfNode += Math.PI; + } + + const radiusOfPeriapsis = semimajorAxis * (1.0 - eccentricity); + const argumentOfPeriapsis = longitudeOfPerigee - longitudeOfNode; + const rightAscensionOfAscendingNode = longitudeOfNode; + const trueAnomaly = meanAnomalyToTrueAnomaly( + meanLongitude - longitudeOfPerigee, + eccentricity, + ); + const type = chooseOrbit(eccentricity, 0.0); + + perifocalToCartesianMatrix( + argumentOfPeriapsis, + inclination, + rightAscensionOfAscendingNode, + perifocalToEquatorial, + ); + const semilatus = radiusOfPeriapsis * (1.0 + eccentricity); + const costheta = Math.cos(trueAnomaly); + const sintheta = Math.sin(trueAnomaly); + + const denom = 1.0 + eccentricity * costheta; + + const radius = semilatus / denom; + if (result == null) { + result = Cartesian3.create(radius * costheta, radius * sintheta, 0.0); + } else { + result.x = radius * costheta; + result.y = radius * sintheta; + result.z = 0.0; + } + + return Matrix3.multiplyByVector(perifocalToEquatorial, result, result); +} + +function chooseOrbit(eccentricity, tolerance) { + if (eccentricity <= tolerance) { + return 'Circular'; + } else if (eccentricity < 1.0 - tolerance) { + return 'Elliptical'; + } else if (eccentricity <= 1.0 + tolerance) { + return 'Parabolic'; + } + return 'Hyperbolic'; +} + +// Calculates the true anomaly given the mean anomaly and the eccentricity. +function meanAnomalyToTrueAnomaly(meanAnomaly, eccentricity) { + const eccentricAnomaly = meanAnomalyToEccentricAnomaly( + meanAnomaly, + eccentricity, + ); + return eccentricAnomalyToTrueAnomaly(eccentricAnomaly, eccentricity); +} + +const maxIterationCount = 50; +const keplerEqConvergence = 1.0e-8; +// Calculates the eccentric anomaly given the mean anomaly and the eccentricity. +function meanAnomalyToEccentricAnomaly(meanAnomaly, eccentricity) { + const revs = Math.floor(meanAnomaly / CesiumMath.TWO_PI); + + // Find angle in current revolution + meanAnomaly -= revs * CesiumMath.TWO_PI; + + // calculate starting value for iteration sequence + let iterationValue = + meanAnomaly + + (eccentricity * Math.sin(meanAnomaly)) / + (1.0 - Math.sin(meanAnomaly + eccentricity) + Math.sin(meanAnomaly)); + + // Perform Newton-Raphson iteration on Kepler's equation + let eccentricAnomaly = Number.MAX_VALUE; + + let count; + for ( + count = 0; + count < maxIterationCount && + Math.abs(eccentricAnomaly - iterationValue) > keplerEqConvergence; + ++count + ) { + eccentricAnomaly = iterationValue; + const NRfunction = + eccentricAnomaly - + eccentricity * Math.sin(eccentricAnomaly) - + meanAnomaly; + const dNRfunction = 1 - eccentricity * Math.cos(eccentricAnomaly); + iterationValue = eccentricAnomaly - NRfunction / dNRfunction; + } + + eccentricAnomaly = iterationValue + revs * CesiumMath.TWO_PI; + return eccentricAnomaly; +} + +// Calculates the true anomaly given the eccentric anomaly and the eccentricity. +function eccentricAnomalyToTrueAnomaly(eccentricAnomaly, eccentricity) { + // Calculate the number of previous revolutions + const revs = Math.floor(eccentricAnomaly / CesiumMath.TWO_PI); + + // Find angle in current revolution + eccentricAnomaly -= revs * CesiumMath.TWO_PI; + + // Calculate true anomaly from eccentric anomaly + const trueAnomalyX = Math.cos(eccentricAnomaly) - eccentricity; + const trueAnomalyY = + Math.sin(eccentricAnomaly) * Math.sqrt(1 - eccentricity * eccentricity); + + let trueAnomaly = Math.atan2(trueAnomalyY, trueAnomalyX); + + // Ensure the correct quadrant + trueAnomaly = CesiumMath.zeroToTwoPi(trueAnomaly); + if (eccentricAnomaly < 0) { + trueAnomaly -= CesiumMath.TWO_PI; + } + + // Add on previous revolutions + trueAnomaly += revs * CesiumMath.TWO_PI; + + return trueAnomaly; +} + +// Calculates the transformation matrix to convert from the perifocal (PQW) coordinate +// system to inertial cartesian coordinates. +function perifocalToCartesianMatrix( + argumentOfPeriapsis, + inclination, + rightAscension, + result, +) { + const cosap = Math.cos(argumentOfPeriapsis); + const sinap = Math.sin(argumentOfPeriapsis); + + const cosi = Math.cos(inclination); + const sini = Math.sin(inclination); + + const cosraan = Math.cos(rightAscension); + const sinraan = Math.sin(rightAscension); + if (result == null) { + result = rowMajor( + cosraan * cosap - sinraan * sinap * cosi, + -cosraan * sinap - sinraan * cosap * cosi, + sinraan * sini, + + sinraan * cosap + cosraan * sinap * cosi, + -sinraan * sinap + cosraan * cosap * cosi, + -cosraan * sini, + + sinap * sini, + cosap * sini, + cosi, + ); + } else { + result[0] = cosraan * cosap - sinraan * sinap * cosi; + result[1] = sinraan * cosap + cosraan * sinap * cosi; + result[2] = sinap * sini; + result[3] = -cosraan * sinap - sinraan * cosap * cosi; + result[4] = -sinraan * sinap + cosraan * cosap * cosi; + result[5] = cosap * sini; + result[6] = sinraan * sini; + result[7] = -cosraan * sini; + result[8] = cosi; + } + return result; +} + +// From section 5.8 +const semiMajorAxis0 = 1.0000010178 * MetersPerAstronomicalUnit; +const meanLongitude0 = 100.46645683 * RadiansPerDegree; +const meanLongitude1 = 1295977422.83429 * RadiansPerArcSecond; + +// From table 6 +const p1u = 16002; +const p2u = 21863; +const p3u = 32004; +const p4u = 10931; +const p5u = 14529; +const p6u = 16368; +const p7u = 15318; +const p8u = 32794; + +const Ca1 = 64 * 1e-7 * MetersPerAstronomicalUnit; +const Ca2 = -152 * 1e-7 * MetersPerAstronomicalUnit; +const Ca3 = 62 * 1e-7 * MetersPerAstronomicalUnit; +const Ca4 = -8 * 1e-7 * MetersPerAstronomicalUnit; +const Ca5 = 32 * 1e-7 * MetersPerAstronomicalUnit; +const Ca6 = -41 * 1e-7 * MetersPerAstronomicalUnit; +const Ca7 = 19 * 1e-7 * MetersPerAstronomicalUnit; +const Ca8 = -11 * 1e-7 * MetersPerAstronomicalUnit; + +const Sa1 = -150 * 1e-7 * MetersPerAstronomicalUnit; +const Sa2 = -46 * 1e-7 * MetersPerAstronomicalUnit; +const Sa3 = 68 * 1e-7 * MetersPerAstronomicalUnit; +const Sa4 = 54 * 1e-7 * MetersPerAstronomicalUnit; +const Sa5 = 14 * 1e-7 * MetersPerAstronomicalUnit; +const Sa6 = 24 * 1e-7 * MetersPerAstronomicalUnit; +const Sa7 = -28 * 1e-7 * MetersPerAstronomicalUnit; +const Sa8 = 22 * 1e-7 * MetersPerAstronomicalUnit; + +const q1u = 10; +const q2u = 16002; +const q3u = 21863; +const q4u = 10931; +const q5u = 1473; +const q6u = 32004; +const q7u = 4387; +const q8u = 73; + +const Cl1 = -325 * 1e-7; +const Cl2 = -322 * 1e-7; +const Cl3 = -79 * 1e-7; +const Cl4 = 232 * 1e-7; +const Cl5 = -52 * 1e-7; +const Cl6 = 97 * 1e-7; +const Cl7 = 55 * 1e-7; +const Cl8 = -41 * 1e-7; + +const Sl1 = -105 * 1e-7; +const Sl2 = -137 * 1e-7; +const Sl3 = 258 * 1e-7; +const Sl4 = 35 * 1e-7; +const Sl5 = -116 * 1e-7; +const Sl6 = -88 * 1e-7; +const Sl7 = -112 * 1e-7; +const Sl8 = -80 * 1e-7; + +const scratchDate = JulianDate.fromTai(0, 0.0); +// Gets a point describing the motion of the Earth-Moon barycenter according to the equations described in section 6. +function computeSimonEarthMoonBarycenter(date, result) { + // t is thousands of years from J2000 TDB + taiToTdb(date, scratchDate); + const x = + scratchDate.dayNumber - + epoch.dayNumber + + (scratchDate.secondsOfDay - epoch.secondsOfDay) / SECONDS_PER_DAY; + const t = x / (DAYS_PER_JULIAN_CENTURY * 10.0); + + const u = 0.3595362 * t; + const semimajorAxis = + semiMajorAxis0 + + Ca1 * Math.cos(p1u * u) + + Sa1 * Math.sin(p1u * u) + + Ca2 * Math.cos(p2u * u) + + Sa2 * Math.sin(p2u * u) + + Ca3 * Math.cos(p3u * u) + + Sa3 * Math.sin(p3u * u) + + Ca4 * Math.cos(p4u * u) + + Sa4 * Math.sin(p4u * u) + + Ca5 * Math.cos(p5u * u) + + Sa5 * Math.sin(p5u * u) + + Ca6 * Math.cos(p6u * u) + + Sa6 * Math.sin(p6u * u) + + Ca7 * Math.cos(p7u * u) + + Sa7 * Math.sin(p7u * u) + + Ca8 * Math.cos(p8u * u) + + Sa8 * Math.sin(p8u * u); + const meanLongitude = + meanLongitude0 + + meanLongitude1 * t + + Cl1 * Math.cos(q1u * u) + + Sl1 * Math.sin(q1u * u) + + Cl2 * Math.cos(q2u * u) + + Sl2 * Math.sin(q2u * u) + + Cl3 * Math.cos(q3u * u) + + Sl3 * Math.sin(q3u * u) + + Cl4 * Math.cos(q4u * u) + + Sl4 * Math.sin(q4u * u) + + Cl5 * Math.cos(q5u * u) + + Sl5 * Math.sin(q5u * u) + + Cl6 * Math.cos(q6u * u) + + Sl6 * Math.sin(q6u * u) + + Cl7 * Math.cos(q7u * u) + + Sl7 * Math.sin(q7u * u) + + Cl8 * Math.cos(q8u * u) + + Sl8 * Math.sin(q8u * u); + + // All constants in this part are from section 5.8 + const eccentricity = 0.0167086342 - 0.0004203654 * t; + const longitudeOfPerigee = + 102.93734808 * RadiansPerDegree + 11612.3529 * RadiansPerArcSecond * t; + const inclination = 469.97289 * RadiansPerArcSecond * t; + const longitudeOfNode = + 174.87317577 * RadiansPerDegree - 8679.27034 * RadiansPerArcSecond * t; + + return elementsToCartesian( + semimajorAxis, + eccentricity, + inclination, + longitudeOfPerigee, + longitudeOfNode, + meanLongitude, + result, + ); +} + +// Gets a point describing the position of the moon according to the equations described in section 4. +function computeSimonMoon(date, result) { + taiToTdb(date, scratchDate); + const x = + scratchDate.dayNumber - + epoch.dayNumber + + (scratchDate.secondsOfDay - epoch.secondsOfDay) / SECONDS_PER_DAY; + const t = x / DAYS_PER_JULIAN_CENTURY; + const t2 = t * t; + const t3 = t2 * t; + const t4 = t3 * t; + + // Terms from section 3.4 (b.1) + let semimajorAxis = 383397.7725 + 0.004 * t; + let eccentricity = 0.055545526 - 0.000000016 * t; + const inclinationConstant = 5.15668983 * RadiansPerDegree; + let inclinationSecPart = + -0.00008 * t + 0.02966 * t2 - 0.000042 * t3 - 0.00000013 * t4; + const longitudeOfPerigeeConstant = 83.35324312 * RadiansPerDegree; + let longitudeOfPerigeeSecPart = + 14643420.2669 * t - 38.2702 * t2 - 0.045047 * t3 + 0.00021301 * t4; + const longitudeOfNodeConstant = 125.04455501 * RadiansPerDegree; + let longitudeOfNodeSecPart = + -6967919.3631 * t + 6.3602 * t2 + 0.007625 * t3 - 0.00003586 * t4; + const meanLongitudeConstant = 218.31664563 * RadiansPerDegree; + let meanLongitudeSecPart = + 1732559343.4847 * t - 6.391 * t2 + 0.006588 * t3 - 0.00003169 * t4; + + // Delaunay arguments from section 3.5 b + const D = + 297.85019547 * RadiansPerDegree + + RadiansPerArcSecond * + (1602961601.209 * t - 6.3706 * t2 + 0.006593 * t3 - 0.00003169 * t4); + const F = + 93.27209062 * RadiansPerDegree + + RadiansPerArcSecond * + (1739527262.8478 * t - 12.7512 * t2 - 0.001037 * t3 + 0.00000417 * t4); + const l = + 134.96340251 * RadiansPerDegree + + RadiansPerArcSecond * + (1717915923.2178 * t + 31.8792 * t2 + 0.051635 * t3 - 0.0002447 * t4); + const lprime = + 357.52910918 * RadiansPerDegree + + RadiansPerArcSecond * + (129596581.0481 * t - 0.5532 * t2 + 0.000136 * t3 - 0.00001149 * t4); + const psi = + 310.17137918 * RadiansPerDegree - + RadiansPerArcSecond * + (6967051.436 * t + 6.2068 * t2 + 0.007618 * t3 - 0.00003219 * t4); + + // Add terms from Table 4 + const twoD = 2.0 * D; + const fourD = 4.0 * D; + const sixD = 6.0 * D; + const twol = 2.0 * l; + const threel = 3.0 * l; + const fourl = 4.0 * l; + const twoF = 2.0 * F; + semimajorAxis += + 3400.4 * Math.cos(twoD) - + 635.6 * Math.cos(twoD - l) - + 235.6 * Math.cos(l) + + 218.1 * Math.cos(twoD - lprime) + + 181.0 * Math.cos(twoD + l); + eccentricity += + 0.014216 * Math.cos(twoD - l) + + 0.008551 * Math.cos(twoD - twol) - + 0.001383 * Math.cos(l) + + 0.001356 * Math.cos(twoD + l) - + 0.001147 * Math.cos(fourD - threel) - + 0.000914 * Math.cos(fourD - twol) + + 0.000869 * Math.cos(twoD - lprime - l) - + 0.000627 * Math.cos(twoD) - + 0.000394 * Math.cos(fourD - fourl) + + 0.000282 * Math.cos(twoD - lprime - twol) - + 0.000279 * Math.cos(D - l) - + 0.000236 * Math.cos(twol) + + 0.000231 * Math.cos(fourD) + + 0.000229 * Math.cos(sixD - fourl) - + 0.000201 * Math.cos(twol - twoF); + inclinationSecPart += + 486.26 * Math.cos(twoD - twoF) - + 40.13 * Math.cos(twoD) + + 37.51 * Math.cos(twoF) + + 25.73 * Math.cos(twol - twoF) + + 19.97 * Math.cos(twoD - lprime - twoF); + longitudeOfPerigeeSecPart += + -55609 * Math.sin(twoD - l) - + 34711 * Math.sin(twoD - twol) - + 9792 * Math.sin(l) + + 9385 * Math.sin(fourD - threel) + + 7505 * Math.sin(fourD - twol) + + 5318 * Math.sin(twoD + l) + + 3484 * Math.sin(fourD - fourl) - + 3417 * Math.sin(twoD - lprime - l) - + 2530 * Math.sin(sixD - fourl) - + 2376 * Math.sin(twoD) - + 2075 * Math.sin(twoD - threel) - + 1883 * Math.sin(twol) - + 1736 * Math.sin(sixD - 5.0 * l) + + 1626 * Math.sin(lprime) - + 1370 * Math.sin(sixD - threel); + longitudeOfNodeSecPart += + -5392 * Math.sin(twoD - twoF) - + 540 * Math.sin(lprime) - + 441 * Math.sin(twoD) + + 423 * Math.sin(twoF) - + 288 * Math.sin(twol - twoF); + meanLongitudeSecPart += + -3332.9 * Math.sin(twoD) + + 1197.4 * Math.sin(twoD - l) - + 662.5 * Math.sin(lprime) + + 396.3 * Math.sin(l) - + 218.0 * Math.sin(twoD - lprime); + + // Add terms from Table 5 + const twoPsi = 2.0 * psi; + const threePsi = 3.0 * psi; + inclinationSecPart += + 46.997 * Math.cos(psi) * t - + 0.614 * Math.cos(twoD - twoF + psi) * t + + 0.614 * Math.cos(twoD - twoF - psi) * t - + 0.0297 * Math.cos(twoPsi) * t2 - + 0.0335 * Math.cos(psi) * t2 + + 0.0012 * Math.cos(twoD - twoF + twoPsi) * t2 - + 0.00016 * Math.cos(psi) * t3 + + 0.00004 * Math.cos(threePsi) * t3 + + 0.00004 * Math.cos(twoPsi) * t3; + const perigeeAndMean = + 2.116 * Math.sin(psi) * t - + 0.111 * Math.sin(twoD - twoF - psi) * t - + 0.0015 * Math.sin(psi) * t2; + longitudeOfPerigeeSecPart += perigeeAndMean; + meanLongitudeSecPart += perigeeAndMean; + longitudeOfNodeSecPart += + -520.77 * Math.sin(psi) * t + + 13.66 * Math.sin(twoD - twoF + psi) * t + + 1.12 * Math.sin(twoD - psi) * t - + 1.06 * Math.sin(twoF - psi) * t + + 0.66 * Math.sin(twoPsi) * t2 + + 0.371 * Math.sin(psi) * t2 - + 0.035 * Math.sin(twoD - twoF + twoPsi) * t2 - + 0.015 * Math.sin(twoD - twoF + psi) * t2 + + 0.0014 * Math.sin(psi) * t3 - + 0.0011 * Math.sin(threePsi) * t3 - + 0.0009 * Math.sin(twoPsi) * t3; + + // Add constants and convert units + semimajorAxis *= MetersPerKilometer; + const inclination = + inclinationConstant + inclinationSecPart * RadiansPerArcSecond; + const longitudeOfPerigee = + longitudeOfPerigeeConstant + + longitudeOfPerigeeSecPart * RadiansPerArcSecond; + const meanLongitude = + meanLongitudeConstant + meanLongitudeSecPart * RadiansPerArcSecond; + const longitudeOfNode = + longitudeOfNodeConstant + longitudeOfNodeSecPart * RadiansPerArcSecond; + + return elementsToCartesian( + semimajorAxis, + eccentricity, + inclination, + longitudeOfPerigee, + longitudeOfNode, + meanLongitude, + result, + ); +} + +// Gets a point describing the motion of the Earth. This point uses the Moon point and +// the 1992 mu value (ratio between Moon and Earth masses) in Table 2 of the paper in order +// to determine the position of the Earth relative to the Earth-Moon barycenter. +const moonEarthMassRatio = 0.012300034; // From 1992 mu value in Table 2 +const factor = (moonEarthMassRatio / (moonEarthMassRatio + 1.0)) * -1; +function computeSimonEarth(date, result) { + result = computeSimonMoon(date, result); + return Cartesian3.multiplyByScalar(result, factor, result); +} + +// Values for the axesTransformation needed for the rotation were found using the STK Components +// GeographicTransformer on the position of the sun center of mass point and the earth J2000 frame. + +const axesTransformation = rowMajor( + 1.0000000000000002, + 5.619723173785822e-16, + 4.690511510146299e-19, + -5.154129427414611e-16, + 0.9174820620691819, + -0.39777715593191376, + -2.23970096136568e-16, + 0.39777715593191376, + 0.9174820620691819, +); +let translation = Cartesian3.create(); + +/** + * Computes the position of the Sun in the Earth-centered inertial frame + * + * @param {JulianDate} [julianDate] The time at which to compute the Sun's position, if not provided the current system time is used. + * @param {Cartesian3} [result] The object onto which to store the result. + * @returns {Cartesian3} Calculated sun position + */ +export function computeSunPositionInEarthInertialFrame(julianDate, result) { + if (result == null) result = Cartesian3.create(); + + //first forward transformation + translation = computeSimonEarthMoonBarycenter(julianDate, translation); + result = Cartesian3.negate(translation, result); + + //second forward transformation + computeSimonEarth(julianDate, translation); + + Cartesian3.subtract(result, translation, result); + Matrix3.multiplyByVector(axesTransformation, result, result); + + return result; +} + +/** + * Computes the position of the Moon in the Earth-centered inertial frame + * + * @param {JulianDate} [julianDate] The time at which to compute the Moon's position, if not provided the current system time is used. + * @param {Cartesian3} [result] The object onto which to store the result. + * @returns {Cartesian3} Calculated moon position + */ +export function computeMoonPositionInEarthInertialFrame(julianDate, result) { + result = computeSimonMoon(julianDate, result); + Matrix3.multiplyByVector(axesTransformation, result, result); + + return result; +} diff --git a/src/geo/simon1994.test.mjs b/src/geo/simon1994.test.mjs new file mode 100644 index 000000000..a95f51562 --- /dev/null +++ b/src/geo/simon1994.test.mjs @@ -0,0 +1,61 @@ +// Bit-for-bit comparison of the Sun and Moon positions, the TAI dates they +// take and the TEME rotation with Cesium's, over dates from 1960 to 2100 +// (leap second edges included). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from './index.js'; + +const same = (a, b, label) => + assert.ok(Object.is(a, b), `${label}: ${a} !== ${b}`); +const dates = [ + Date.UTC(1960, 0, 1), + Date.UTC(1972, 0, 1), + Date.UTC(1972, 0, 1) - 1, + Date.UTC(2016, 11, 31, 23, 59, 59, 999), + Date.UTC(2017, 0, 1), + Date.UTC(2026, 10, 4, 12, 30, 15, 250), + Date.UTC(2100, 5, 30, 11, 59, 59, 500), +]; +for (let i = 0; i < 400; i++) + dates.push(Date.UTC(1990, 0, 1) + Math.floor(i * 7919.123 * 3600 * 1000)); + +test('dates from JavaScript Dates match Cesium JulianDate.fromDate', () => { + for (const ms of dates) { + const ours = geo.julianDate.fromDate(new Date(ms)); + const theirs = Cesium.JulianDate.fromDate(new Date(ms)); + same(ours.dayNumber, theirs.dayNumber, `${ms} day`); + same(ours.secondsOfDay, theirs.secondsOfDay, `${ms} seconds`); + same( + geo.julianDate.computeTaiMinusUtc(ours), + Cesium.JulianDate.computeTaiMinusUtc(theirs), + `${ms} TAI - UTC`, + ); + } +}); + +test('Sun and Moon inertial positions and the TEME rotation match Cesium bit for bit', () => { + const Simon = Cesium.Simon1994PlanetaryPositions; + for (const ms of dates) { + const ours = geo.julianDate.fromDate(new Date(ms)); + const theirs = Cesium.JulianDate.fromDate(new Date(ms)); + for (const [name, mine, cesium] of [ + [ + 'sun', + geo.simon1994.computeSunPositionInEarthInertialFrame(ours), + Simon.computeSunPositionInEarthInertialFrame(theirs), + ], + [ + 'moon', + geo.simon1994.computeMoonPositionInEarthInertialFrame(ours), + Simon.computeMoonPositionInEarthInertialFrame(theirs), + ], + ]) + for (const axis of ['x', 'y', 'z']) + same(mine[axis], cesium[axis], `${ms} ${name}.${axis}`); + const teme = geo.transforms.computeTemeToPseudoFixedMatrix(ours); + const cesiumTeme = Cesium.Transforms.computeTemeToPseudoFixedMatrix(theirs); + for (let k = 0; k < 9; k++) + same(teme[k], cesiumTeme[k], `${ms} teme[${k}]`); + } +}); diff --git a/src/geo/transforms.js b/src/geo/transforms.js index 76101a186..4fa6b60c3 100644 --- a/src/geo/transforms.js +++ b/src/geo/transforms.js @@ -9,7 +9,12 @@ import { DEBUG } from './debug.js'; import { Check } from './check.js'; import { DeveloperError } from './developerError.js'; -import { EPSILON14, equalsEpsilon as scalarEqualsEpsilon } from './math.js'; +import { + EPSILON14, + TWO_PI, + equalsEpsilon as scalarEqualsEpsilon, +} from './math.js'; +import * as JulianDate from './julianDate.js'; import { create as createCartesian3, cross, @@ -238,3 +243,57 @@ export function headingPitchRollToFixedFrame( result = fixedFrameTransform(origin, ellipsoid, result); return multiply(result, hprMatrix, result); } + +const gmstConstant0 = 6 * 3600 + 41 * 60 + 50.54841; +const gmstConstant1 = 8640184.812866; +const gmstConstant2 = 0.093104; +const gmstConstant3 = -6.2e-6; +const rateCoef = 1.1772758384668e-19; +const wgs84WRPrecessing = 7.2921158553e-5; +const twoPiOverSecondsInDay = TWO_PI / 86400.0; +let dateInUtc = { dayNumber: 0, secondsOfDay: 0 }; + +/** + * The rotation from the True Equator Mean Equinox (TEME) axes to the + * pseudo-fixed axes at a geo/julianDate.js date: Cesium's + * computeTemeToPseudoFixedMatrix, which takes UTC for UT1. + */ +export function computeTemeToPseudoFixedMatrix(date, result) { + dateInUtc = JulianDate.addSeconds( + date, + -JulianDate.computeTaiMinusUtc(date), + dateInUtc, + ); + const utcDayNumber = dateInUtc.dayNumber; + const utcSecondsIntoDay = dateInUtc.secondsOfDay; + + let t; + const diffDays = utcDayNumber - 2451545; + if (utcSecondsIntoDay >= 43200.0) { + t = (diffDays + 0.5) / 36525.0; + } else { + t = (diffDays - 0.5) / 36525.0; + } + + const gmst0 = + gmstConstant0 + + t * (gmstConstant1 + t * (gmstConstant2 + t * gmstConstant3)); + const angle = (gmst0 * twoPiOverSecondsInDay) % TWO_PI; + const ratio = wgs84WRPrecessing + rateCoef * (utcDayNumber - 2451545.5); + const secondsSinceMidnight = (utcSecondsIntoDay + 86400.0 * 0.5) % 86400.0; + const gha = angle + ratio * secondsSinceMidnight; + const cosGha = Math.cos(gha); + const sinGha = Math.sin(gha); + + if (result == null) result = new Array(9); + result[0] = cosGha; + result[1] = -sinGha; + result[2] = 0.0; + result[3] = sinGha; + result[4] = cosGha; + result[5] = 0.0; + result[6] = 0.0; + result[7] = 0.0; + result[8] = 1.0; + return result; +} 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/awareness/anchor.test.mjs b/src/layers/awareness/anchor.test.mjs index 0f29fc331..26b90c20d 100644 --- a/src/layers/awareness/anchor.test.mjs +++ b/src/layers/awareness/anchor.test.mjs @@ -43,7 +43,9 @@ test('passive Contacts releases its installation anchor and subject refresh cann const subject = createSubject({ state, services, parts }); subject.refreshSelectedSubject(); assert.equal(anchors.length, 1); - assert.ok(anchors[0] instanceof Cesium.Cartesian3); + // A copy of the subject's position, not the live one. + assert.notEqual(anchors[0], position); + assert.deepEqual({ ...anchors[0] }, { ...position }); controls.setParams({ passive: true }); assert.equal(anchors.at(-1), null); const released = anchors.length; @@ -57,5 +59,5 @@ test('passive Contacts releases its installation anchor and subject refresh cann controls.setParams({ passive: false }); assert.equal(activations, 1); subject.refreshSelectedSubject(); - assert.ok(anchors.at(-1) instanceof Cesium.Cartesian3); + assert.deepEqual({ ...anchors.at(-1) }, { ...position }); }); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 0d266e476..47bfec776 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -2,7 +2,6 @@ import { AWARENESS_RADIUS_M, AWARENESS_RELATIONSHIP, } from '../../data/militaryAwarenessEngine.js'; -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { t } from '../../i18n/index.js'; import { @@ -265,7 +264,7 @@ export function createSubject({ state: layerState, services, parts, source }) { layerState.display?.trackedEntity?.gevDisplayPosition?.(); if (trackedPosition) { return { - position: Cesium.Cartesian3.clone(trackedPosition), + position: geo.cartesian3.clone(trackedPosition), // Only the follow camera's own contact proves presence this way; a // different tracked entity says nothing about this subject. presence: @@ -280,7 +279,7 @@ export function createSubject({ state: layerState, services, parts, source }) { // awareness position instead of allocating/scanning up to 1,000 contacts. if (!allowCollectionMaterialization) { return { - position: Cesium.Cartesian3.clone(subject.position), + position: geo.cartesian3.clone(subject.position), presence: SUBJECT_PRESENCE.UNCHECKED, }; } @@ -295,7 +294,7 @@ export function createSubject({ state: layerState, services, parts, source }) { if (subject.layerId === 'ais-live-vessels') { if (!allowCollectionMaterialization) { return { - position: Cesium.Cartesian3.clone(subject.position), + position: geo.cartesian3.clone(subject.position), presence: SUBJECT_PRESENCE.UNCHECKED, }; } @@ -308,7 +307,7 @@ export function createSubject({ state: layerState, services, parts, source }) { // Mapped installations come from static geometry, not a live feed: there is // nothing to be culled from. return { - position: Cesium.Cartesian3.clone(subject.position), + position: geo.cartesian3.clone(subject.position), presence: SUBJECT_PRESENCE.LIVE, }; } @@ -343,8 +342,8 @@ export function createSubject({ state: layerState, services, parts, source }) { : SUBJECT_PRESENCE.UNCHECKED; return { position: current?.position - ? Cesium.Cartesian3.clone(current.position) - : Cesium.Cartesian3.clone(subject.position), + ? geo.cartesian3.clone(current.position) + : geo.cartesian3.clone(subject.position), presence, }; } diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 7a1193f25..45488f950 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -67,7 +67,7 @@ export function createCyclonesLayer({ feed, hitTestOverlay = hitTestWorldOverlay, overlayHost, - cesium = Cesium, + cesium = null, createRendering = createCycloneRendering, createNativeRendering = null, matchMedia = globalThis.matchMedia?.bind(globalThis), @@ -99,24 +99,27 @@ export function createCyclonesLayer({ selectedId = id; rendering?.setSelection(id); } + // The SDK, on Cesium only: the production build's Cesium global loads with + // the first Cesium globe, so a Navara page has none. + const sdk = () => cesium ?? (engine?.kind === 'navara' ? null : Cesium); // Photorealistic 3D Tiles pick as tileset content without an entity id; // that is empty map, the same as no pick at all on the globe. const isSurfacePick = (picked) => !picked || (picked.id === undefined && (picked.content !== undefined || - (typeof cesium.Cesium3DTileset === 'function' && - picked.primitive instanceof cesium.Cesium3DTileset))); + (typeof sdk()?.Cesium3DTileset === 'function' && + picked.primitive instanceof sdk().Cesium3DTileset))); function installSelection() { const canvas = engine?.host.canvas ?? viewer?.scene?.canvas; const native = engine?.kind === 'navara'; if ( clickHandler || !canvas || - (!native && typeof cesium.ScreenSpaceEventHandler !== 'function') + (!native && typeof sdk()?.ScreenSpaceEventHandler !== 'function') ) return; - const owner = native ? {} : new cesium.ScreenSpaceEventHandler(canvas); + const owner = native ? {} : new (sdk().ScreenSpaceEventHandler)(canvas); clickHandler = owner; let capturedHit = null; const overlayHit = (x, y) => { @@ -192,7 +195,7 @@ export function createCyclonesLayer({ }; if (native) owner.destroy = engine.input.on('click', onClick, { pick: true }); - else owner.setInputAction(onClick, cesium.ScreenSpaceEventType.LEFT_CLICK); + else owner.setInputAction(onClick, sdk().ScreenSpaceEventType.LEFT_CLICK); } function removeSelection() { const owner = clickHandler; @@ -217,7 +220,7 @@ export function createCyclonesLayer({ rendering = engine?.kind === 'navara' ? createNativeRendering(engine, { overlayHost }) - : createRendering({ viewer, cesium, overlayHost }); + : createRendering({ viewer, cesium: sdk(), overlayHost }); if (!enabled) return; installSelection(); if (snapshot && !snapshot.unavailable) { diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 512d795c6..d7debcc79 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -17,6 +17,8 @@ */ import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { WHITE, rgbaFromCss } from '../shared/color.js'; import { roadLabel } from '../../data/routeSteps.js'; import { t, @@ -68,10 +70,12 @@ 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); +// Engine-neutral colours (src/layers/shared/color.js): a Navara page has no +// Cesium SDK. +const MARKER_A_COLOR = () => rgbaFromCss('#5dff9f'); +const MARKER_B_COLOR = () => rgbaFromCss('#ff6b6b'); +const STEP_COLOR = () => WHITE.withAlpha(0.95); +const STEP_OUTLINE = () => rgbaFromCss(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. */ @@ -978,11 +982,11 @@ export function createDirectionsLayer({ ? _engine.picker.pickGlobe(screenPosition) : services.scenePick.pickGroundPosition(_viewer, screenPosition); if (!cartesian) return null; - const carto = Cesium.Cartographic.fromCartesian(cartesian); + const carto = geo.cartographic.fromCartesian(cartesian); if (!carto) return null; return { - lat: Cesium.Math.toDegrees(carto.latitude), - lon: Cesium.Math.toDegrees(carto.longitude), + lat: geo.math.toDegrees(carto.latitude), + lon: geo.math.toDegrees(carto.longitude), }; } @@ -1026,10 +1030,10 @@ export function createDirectionsLayer({ 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()); } } @@ -1136,9 +1140,9 @@ export function createDirectionsLayer({ _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/index.js b/src/layers/streetLevel/index.js index b11ed92ff..41168d43b 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -1,4 +1,5 @@ import * as geo from '../../geo/index.js'; +import { whenCesiumSdk } from '../../app/loadCesium.js'; import { createState } from './state.js'; import { createMarker } from './marker.js'; import { createCameraFraming } from './cameraFraming.js'; @@ -549,15 +550,19 @@ export function createStreetLevelLayer({ mapStack = null; state.viewer = null; }, - enable(viewer) { - state.enabled = true; - if (viewer) state.viewer = viewer; - parts.marker.setVisible(true); - parts.selection.install(state.viewer); - for (const entry of activeEntries()) activate(entry); - watchCamera(state.viewer); - remeasureGround(); - notify(); + enable(viewer, { signal } = {}) { + // The marker, cones, coverage and ground picks use Cesium's math and + // primitives on either globe: a Navara page loads the Cesium SDK first. + return whenCesiumSdk(() => { + state.enabled = true; + if (viewer) state.viewer = viewer; + parts.marker.setVisible(true); + parts.selection.install(state.viewer); + for (const entry of activeEntries()) activate(entry); + watchCamera(state.viewer); + remeasureGround(); + notify(); + }, signal); }, disable() { diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index 702d33a18..66f1408b0 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -227,11 +227,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 @@ -240,12 +236,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..9fb0c4341 100644 --- a/src/layers/submarineCables/policy.js +++ b/src/layers/submarineCables/policy.js @@ -86,11 +86,15 @@ 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 bbab2a673..1beb629fc 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); @@ -102,7 +104,7 @@ export function createReticleFootprint() { } else { const picked = engine ? camera.pickRay(p) - : camera.getPickRay(p, ray) || ray; + : camera.getPickRay(p, (ray ??= new Cesium.Ray())) || ray; if (!picked) continue; const d = picked.direction; dx = diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 9d34033a5..0c288cfbd 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -5,6 +5,7 @@ import { claimCameraSensitivity, releaseCameraSensitivity, } from '../../data/cameraSensitivity.js'; +import { whenCesiumSdk } from '../../app/loadCesium.js'; export function createLifecycle({ state: layerState, @@ -245,36 +246,40 @@ export function createLifecycle({ * * @param {Cesium.Viewer} viewer - The Cesium viewer instance. */ - enable(viewer) { - if (layerState._enabled) return; - if (viewer) layerState._viewer = viewer; - layerState._enabled = true; - if (layerState._roadMode !== 'tomtom') source.prefetch?.(); - bindScene(); - // Enabling is explicit intent, not a camera gesture that needs settling. - parts.viewport.onCameraChanged({ immediate: true }); + enable(viewer, { signal } = {}) { + // The simulation's math is Cesium's on either globe: a Navara page + // loads the Cesium SDK first. + return whenCesiumSdk(() => { + if (layerState._enabled) return; + if (viewer) layerState._viewer = viewer; + layerState._enabled = true; + if (layerState._roadMode !== 'tomtom') source.prefetch?.(); + bindScene(); + // Enabling is explicit intent, not a camera gesture that needs settling. + parts.viewport.onCameraChanged({ immediate: true }); - // Boot-order guard (field-test round 1: layer sat empty until the user - // moved): when the persisted layer state re-enables traffic during the - // intro flyTo, the initial check bails at high altitude — and a camera - // that then parks never re-fires camera.changed. Retry cheaply until the - // first load commits, then self-clear. Also acts as a safety kick if a - // failed first fetch left the viewport unloaded while parked. - clearInterval(layerState._enableKickTimer); - layerState._enableKickTimer = setInterval(() => { - if ( - !layerState._enabled || - layerState._lastUpdate || - layerState._roadRetryStopped || - layerState._retryAttempts >= 3 - ) { - clearInterval(layerState._enableKickTimer); - layerState._enableKickTimer = null; - return; - } - if (!layerState._fetching && !layerState._retryTimer) - parts.viewport.onCameraChanged(); - }, 1500); + // Boot-order guard (field-test round 1: layer sat empty until the user + // moved): when the persisted layer state re-enables traffic during the + // intro flyTo, the initial check bails at high altitude — and a camera + // that then parks never re-fires camera.changed. Retry cheaply until the + // first load commits, then self-clear. Also acts as a safety kick if a + // failed first fetch left the viewport unloaded while parked. + clearInterval(layerState._enableKickTimer); + layerState._enableKickTimer = setInterval(() => { + if ( + !layerState._enabled || + layerState._lastUpdate || + layerState._roadRetryStopped || + layerState._retryAttempts >= 3 + ) { + clearInterval(layerState._enableKickTimer); + layerState._enableKickTimer = null; + return; + } + if (!layerState._fetching && !layerState._retryTimer) + parts.viewport.onCameraChanged(); + }, 1500); + }, signal); }, /** diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js index c20b15e41..fd99d3b85 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,11 @@ 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); +export const HEAT_SLOW_COLOR = 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 0e51708db..c6cb3a519 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 f5331a5f9..50b19c535 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/layers/weather/index.js b/src/layers/weather/index.js index 74648fba6..df48e9baf 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { createWeatherRendering } from './rendering.js'; import { imageryHostStatus } from './imageryHost.js'; import { weatherDisplay, insideExtent } from './display.js'; @@ -57,7 +58,7 @@ export function createWeatherLayer({ feed, clock, id = 'weather-radar', - cesium = Cesium, + cesium = null, createRendering = createWeatherRendering, createNativeRendering = null, documentRef = globalThis.document, @@ -311,11 +312,13 @@ export function createWeatherLayer({ attachRenderer(nextViewer, { engine = null } = {}) { viewer = nextViewer; sceneEngine = engine; - const options = { viewer, cesium, getHost, onChange: notify }; + const options = { viewer, getHost, onChange: notify }; + // The SDK is read here, on Cesium only: the production build's Cesium + // global loads with the first Cesium globe. rendering = engine?.kind === 'navara' ? createNativeRendering(engine, options) - : createRendering(options); + : createRendering({ ...options, cesium: cesium ?? Cesium }); checkHost(false); eventTarget?.addEventListener?.( 'gev:map-stack-changed', @@ -530,12 +533,20 @@ export function createWeatherLayer({ : manifest.bounds); runNavigation(() => (sceneEngine?.camera ?? viewer.camera).flyTo({ - destination: cesium.Rectangle.fromDegrees( - b.west, - b.south, - b.east, - b.north, - ), + // The engine camera takes a plain rectangle in radians. + destination: sceneEngine + ? { + west: geo.math.toRadians(b.west), + south: geo.math.toRadians(b.south), + east: geo.math.toRadians(b.east), + north: geo.math.toRadians(b.north), + } + : (cesium ?? Cesium).Rectangle.fromDegrees( + b.west, + b.south, + b.east, + b.north, + ), duration: motion?.matches ? 0 : 1.4, }), ); @@ -634,20 +645,17 @@ export function createWeatherLayer({ const point = canvas && (sceneEngine?.camera ?? viewer.camera).pickEllipsoid?.( - new cesium.Cartesian2( + geo.cartesian2.create( canvas.clientWidth / 2, canvas.clientHeight / 2, ), viewer?.scene?.globe?.ellipsoid, ); const camera = point - ? cesium.Cartographic.fromCartesian( - point, - viewer?.scene?.globe?.ellipsoid, - ) + ? geo.cartographic.fromCartesian(point, viewer?.scene?.globe?.ellipsoid) : null; - const lon = camera ? cesium.Math.toDegrees(camera.longitude) : 0; - const lat = camera ? cesium.Math.toDegrees(camera.latitude) : 0; + const lon = camera ? geo.math.toDegrees(camera.longitude) : 0; + const lat = camera ? geo.math.toDegrees(camera.latitude) : 0; const outside = b && camera && diff --git a/src/layers/wind/edgeCases.test.mjs b/src/layers/wind/edgeCases.test.mjs index 424345466..25d65c825 100644 --- a/src/layers/wind/edgeCases.test.mjs +++ b/src/layers/wind/edgeCases.test.mjs @@ -7,22 +7,9 @@ import { inspectWindAtCenter } from './inspection.js'; import { windReadingResult } from './presentation.js'; import { sampleWind, windColor, windSpeed } from './model.js'; import { windStats } from './index.js'; +import * as geo from '../../geo/index.js'; -const cesium = { - Cartesian2: class { - constructor(x, y) { - this.x = x; - this.y = y; - } - }, - Ellipsoid: { WGS84: {} }, - Cartographic: { fromCartesian: (p) => p }, - Math: { toDegrees: (x) => (x * 180) / Math.PI }, -}; -const at = (lonDeg, latDeg) => ({ - longitude: (lonDeg * Math.PI) / 180, - latitude: (latDeg * Math.PI) / 180, -}); +const at = (lonDeg, latDeg) => geo.cartesian3.fromDegrees(lonDeg, latDeg); const grid = { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 }; const field = (u, v) => ({ grid, @@ -44,11 +31,10 @@ const viaViewer = (snapshot, point) => camera: { pickEllipsoid: () => point }, scene: { canvas: { clientWidth: 800, clientHeight: 600 } }, }, - cesium, options, ); const viaPosition = (snapshot, point) => - inspectWindAtCenter(snapshot, null, cesium, { ...options, position: point }); + inspectWindAtCenter(snapshot, null, { ...options, position: point }); const windLine = (reading) => windReadingResult(reading).lines.find(({ id }) => id === 'wind').text; diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 89c0e1a8d..1ea2c492d 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -54,7 +54,7 @@ export function windStats(manifest) { export function createWindLayer({ feed, clock, - cesium = Cesium, + cesium = null, container, createRendering = createWindRendering, createNativeRendering, @@ -90,7 +90,7 @@ export function createWindLayer({ y: canvas.clientHeight / 2, }); } - reading = inspectWindAtCenter(manifest, viewer, cesium, { + reading = inspectWindAtCenter(manifest, viewer, { position, units, overlay, @@ -129,7 +129,9 @@ export function createWindLayer({ engine?.kind === 'navara' ? createNativeRendering(engine) : createRendering({ - cesium, + // Read on Cesium only: the production build's Cesium global + // loads with the first Cesium globe. + cesium: cesium ?? Cesium, container: container ?? nextViewer.container, getViewer: () => viewer, getHost: () => @@ -147,7 +149,7 @@ export function createWindLayer({ inspectionMarker = createWindInspectionMarker({ container: target, viewer, - cesium, + cesium: engine ? null : (cesium ?? Cesium), engine, }); if (enabled && manifest && !manifest.unavailable) { diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index c0b738643..d30af79d1 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createWindLayer, formatWindValidTime, windStats } from './index.js'; import { useWindInspectionStore } from '../../ui/stores/windInspection.js'; import { applicationPinia } from '../../ui/vueRuntime.js'; +import * as geo from '../../geo/index.js'; const snapshot = (model) => ({ model, @@ -314,7 +315,7 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis }; const viewer = { container, - camera: { positionWC: {}, pickEllipsoid: () => { samples++; return { longitude: 0, latitude: 0 }; } }, + camera: { positionWC: {}, pickEllipsoid: () => { samples++; return geo.cartesian3.fromDegrees(0, 0); } }, scene: { mode: 3, canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 0, top: 0 }) }, @@ -352,7 +353,7 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis assert.deepEqual(layer.getRowControls().summary.settings.map(({ label }) => label), ['MODEL', 'FIELD', 'UNITS', 'MOTION']); assert.ok(marker()); assert.equal(typeof listener, 'function'); - viewer.camera.pickEllipsoid = () => { samples++; return { longitude: 70, latitude: 20 }; }; + viewer.camera.pickEllipsoid = () => { samples++; return geo.cartesian3.fromDegrees(70, 20); }; layer.setParams({ overlay: 'speed' }); assert.equal(layer.getRowControls().summary.reading.coordinates, captured.coordinates); layer.setParams({ model: 'ifs' }); diff --git a/src/layers/wind/inspection.js b/src/layers/wind/inspection.js index 93e96fcb3..3459a1df2 100644 --- a/src/layers/wind/inspection.js +++ b/src/layers/wind/inspection.js @@ -1,5 +1,6 @@ import { sampleWind, windSpeed } from './model.js'; import { sampleScalar } from './fields.js'; +import * as geo from '../../geo/index.js'; // The reading stays English: AI tools read it too. Text is marked for i18n // extraction and translated where the WEATHER card shows it (presentation.js). @@ -45,17 +46,17 @@ export function formatWindSpeed(speed, units = 'km/h') { export function inspectWindAtCenter( snapshot, viewer, - cesium, { units, overlay, model, validTime, status, position } = {}, ) { const scene = viewer?.scene; const canvas = scene?.canvas; - const ellipsoid = scene?.globe?.ellipsoid || cesium.Ellipsoid?.WGS84; + // The globe's own ellipsoid, else WGS84 (geo's default). + const ellipsoid = scene?.globe?.ellipsoid; const point = position || (canvas && viewer.camera?.pickEllipsoid?.( - new cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2), + geo.cartesian2.create(canvas.clientWidth / 2, canvas.clientHeight / 2), ellipsoid, )); const noReading = () => ({ @@ -69,9 +70,9 @@ export function inspectWindAtCenter( ), }); if (!point || !snapshot?.u || !snapshot?.v) return noReading(); - const location = cesium.Cartographic.fromCartesian(point, ellipsoid); - const lon = cesium.Math.toDegrees(location.longitude); - const lat = cesium.Math.toDegrees(location.latitude); + const location = geo.cartographic.fromCartesian(point, ellipsoid); + const lon = geo.math.toDegrees(location.longitude); + const lat = geo.math.toDegrees(location.latitude); const vector = sampleWind( { ...snapshot.grid, u: snapshot.u, v: snapshot.v }, lon, diff --git a/src/layers/wind/inspection.test.mjs b/src/layers/wind/inspection.test.mjs index 52adb07f8..a19801228 100644 --- a/src/layers/wind/inspection.test.mjs +++ b/src/layers/wind/inspection.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; import { windFrom, formatWindSpeed, @@ -27,21 +28,10 @@ test('center inspection uses a fixed forecast reading without double-converting }, }; const viewer = { - camera: { pickEllipsoid: () => ({ longitude: 0, latitude: 0 }) }, + camera: { pickEllipsoid: () => geo.cartesian3.fromDegrees(0, 0) }, scene: { canvas: { clientWidth: 800, clientHeight: 600 } }, }; - const cesium = { - Cartesian2: class { - constructor(x, y) { - this.x = x; - this.y = y; - } - }, - Ellipsoid: { WGS84: {} }, - Cartographic: { fromCartesian: (p) => p }, - Math: { toDegrees: (x) => (x * 180) / Math.PI }, - }; - const result = inspectWindAtCenter(snapshot, viewer, cesium, { + const result = inspectWindAtCenter(snapshot, viewer, { overlay: 'temperature', units: 'km/h', model: 'GFS', @@ -54,7 +44,7 @@ test('center inspection uses a fixed forecast reading without double-converting assert.equal(result.coordinates, '0.00°N · 0.00°E'); viewer.camera.pickEllipsoid = () => null; assert.equal( - inspectWindAtCenter(snapshot, viewer, cesium).coordinates, + inspectWindAtCenter(snapshot, viewer).coordinates, 'No surface reading', ); }); diff --git a/src/main.js b/src/main.js index 8ef0f6f39..9e40e5bfc 100644 --- a/src/main.js +++ b/src/main.js @@ -16,6 +16,10 @@ import { createSessionDiagnostics } from './app/sessionDiagnostics.js'; import { useDisplayStore } from './ui/stores/display.js'; import { applicationPinia } from './ui/vueRuntime.js'; import { t } from './i18n/index.js'; +import { guardCesiumGlobal } from './app/loadCesium.js'; + +// Cesium.js loads with the first Cesium globe (src/app/loadCesium.js). +guardCesiumGlobal(); // The Vite boundary exposes only undefined, null, or a validated URL. const photonSetting = import.meta.env.PHOTON_URL; 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, }, { diff --git a/src/scenes/dataPacks/navara.js b/src/scenes/dataPacks/navara.js index 0107d2cdd..59a13976d 100644 --- a/src/scenes/dataPacks/navara.js +++ b/src/scenes/dataPacks/navara.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { BufferAttribute, BufferGeometry, @@ -11,6 +10,8 @@ import { SRGBColorSpace, Texture, } from 'three'; +import * as geo from '../../geo/index.js'; +import { polygonShape, rectangleShape } from './navaraGeometry.js'; import { attachSceneDepth, createSceneDepthTest, @@ -18,7 +19,7 @@ import { const KEY = 'gevScenePack', registered = new WeakSet(); const cyan = [0, 1, 1, 1], - xyz = (p) => Cesium.Cartesian3.fromDegrees(...p); + xyz = ([lon, lat, height]) => geo.cartesian3.fromDegrees(lon, lat, height); export function createNavaraPackRenderer(engine, { view, MeshDesc }) { if (!registered.has(view)) { view.registerMesh( @@ -153,20 +154,9 @@ export function createNavaraPackRenderer(engine, { view, MeshDesc }) { line.setPositions(f.coordinates.map(xyz)); } else { batch ??= meshGroup(handle); - const polygon = new Cesium.PolygonGeometry({ - polygonHierarchy: new Cesium.PolygonHierarchy( - f.coordinates[0].map(xyz), - f.coordinates - .slice(1) - .map((r) => new Cesium.PolygonHierarchy(r.map(xyz))), - ), - perPositionHeight: true, - vertexFormat: Cesium.VertexFormat.POSITION_ONLY, - arcType: Cesium.ArcType.GEODESIC, - }); addMesh( batch, - Cesium.PolygonGeometry.createGeometry(polygon), + polygonShape(f.coordinates), { color: new Color(0x00ffff), opacity: 0.35 }, id, null, @@ -189,7 +179,7 @@ export function createNavaraPackRenderer(engine, { view, MeshDesc }) { const hits = raycaster.intersectObjects(batch.group.children); const earth = engine.picker.pickGlobe(at); const depth = earth - ? Cesium.Cartesian3.distance(ray.origin, earth) + ? geo.cartesian3.distance(ray.origin, earth) : Infinity; return hits .filter((h) => h.distance <= depth + 1) @@ -210,12 +200,9 @@ export function createNavaraPackRenderer(engine, { view, MeshDesc }) { texture.colorSpace = SRGBColorSpace; texture.needsUpdate = true; batch.owned.push(texture); - const shape = Cesium.RectangleGeometry.createGeometry( - new Cesium.RectangleGeometry({ - rectangle: Cesium.Rectangle.fromDegrees(...pack.placement.bounds), - height: pack.placement.height, - vertexFormat: Cesium.VertexFormat.POSITION_AND_ST, - }), + const shape = rectangleShape( + pack.placement.bounds, + pack.placement.height, ); addMesh(batch, shape, { color: 0xffffff, opacity: 1 }, null, texture); engine.render.request('scene-pack'); diff --git a/src/scenes/dataPacks/navaraGeometry.js b/src/scenes/dataPacks/navaraGeometry.js new file mode 100644 index 000000000..10fdbac3d --- /dev/null +++ b/src/scenes/dataPacks/navaraGeometry.js @@ -0,0 +1,141 @@ +// Pack polygons and images as Navara mesh geometry, without the Cesium SDK (a +// page that starts on Navara never loads it). A shape is what Cesium's +// Geometry gives the renderer: Earth-fixed positions, triangle indices, an +// optional texture coordinate per vertex and a centre to draw relative to. +import { ShapeUtils, Vector2 } from 'three'; +import * as geo from '../../geo/index.js'; + +// Cesium's default granularity: no edge or cell spans more than one degree. +const GRANULARITY_DEGREES = 1; + +function shape(positions, indices, st = null) { + const center = geo.cartesian3.create(); + for (const p of positions) geo.cartesian3.add(center, p, center); + geo.cartesian3.divideByScalar(center, positions.length || 1, center); + const values = new Float64Array(positions.length * 3); + positions.forEach((p, i) => values.set([p.x, p.y, p.z], i * 3)); + return { + attributes: { + position: { values }, + ...(st ? { st: { values: st } } : {}), + }, + indices: new Uint32Array(indices), + boundingSphere: { center }, + }; +} + +/** A [lon, lat, height?] ring with long edges split along great circles. */ +function densify(ring) { + const out = []; + ring.forEach((a, i) => { + const b = ring[(i + 1) % ring.length]; + const steps = Math.max( + 1, + Math.ceil( + Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1])) / + GRANULARITY_DEGREES, + ), + ); + for (let k = 0; k < steps; k++) { + const t = k / steps; + out.push([ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + (a[2] ?? 0) + ((b[2] ?? 0) - (a[2] ?? 0)) * t, + ]); + } + }); + return out; +} + +/** + * A filled polygon: `rings` are GeoJSON-style [lon, lat, height?] rings, the + * first the outline and the rest holes, each vertex at its own height (as + * Cesium's perPositionHeight). Triangulated in the plane tangent to the + * Earth at the outline's centre; null when nothing is left to fill. + */ +export function polygonShape(rings) { + const loops = rings + .map((ring) => { + const open = + ring.length > 1 && + ring[0][0] === ring.at(-1)[0] && + ring[0][1] === ring.at(-1)[1] + ? ring.slice(0, -1) + : ring; + return densify(open); + }) + .filter((ring) => ring.length >= 3); + if (!loops.length) return null; + const [outline, ...holes] = loops; + const points = loops.flat(); + const world = points.map(([lon, lat, h]) => + geo.cartesian3.fromDegrees(lon, lat, h), + ); + const middle = geo.cartesian3.create(); + for (const p of world.slice(0, outline.length)) + geo.cartesian3.add(middle, p, middle); + geo.cartesian3.divideByScalar(middle, outline.length, middle); + const frame = geo.transforms.eastNorthUpToFixedFrame(middle); + const east = geo.cartesian3.create(frame[0], frame[1], frame[2]); + const north = geo.cartesian3.create(frame[4], frame[5], frame[6]); + const offset = geo.cartesian3.create(); + const plane = world.map((p) => { + geo.cartesian3.subtract(p, middle, offset); + return new Vector2( + geo.cartesian3.dot(offset, east), + geo.cartesian3.dot(offset, north), + ); + }); + let start = outline.length; + const holePoints = holes.map((hole) => { + const ring = plane.slice(start, start + hole.length); + start += hole.length; + return ring; + }); + const triangles = ShapeUtils.triangulateShape( + plane.slice(0, outline.length), + holePoints, + ); + if (!triangles.length) return null; + return shape(world, triangles.flat()); +} + +/** + * An image's rectangle, [west, south, east, north] in degrees at `height`: + * a grid of cells no larger than the granularity, texture coordinates from + * (0, 0) at the south-west corner to (1, 1) at the north-east one. + */ +export function rectangleShape([west, south, east, north], height = 0) { + const span = east >= west ? east - west : east + 360 - west; + const columns = Math.max(1, Math.ceil(span / GRANULARITY_DEGREES)); + const rows = Math.max( + 1, + Math.ceil(Math.abs(north - south) / GRANULARITY_DEGREES), + ); + const positions = []; + const st = new Float32Array((rows + 1) * (columns + 1) * 2); + for (let row = 0; row <= rows; row++) + for (let column = 0; column <= columns; column++) { + const s = column / columns, + t = row / rows; + st.set([s, t], positions.length * 2); + positions.push( + geo.cartesian3.fromDegrees( + west + span * s, + south + (north - south) * t, + height, + ), + ); + } + const indices = []; + for (let row = 0; row < rows; row++) + for (let column = 0; column < columns; column++) { + const a = row * (columns + 1) + column, + b = a + 1, + c = a + columns + 1, + d = c + 1; + indices.push(a, b, d, a, d, c); + } + return shape(positions, indices, st); +} diff --git a/src/scenes/dataPacks/navaraGeometry.test.mjs b/src/scenes/dataPacks/navaraGeometry.test.mjs new file mode 100644 index 000000000..d46e3ddad --- /dev/null +++ b/src/scenes/dataPacks/navaraGeometry.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as geo from '../../geo/index.js'; +import { polygonShape, rectangleShape } from './navaraGeometry.js'; + +const vertex = (shape, index) => { + const v = shape.attributes.position.values; + return geo.cartographic.fromCartesian( + geo.cartesian3.create(v[index * 3], v[index * 3 + 1], v[index * 3 + 2]), + ); +}; +const area = (shape) => { + // Sum of triangle areas in square metres. + const v = shape.attributes.position.values, + i = shape.indices; + const p = (k) => geo.cartesian3.create(v[k * 3], v[k * 3 + 1], v[k * 3 + 2]); + let sum = 0; + for (let t = 0; t < i.length; t += 3) { + const a = p(i[t]), + ab = geo.cartesian3.subtract(p(i[t + 1]), a, geo.cartesian3.create()), + ac = geo.cartesian3.subtract(p(i[t + 2]), a, geo.cartesian3.create()); + sum += + geo.cartesian3.magnitude( + geo.cartesian3.cross(ab, ac, geo.cartesian3.create()), + ) / 2; + } + return sum; +}; + +test('a pack polygon fills its outline less its holes, each vertex at its own height', () => { + const square = [ + [-97.75, 30.25, 10], + [-97.74, 30.25, 10], + [-97.74, 30.26, 10], + [-97.75, 30.26, 10], + [-97.75, 30.25, 10], + ]; + const hole = [ + [-97.747, 30.253, 10], + [-97.743, 30.253, 10], + [-97.743, 30.257, 10], + [-97.747, 30.257, 10], + ]; + const full = polygonShape([square]); + const holed = polygonShape([square, hole]); + assert.equal(full.indices.length, 6, 'the closing vertex is not repeated'); + assert.ok( + Math.abs(area(holed) / area(full) - (1 - 0.16)) < 0.01, + `the hole is left out: ${area(holed) / area(full)}`, + ); + for (let k = 0; k < holed.attributes.position.values.length / 3; k++) + assert.ok(Math.abs(vertex(holed, k).height - 10) < 1e-6); + assert.equal(polygonShape([[square[0], square[1]]]), null); +}); + +test('a long pack edge is split so no step spans more than a degree', () => { + const shape = polygonShape([ + [ + [0, 0], + [3, 0], + [3, 1], + [0, 1], + ], + ]); + assert.equal(shape.attributes.position.values.length / 3, 8); +}); + +test('a pack image rectangle maps its texture from the south-west corner', () => { + const shape = rectangleShape([-97.8, 30.2, -97.7, 30.3], 25); + const st = shape.attributes.st.values; + assert.deepEqual([...st.slice(0, 2)], [0, 0]); + assert.deepEqual([...st.slice(-2)], [1, 1]); + const first = vertex(shape, 0), + last = vertex(shape, st.length / 2 - 1); + assert.ok(Math.abs(geo.math.toDegrees(first.longitude) + 97.8) < 1e-9); + assert.ok(Math.abs(geo.math.toDegrees(last.latitude) - 30.3) < 1e-9); + assert.ok(Math.abs(first.height - 25) < 1e-6); + assert.equal(shape.indices.length, 6); + const wide = rectangleShape([10, 0, 13.5, 1]); + assert.equal(wide.indices.length, 4 * 1 * 6, 'cells of at most a degree'); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 0fa9b94f5..1aab457de 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -11,7 +11,6 @@ import { defaultGeospatial } from '../search/defaults.js'; import { msg } from '../i18n/index.js'; import { useProviderSettingsStore } from '../ui/stores/providerSettings.js'; import { applicationPinia } from '../ui/vueRuntime.js'; -import * as Cesium from 'cesium'; import * as geo from '../geo/index.js'; import { CITY_POIS } from '../cityPois.js'; import { greatCircleKm } from '../geoDistance.js'; @@ -2798,8 +2797,8 @@ async function frameOverhead(engine, dataManager, styleManager, args = {}) { }; } - // Cesium's bounding sphere (pure math, no viewer), flown by the engine. - const bounds = Cesium.BoundingSphere.fromPoints( + // Cesium's bounding sphere (geo's port, no SDK), flown by the engine. + const bounds = geo.boundingSphere.fromPoints( entries.map((entry) => entry.position), ); const sphere = {