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 (
+ <>
+
+
+
+
+
+ {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 = () => (
+
+
+
+