diff --git a/package-lock.json b/package-lock.json index 5c56847..77a5c7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@skybrush/redux-toolkit": "^5.0.4", "@skybrush/show-format": "^5.4.0", "@tippyjs/react": "^4.2.6", + "3d-tiles-renderer": "^0.4.24", "aframe": "^1.7.1", "aframe-environment-component": "^1.5.0", "aframe-look-at-component": "^1.0.0", @@ -37,6 +38,7 @@ "core-js": "^3.48.0", "delay": "^7.0.0", "denque": "^2.1.0", + "egm96-universal": "^1.1.1", "electron-better-ipc": "^2.0.1", "electron-store": "^11.0.2", "electron-util": "^1.0.0", @@ -3055,6 +3057,40 @@ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "dev": true }, + "node_modules/3d-tiles-renderer": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/3d-tiles-renderer/-/3d-tiles-renderer-0.4.24.tgz", + "integrity": "sha512-1n21vaWoV5e+N6rTfK1nv0Bsgu9ptbY6d9ICHnT6W1llloIhUGmGzx5/JH+B7t9XggjL0aJASn0Z5sFOXXvYjw==", + "license": "Apache-2.0", + "peerDependencies": { + "@babylonjs/core": ">=8.0.0", + "@babylonjs/loaders": ">=8.0.0", + "@react-three/fiber": "^8.17.9 || ^9.0.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0", + "three": ">=0.167.0" + }, + "peerDependenciesMeta": { + "@babylonjs/core": { + "optional": true + }, + "@babylonjs/loaders": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "three": { + "optional": true + } + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5406,6 +5442,19 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/egm96-universal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/egm96-universal/-/egm96-universal-1.1.1.tgz", + "integrity": "sha512-rHOKjWBcT2SQ1SqATOt3FdVVEkd6+n4Q+j0IDivVc/yqS0SmQ+LLUbNdCRuS6C0aibv9UXxqOyAGIyyg4E22Qg==", + "license": "MIT", + "dependencies": { + "rfc4648": "^1.5.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/nicolas-van" + } + }, "node_modules/electron": { "version": "40.4.1", "resolved": "https://registry.npmjs.org/electron/-/electron-40.4.1.tgz", @@ -11256,6 +11305,12 @@ "node": ">=0.10.0" } }, + "node_modules/rfc4648": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", + "license": "MIT" + }, "node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -16201,6 +16256,11 @@ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "dev": true }, + "3d-tiles-renderer": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/3d-tiles-renderer/-/3d-tiles-renderer-0.4.24.tgz", + "integrity": "sha512-1n21vaWoV5e+N6rTfK1nv0Bsgu9ptbY6d9ICHnT6W1llloIhUGmGzx5/JH+B7t9XggjL0aJASn0Z5sFOXXvYjw==" + }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -17796,6 +17856,14 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "egm96-universal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/egm96-universal/-/egm96-universal-1.1.1.tgz", + "integrity": "sha512-rHOKjWBcT2SQ1SqATOt3FdVVEkd6+n4Q+j0IDivVc/yqS0SmQ+LLUbNdCRuS6C0aibv9UXxqOyAGIyyg4E22Qg==", + "requires": { + "rfc4648": "^1.5.0" + } + }, "electron": { "version": "40.4.1", "resolved": "https://registry.npmjs.org/electron/-/electron-40.4.1.tgz", @@ -21822,6 +21890,11 @@ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true }, + "rfc4648": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==" + }, "rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", diff --git a/package.json b/package.json index 5bb1703..d3a41e3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@skybrush/redux-toolkit": "^5.0.4", "@skybrush/show-format": "^5.4.0", "@tippyjs/react": "^4.2.6", + "3d-tiles-renderer": "^0.4.24", "aframe": "^1.7.1", "aframe-environment-component": "^1.5.0", "aframe-look-at-component": "^1.0.0", @@ -50,6 +51,7 @@ "core-js": "^3.48.0", "delay": "^7.0.0", "denque": "^2.1.0", + "egm96-universal": "^1.1.1", "electron-better-ipc": "^2.0.1", "electron-store": "^11.0.2", "electron-util": "^1.0.0", diff --git a/src/aframe/components/terrain-tiles.ts b/src/aframe/components/terrain-tiles.ts new file mode 100644 index 0000000..c4bfffe --- /dev/null +++ b/src/aframe/components/terrain-tiles.ts @@ -0,0 +1,337 @@ +import type { Component, Entity } from 'aframe'; +import AFrame from 'aframe'; +import { TilesRenderer } from '3d-tiles-renderer'; +import { GoogleCloudAuthPlugin } from '3d-tiles-renderer/core/plugins'; +import { CesiumIonAuthPlugin, CesiumIonOverlay, ImageOverlayPlugin, QuantizedMeshPlugin, LoadRegionPlugin, SphereRegion } from '3d-tiles-renderer/three/plugins'; +import { WGS84_ELLIPSOID } from '3d-tiles-renderer/three'; +import { AmbientLight, DirectionalLight, Sphere, Vector3 } from 'three'; + +import { egm96ToEllipsoid } from 'egm96-universal'; + +import { computeEcefToSkybrushTransform } from '~/geo/coordinates'; + +type TerrainTilesComponent = Component & { + _tilesRenderer: TilesRenderer | null; + _buildingsRenderer: TilesRenderer | null; + _tilesetUrl: string; + _token: string; + _cesiumAssetId: number; + _originLat: number; + _originLon: number; + _originAlt: number; + _orientation: number; + _provider: string; + _resolutionSet: boolean; + _initRenderer(): void; + _disposeRenderer(): void; +}; + +function toEllipsoidalHeight( + lat: number, + lon: number, + amslAlt: number +): number { + return egm96ToEllipsoid(lat, lon, amslAlt); +} + +function computeOriginEcef( + lat: number, + lon: number, + alt: number +): Vector3 { + const result = new Vector3(); + const ellipsoidalAlt = toEllipsoidalHeight(lat, lon, alt); + WGS84_ELLIPSOID.getCartographicToPosition( + lat * Math.PI / 180, + lon * Math.PI / 180, + ellipsoidalAlt, + result + ); + return result; +} + +function makeRegionPlugin(originEcef: Vector3): LoadRegionPlugin { + const plugin = new LoadRegionPlugin(); + plugin.addRegion( + new SphereRegion({ + sphere: new Sphere(originEcef, 5000), + mask: true, + errorTarget: 16, + }) + ); + return plugin; +} + +function makeCesiumAuthPlugin( + accessToken: string, + assetId: string +): CesiumIonAuthPlugin { + return new CesiumIonAuthPlugin({ + apiToken: accessToken, + assetId, + autoRefreshToken: true, + useRecommendedSettings: true, + assetTypeHandler: (type, renderer) => { + if (type === 'TERRAIN') { + renderer.registerPlugin(new QuantizedMeshPlugin({})); + } + }, + }); +} + +function initIonTiles( + component: TerrainTilesComponent, + assetId: number, + accessToken: string +) { + const originEcef = computeOriginEcef( + component._originLat, + component._originLon, + component._originAlt + ); + + // Terrain renderer (CWT) + const terrainRenderer = new TilesRenderer(''); + terrainRenderer.registerPlugin(makeCesiumAuthPlugin(accessToken, String(assetId))); + terrainRenderer.registerPlugin(makeRegionPlugin(originEcef)); + + // Buildings renderer (OSM 3D Buildings) + const buildingsRenderer = new TilesRenderer(''); + buildingsRenderer.registerPlugin(makeCesiumAuthPlugin(accessToken, '96188')); + buildingsRenderer.registerPlugin(makeRegionPlugin(originEcef)); + + finishInit(component, terrainRenderer, buildingsRenderer); +} + +function initDirectTiles(component: TerrainTilesComponent, url: string) { + const isGoogleUrl = url.includes('tile.googleapis.com'); + + if (isGoogleUrl) { + const urlObj = new URL(url); + const googleKey = urlObj.searchParams.get('key') || component._token; + + if (!googleKey) { + finishInit(component, new TilesRenderer(url)); + return; + } + + const tilesRenderer = new TilesRenderer(url); + tilesRenderer.registerPlugin( + new GoogleCloudAuthPlugin({ + apiToken: googleKey, + autoRefreshToken: true, + useRecommendedSettings: true, + }) + ); + finishInit(component, tilesRenderer); + } else { + const finalUrl = component._token && !url.includes('cesium.com') + ? url + : url; + const tilesRenderer = new TilesRenderer(finalUrl); + if (component._token) { + tilesRenderer.fetchOptions.headers = { + Authorization: `Bearer ${component._token}`, + }; + } + finishInit(component, tilesRenderer); + } +} + +function finishInit( + component: TerrainTilesComponent, + tilesRenderer: TilesRenderer, + buildingsRenderer?: TilesRenderer +) { + const sceneEl = (component.el as Entity).sceneEl; + if (!sceneEl) { + console.warn('[terrain-tiles] No sceneEl available'); + return; + } + + const camera = sceneEl.camera; + const renderer = sceneEl.renderer; + if (!camera || !renderer) { + console.warn('[terrain-tiles] No camera or renderer available'); + return; + } + + // CWT terrain uses MeshStandardMaterial which needs scene lights + const ambient = new AmbientLight(0xffffff, 0.6); + sceneEl.object3D.add(ambient); + const sun = new DirectionalLight(0xffffff, 1.0); + sun.position.set(100, 200, 100); + sceneEl.object3D.add(sun); + sceneEl.object3D.add(sun.target); + + // Drape satellite imagery (Bing Maps Aerial) over terrain tiles via Cesium Ion. + if (component._provider === 'cesiumIon' && component._token) { + const imagery = new ImageOverlayPlugin({ overlays: [], renderer }); + imagery.addOverlay( + new CesiumIonOverlay({ + assetId: 2, + apiToken: component._token, + autoRefreshToken: true, + }) + ); + tilesRenderer.registerPlugin(imagery); + } + + const ellipsoidalAlt = toEllipsoidalHeight( + component._originLat, + component._originLon, + component._originAlt + ); + + const transform = computeEcefToSkybrushTransform( + component._originLat, + component._originLon, + ellipsoidalAlt, + component._orientation + ); + + const renderers = [tilesRenderer]; + if (buildingsRenderer) { + renderers.push(buildingsRenderer); + } + + for (const r of renderers) { + r.setCamera(camera); + r.setResolutionFromRenderer(camera, renderer); + + r.addEventListener('load-root-tileset', () => { + r.group.applyMatrix4(transform); + }); + + r.addEventListener('load-error', (event: any) => { + console.error('[terrain-tiles] Tile load error:', event); + }); + + (component.el as Entity).object3D.add(r.group); + } + + component._tilesRenderer = tilesRenderer; + component._buildingsRenderer = buildingsRenderer ?? null; +} + +if (AFrame.components?.['terrain-tiles']) { + delete AFrame.components['terrain-tiles']; +} +AFrame.registerComponent('terrain-tiles', { + schema: { + url: { type: 'string' }, + token: { type: 'string' }, + cesiumAssetId: { type: 'number', default: 0 }, + provider: { type: 'string', default: 'cesiumIon' }, + originLat: { type: 'number' }, + originLon: { type: 'number' }, + originAlt: { type: 'number', default: 0 }, + orientation: { type: 'number', default: 0 }, + }, + + init(this: TerrainTilesComponent) { + this._tilesRenderer = null; + this._buildingsRenderer = null; + this._tilesetUrl = ''; + this._token = ''; + this._cesiumAssetId = 0; + this._originLat = 0; + this._originLon = 0; + this._originAlt = 0; + this._orientation = 0; + this._provider = ''; + this._resolutionSet = false; + }, + + update(this: TerrainTilesComponent) { + const data = this.data as TerrainTilesComponent['data']; + + const changed = + data.url !== this._tilesetUrl || + data.token !== this._token || + data.cesiumAssetId !== this._cesiumAssetId || + data.originLat !== this._originLat || + data.originLon !== this._originLon || + data.originAlt !== this._originAlt || + data.orientation !== this._orientation || + data.provider !== this._provider; + + if (!changed) { + return; + } + + this._tilesetUrl = data.url as string; + this._token = data.token as string; + this._cesiumAssetId = data.cesiumAssetId as number; + this._originLat = data.originLat as number; + this._originLon = data.originLon as number; + this._originAlt = data.originAlt as number; + this._orientation = data.orientation as number; + this._provider = data.provider as string; + + this._disposeRenderer(); + this._initRenderer(); + }, + + tick(this: TerrainTilesComponent, _time: number, _delta: number) { + const renderers: (TilesRenderer | null)[] = [ + this._tilesRenderer, + this._buildingsRenderer, + ]; + + const camera = (this.el as Entity).sceneEl?.camera; + if (camera) { + for (const r of renderers) { + if (!r) { continue; } + r.setCamera(camera); + if (!this._resolutionSet) { + const sceneRenderer = (this.el as Entity).sceneEl?.renderer; + if (sceneRenderer) { + r.setResolutionFromRenderer(camera, sceneRenderer); + } + } + } + if (!this._resolutionSet && camera) { + this._resolutionSet = true; + } + camera.updateMatrixWorld(); + } + + for (const r of renderers) { + if (r) { r.update(); } + } + }, + + remove(this: TerrainTilesComponent) { + this._disposeRenderer(); + }, + + _initRenderer(this: TerrainTilesComponent) { + if (this._provider === 'cesiumIon') { + if (!this._token) { + console.warn('[terrain-tiles] No Cesium Ion token configured'); + return; + } + const assetId = this._cesiumAssetId || 1; + initIonTiles(this, assetId, this._token); + } else if (this._provider === 'googleMaps') { + if (!this._token) { + console.warn('[terrain-tiles] No Google Maps API key configured'); + return; + } + initDirectTiles(this, this._tilesetUrl); + } else if (this._tilesetUrl) { + initDirectTiles(this, this._tilesetUrl); + } + }, + + _disposeRenderer(this: TerrainTilesComponent) { + for (const key of ['_tilesRenderer', '_buildingsRenderer'] as const) { + const r = this[key]; + if (r) { + (this.el as Entity).object3D.remove(r.group); + this[key] = null; + } + } + }, +}); diff --git a/src/aframe/index.ts b/src/aframe/index.ts index 6539b66..0a007ed 100644 --- a/src/aframe/index.ts +++ b/src/aframe/index.ts @@ -11,6 +11,7 @@ import './components/arrow'; import './components/drone-flock'; import './components/glow-material'; import './components/modifier-keys'; +import './components/terrain-tiles'; import './primitives/arrow'; import './primitives/drone-flock'; diff --git a/src/features/settings/TerrainSettingsEditor.tsx b/src/features/settings/TerrainSettingsEditor.tsx new file mode 100644 index 0000000..3688e3b --- /dev/null +++ b/src/features/settings/TerrainSettingsEditor.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from 'react-i18next'; + +import Box from '@mui/material/Box'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import TextField from '@mui/material/TextField'; + +import { useAppDispatch, useAppSelector } from '~/hooks/store'; + +import { updateAppSettings } from './slice'; +import { getTerrainSettings } from './selectors'; +import type { TerrainMode } from './types'; + +const TerrainSettingsEditor = () => { + const dispatch = useAppDispatch(); + const terrain = useAppSelector(getTerrainSettings); + const { t } = useTranslation(); + + const setMode = (mode: TerrainMode) => { + const next = { ...terrain, mode }; + // Save current token to the old provider slot, load the other provider's + // stored token when switching between active providers. + if (terrain.mode === 'googleMaps' && mode !== 'googleMaps') { + next.googleMapsToken = terrain.token; + next.token = mode === 'cesiumIon' ? terrain.cesiumIonToken : ''; + } else if (terrain.mode === 'cesiumIon' && mode !== 'cesiumIon') { + next.cesiumIonToken = terrain.token; + next.token = mode === 'googleMaps' ? terrain.googleMapsToken : ''; + } else if (terrain.mode === 'disabled' && mode !== 'disabled') { + next.token = mode === 'googleMaps' ? terrain.googleMapsToken : terrain.cesiumIonToken; + } + dispatch(updateAppSettings('threeD', { terrain: next })); + }; + + const setField = (field: string, value: string | number) => { + dispatch( + updateAppSettings('threeD', { terrain: { ...terrain, [field]: value } }) + ); + }; + + const showToken = terrain.mode !== 'disabled'; + + return ( + <> + + + {t('settings.terrain.label')} + + + + + {showToken && ( + + setField('token', event.target.value)} + /> + + )} + + ); +}; + +export default TerrainSettingsEditor; diff --git a/src/features/settings/selectors.ts b/src/features/settings/selectors.ts index 513e43f..61f96ae 100644 --- a/src/features/settings/selectors.ts +++ b/src/features/settings/selectors.ts @@ -3,6 +3,7 @@ import config from 'config'; import { DEFAULT_DRONE_MODEL, DEFAULT_PLAYBACK_FPS } from '~/constants'; import type { RootState } from '~/store'; import { isSupportedFrameRate } from '../playback/types'; +import type { TerrainSettings } from './types'; export const shouldShowPlaybackHintButton = () => config.buttons.playbackHint; export const shouldUseWelcomeScreen = () => config.useWelcomeScreen; @@ -19,6 +20,9 @@ export const getLanguage = (state: RootState) => export const getScenery = (state: RootState) => state.settings.threeD.scenery; +export const getTerrainSettings = (state: RootState): TerrainSettings => + state.settings.threeD.terrain ?? { mode: 'disabled', tilesetUrl: '', token: '', cesiumAssetId: 0, googleMapsToken: '', cesiumIonToken: '' }; + export const getSimulatedPlaybackFrameRate = (state: RootState) => { const fps = state.settings.playback?.fps ?? DEFAULT_PLAYBACK_FPS; return isSupportedFrameRate(fps) ? fps : DEFAULT_PLAYBACK_FPS; diff --git a/src/features/settings/slice.ts b/src/features/settings/slice.ts index c9fcf4d..aa8369e 100644 --- a/src/features/settings/slice.ts +++ b/src/features/settings/slice.ts @@ -7,7 +7,7 @@ import config from 'config'; import { DEFAULT_DRONE_MODEL, DEFAULT_PLAYBACK_FPS } from '~/constants'; -import type { DroneModelType } from './types'; +import type { DroneModelType, TerrainSettings } from './types'; type SettingsSliceState = { general: { @@ -27,6 +27,7 @@ type SettingsSliceState = { showYaw: boolean; droneRadius?: number; droneModel?: DroneModelType; + terrain: TerrainSettings; }; }; @@ -74,6 +75,16 @@ const initialState: SettingsSliceState = { // Drone model to use in the 3D view droneModel: DEFAULT_DRONE_MODEL, + + // Realistic 3D terrain settings + terrain: { + mode: 'disabled', + tilesetUrl: '', + token: '', + cesiumAssetId: 0, + googleMapsToken: '', + cesiumIonToken: '', + }, }, }; diff --git a/src/features/settings/types.ts b/src/features/settings/types.ts index f8d22af..1d04544 100644 --- a/src/features/settings/types.ts +++ b/src/features/settings/types.ts @@ -3,3 +3,14 @@ export type DroneModelType = 'sphere' | 'quad' | 'flapper'; export function isValidDroneModelType(value: string): value is DroneModelType { return value === 'sphere' || value === 'quad' || value === 'flapper'; } + +export type TerrainMode = 'disabled' | 'googleMaps' | 'cesiumIon'; + +export type TerrainSettings = { + mode: TerrainMode; + tilesetUrl: string; + token: string; + cesiumAssetId: number; + googleMapsToken: string; + cesiumIonToken: string; +}; diff --git a/src/features/show/selectors.ts b/src/features/show/selectors.ts index f360e21..4d9cefb 100644 --- a/src/features/show/selectors.ts +++ b/src/features/show/selectors.ts @@ -20,6 +20,7 @@ import { type Camera, type Cue, type DroneSpecification, + type Location, type PyroProgram, type ShowMetadata, type ShowSettings, @@ -230,6 +231,56 @@ export const isShowIndoor = (state: RootState) => export const isShowOutdoor = (state: RootState) => getShowEnvironmentType(state) === 'outdoor'; +/** + * Scale factor for converting latitude/longitude from the .skyc file format + * (1e-7 degree integers) to decimal degrees. + */ +const LOCATION_COORD_SCALE = 1 / 10_000_000; + +/** + * Scale factor for converting altitude from the .skyc file format + * (millimeters) to meters. + */ +const LOCATION_ALT_SCALE = 1 / 1000; + +/** + * Returns the geodetic location of the show, or undefined if the show has + * no location data. Converts from the .skyc file's native integer format + * (1e-7 degrees, millimeters) to degrees and meters. + */ +export const getShowLocation = createSelector( + getShowSpecification, + (spec?: ShowSpecification): Location | undefined => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const raw: any = (spec as any)?.environment?.location; + if (!raw) { + return undefined; + } + let origin: any; + let orientation: number | undefined; + if (Array.isArray(raw)) { + origin = raw; + orientation = undefined; + } else { + origin = raw.origin; + orientation = raw.orientation; + } + if (!origin || !Array.isArray(origin)) { + console.warn('[getShowLocation] Invalid location origin:', origin, 'raw:', raw); + return undefined; + } + const [rawLat, rawLon, rawAlt] = origin; + // TODO: When the show file does not provide an altitude, default to 0 + // here. In the future, the proper AMSL elevation for this location + // should be inferred from the terrain tiles or a geoid model. + const alt = rawAlt !== undefined ? rawAlt * LOCATION_ALT_SCALE : 0; + return { + origin: [rawLat * LOCATION_COORD_SCALE, rawLon * LOCATION_COORD_SCALE, alt], + orientation: orientation ?? 0, + }; + } +); + /** * Returns an array containing all the cameras from the show file, * or an empty array if the show has no cameras. diff --git a/src/geo/coordinates.ts b/src/geo/coordinates.ts new file mode 100644 index 0000000..33e1954 --- /dev/null +++ b/src/geo/coordinates.ts @@ -0,0 +1,43 @@ +import { Matrix4, Vector3 } from 'three'; +import { WGS84_ELLIPSOID } from '3d-tiles-renderer/three'; + +const DEG2RAD = Math.PI / 180; + +export function computeEcefToSkybrushTransform( + originLat: number, + originLon: number, + originAlt: number, + orientation: number +): Matrix4 { + const latRad = originLat * DEG2RAD; + const lonRad = originLon * DEG2RAD; + const alphaRad = orientation * DEG2RAD; + const cosA = Math.cos(alphaRad); + const sinA = Math.sin(alphaRad); + + const origin = new Vector3(); + WGS84_ELLIPSOID.getCartographicToPosition(latRad, lonRad, originAlt, origin); + + const east = new Vector3(); + const north = new Vector3(); + const up = new Vector3(); + // Use a temp vector for the surface point to avoid overwriting `origin` + const surfacePoint = new Vector3(); + WGS84_ELLIPSOID.getEastNorthUpAxes(latRad, lonRad, east, north, up, surfacePoint); + + const fwd = new Vector3().copy(north).multiplyScalar(cosA).add( + new Vector3().copy(east).multiplyScalar(sinA) + ); + + const left = new Vector3().copy(north).multiplyScalar(sinA).add( + new Vector3().copy(east).multiplyScalar(-cosA) + ); + + const m = new Matrix4().set( + fwd.x, fwd.y, fwd.z, -fwd.dot(origin), + left.x, left.y, left.z, -left.dot(origin), + up.x, up.y, up.z, -up.dot(origin), + 0, 0, 0, 1, + ); + return m; +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 71096a7..f5fbb8e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -106,7 +106,14 @@ "showAxes": "Show axes", "showGrid": "Show grid", "showLabels": "Show labels", - "showYaw": "Show yaw indicators" + "showYaw": "Show yaw indicators", + "terrain": { + "label": "Real-world terrain", + "disabled": "Disabled", + "googleMaps": "Google Maps", + "cesiumIon": "Cesium Ion", + "token": "Access token" + } }, "sharing": { "copyLinkToClipboard": { diff --git a/src/views/player/ThreeDView.tsx b/src/views/player/ThreeDView.tsx index ef9051d..d824213 100644 --- a/src/views/player/ThreeDView.tsx +++ b/src/views/player/ThreeDView.tsx @@ -18,11 +18,13 @@ import type { } from '@skybrush/aframe-components/spatial'; import { getDroneModel } from '~/features/settings/selectors'; -import type { DroneModelType } from '~/features/settings/types'; +import type { DroneModelType, TerrainSettings } from '~/features/settings/types'; import { getLoadedShowId, getNumberOfDronesInShow, + getShowLocation, } from '~/features/show/selectors'; +import type { Location } from '@skybrush/show-format'; import { getEffectiveDroneRadius, getEffectiveScenery, @@ -61,6 +63,8 @@ type ThreeDViewProps = { readonly showStatistics: boolean; readonly showYaw: boolean; readonly vrEnabled?: boolean; + readonly location?: Location; + readonly terrain: TerrainSettings; }; const DEFAULT_CAMERA_CONFIGURATION = { @@ -68,6 +72,15 @@ const DEFAULT_CAMERA_CONFIGURATION = { rotation: [0, 0, 0], }; +const DEFAULT_TERRAIN_SETTINGS: TerrainSettings = { + mode: 'disabled', + tilesetUrl: '', + token: '', + cesiumAssetId: 0, + googleMapsToken: '', + cesiumIonToken: '', +}; + const ThreeDView = (props: ThreeDViewProps) => { const { axes, @@ -76,6 +89,7 @@ const ThreeDView = (props: ThreeDViewProps) => { droneModel, droneRadius, grid, + location, navigation, numDrones, ref, @@ -85,11 +99,40 @@ const ThreeDView = (props: ThreeDViewProps) => { showLabels, showStatistics, showYaw, + terrain = DEFAULT_TERRAIN_SETTINGS, vrEnabled, } = props; const [cameraId, setCameraId] = useState(0); + const terrainEnabled = terrain.mode !== 'disabled' && !!location; + const terrainOrigin = location?.origin; + const terrainLat = terrainOrigin?.[0] ?? 0; + const terrainLon = terrainOrigin?.[1] ?? 0; + const terrainAlt = terrainOrigin?.[2] ?? 0; + const terrainOrientation = location?.orientation ?? 0; + + const terrainTilesProps = + terrain.mode === 'googleMaps' + ? { + url: 'https://tile.googleapis.com/v1/3dtiles/root.json', + token: terrain.token, + cesiumAssetId: 0, + provider: 'googleMaps', + } + : { + url: '', + token: terrain.token, + cesiumAssetId: terrain.cesiumAssetId || 1, + provider: 'cesiumIon', + }; + + if (terrain.mode !== 'disabled' && !location) { + console.info( + 'Terrain mode enabled but show has no geodetic location; disabling terrain rendering.' + ); + } + const extraCameraProps = { 'look-controls': objectToString({ enabled: false, @@ -105,8 +148,8 @@ const ThreeDView = (props: ThreeDViewProps) => { }), }; const extraSceneProps: Record = {}; - const isLightScenery = scenery === 'day'; - const isSceneryEnabled = scenery !== 'disabled'; + const isLightScenery = scenery === 'day' && !terrainEnabled; + const isSceneryEnabled = scenery !== 'disabled' && !terrainEnabled; if (showStatistics) { extraSceneProps.stats = 'true'; @@ -178,10 +221,25 @@ const ThreeDView = (props: ThreeDViewProps) => { size={numDrones} /> + {terrainEnabled && ( + + )} {/* */} - + {!terrainEnabled && } ); }; @@ -197,6 +255,7 @@ export default connect( droneModel: getDroneModel(state), droneRadius: getEffectiveDroneRadius(state), scenery: getEffectiveScenery(state), + location: getShowLocation(state), }), // mapDispatchToProps {}, diff --git a/src/views/player/sidebar/SettingsTab.tsx b/src/views/player/sidebar/SettingsTab.tsx index d51adca..0bd910c 100644 --- a/src/views/player/sidebar/SettingsTab.tsx +++ b/src/views/player/sidebar/SettingsTab.tsx @@ -6,6 +6,7 @@ import FrameRateSelector from '~/features/settings/FrameRateSelector'; import LanguageSelector from '~/features/settings/LanguageSelector'; import PlaybackSpeedSelector from '~/features/settings/PlaybackSpeedSelector'; import ScenerySelector from '~/features/settings/ScenerySelector'; +import TerrainSettingsEditor from '~/features/settings/TerrainSettingsEditor'; import ThreeDViewSettingToggles from '~/features/settings/ThreeDViewSettingToggles'; /** @@ -21,6 +22,10 @@ const SettingsTab = () => ( + + + +