@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
251 lines (215 loc) • 10.3 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import type { Euler, Matrix4, Vector2 } from 'three';
import { FrontSide, MathUtils, Quaternion, Vector3 } from 'three';
import type HasDefaultPointOfView from '../core/HasDefaultPointOfView';
import type { HeadingPitchRollLike } from '../core/HeadingPitchRoll';
import type Layer from '../core/layer/Layer';
import type PointOfView from '../core/PointOfView';
import type { MapOptions } from './Map';
import type { TileGeometryBuilder } from './tiles/TileGeometry';
import type TileVolume from './tiles/TileVolume';
import CoordinateSystem from '../core/geographic/CoordinateSystem';
import Ellipsoid from '../core/geographic/Ellipsoid';
import Extent from '../core/geographic/Extent';
import { isColorLayer } from '../core/layer/ColorLayer';
import { isEuler, isMatrix4, isPerspectiveCamera, isQuaternion } from '../utils/predicates';
import { computeEllipsoidalImageSize } from './Globe';
import Map from './Map';
import PanoramaTileGeometryBuilder from './tiles/PanoramaTileGeometryBuilder';
import PanoramaTileVolume from './tiles/PanoramaTileVolume';
const FORWARD = new Vector3(0, 1, 0);
const ORIGIN = new Vector3(0, 0, 0);
const IDENTITY_QUATERNION = new Quaternion().identity();
const tmpQuaternion = new Quaternion();
const tmpVector3 = new Vector3();
/**
* Sets the default orientation of the sphere.
*
* @param orientation - The default orientation.
*/
function getQuaternion(orientation?: Matrix4 | Euler | Quaternion): Quaternion {
if (orientation == null) {
return IDENTITY_QUATERNION;
}
if (isMatrix4(orientation)) {
return tmpQuaternion.setFromRotationMatrix(orientation);
} else if (isEuler(orientation)) {
return tmpQuaternion.setFromEuler(orientation, true);
} else if (isQuaternion(orientation)) {
return tmpQuaternion.copy(orientation);
}
throw new Error('not a valid orientation parameter');
}
/**
* Constructor options for the {@link SphericalPanorama} entity.
*/
export interface SphericalPanoramaOptions extends Omit<MapOptions, 'extent'> {
/**
* The radius of the sphere, in scene units.
* @defaultValue 5
*/
radius?: number;
}
/**
* An entity that can display spherical panoramas in an equirectangular projection.
*
* ## The equirectangular projection
*
* The panoramic image is mapped into a sphere using in the [equirectangular projection](https://en.wikipedia.org/wiki/Equirectangular_projection).
*
* The units are the degrees. This is the same projection that is used for the EPSG:4326 coordinate system.
*
* In this projection:
* - the center of the image is at [0, 0]
* - the top left corner is at [-180, +90],
* - the top right corner is at [+180, +90],
* - the bottom right corner is at [+180, -90],
* - the bottom left corner is at [-180, -90],
*
* ### The `'equirectangular'` coordinate system
*
* This special coordinate system is used for layers that are added to this entity. It is technically equivalent
* to the EPSG:4326 system, but since the images projected into the sphere are not georeferenced in an actual
* cartographic coordinate system, we use this special CRS.
*
* All image sources must express extents in this coordinate system.
*
* ## How to load panoramic images
*
* This entity is a subclass of {@link Map}. To load a panoramic images, you must add it as a {@link core.layer.ColorLayer | ColorLayer} with {@link addLayer}.
*
* - For simple images, such as JPG, PNG and WebP, use a {@link sources.StaticImageSource | StaticImageSource}.
* Note that the image dimensions **cannot exceed WebGL's `MAX_TEXTURE_SIZE`** (4096 pixels),
* and that the extent of this source must be expressed in the `'equirectangular'`CRS (see note below).
*
* - For images that exceed WebGL's `MAX_TEXTURE_SIZE`, you can convert them to
* GeoTIFF / COG with [GDAL](https://gdal.org/en/stable/programs/gdal_translate.html),
* in the EPSG:4326 coordinate system and appropriate georeferencing parameters (i.e if the image
* uses the entire equirectangular projection, its extent would be -180, -90, +180, +90),
* then load it through a {@link sources.GeoTIFFSource | GeoTIFFSource}.
* It will be streamed efficiently to save memory and HTTP bandwidth. Again, the CRS of this source must
* be `'equirectangular'`.
*
* - You can also load arbitrary vector features in the `'equirectangular'` coordinate system
* using a {@link sources.VectorSource | VectorSource}, and they will be positioned accordingly
* on the surface of the sphere. This can be used for example to digitize geometries from features
* visible in the panoramic image. If the features are already expressed in the equirectangular projection,
* no need to mention it in the constructor of the source.
*
* ## Orientation of the panorama
*
* Panoramic images generally have an orientation expressed in _heading_, _pitch_ and _roll_ (typically in degrees).
*
* Use the {@link setOrientation} method to set the orientation of the image for a given coordinate system.
*
* ```js
* panorama.setOrientation({ heading: 56, pitch: -3, roll: 1 });
* ```
*
* In planar coordinate systems, the rotation angles are applied to the local XYZ axes of the entity.
*
* In the EPSG:4978 coordinate system, the sphere is first rotated to match the local reference [East, North, Up (ENU) reference frame](https://en.wikipedia.org/wiki/Local_tangent_plane_coordinates)
* at the coordinate of the center of the sphere, then the angles are applied.
*/
class SphericalPanorama extends Map {
public readonly isSphericalPanorama = true as const;
public override readonly type = 'SphericalPanorama' as const;
private readonly _sphere: Ellipsoid;
private readonly _radius: number;
public constructor(params?: SphericalPanoramaOptions) {
super({
...params,
extent: Extent.fullEquirectangularProjection,
depthTest: params?.depthTest ?? false,
terrain: {
enabled: false,
segments: 32, // To avoid visible seams between tiles
},
backgroundColor: params?.backgroundColor ?? '#000000',
});
this._radius = params?.radius ?? 5;
this._sphere = Ellipsoid.sphere(this._radius);
this.side = params?.side ?? FrontSide;
}
protected override getGeometryBuilder(): TileGeometryBuilder {
return new PanoramaTileGeometryBuilder(this._radius, this.segments);
}
protected override get isEllipsoidal(): boolean {
return true;
}
protected override getComposerProjection(): CoordinateSystem {
return CoordinateSystem.equirectangular;
}
protected override getTextureSize(extent: Extent): Vector2 {
// Since panorama tiles are curved, their extent dimensions is not the same depending
// on the location of the tile. We must compute a reasonable approximation of
// the width and height of the extent in meters.
return computeEllipsoidalImageSize(extent, this._sphere);
}
protected override createTileVolume(extent: Extent): TileVolume {
return new PanoramaTileVolume({ extent, radius: this._radius });
}
public override addLayer<TLayer extends Layer>(layer: TLayer): Promise<TLayer> {
if (isColorLayer(layer)) {
return super.addLayer(layer);
}
throw new Error('Only color layers are supported by this entity');
}
/**
* Returns a point of view that is located at the center of the sphere, and looking at the center of the image.
*
* Note: only perspective cameras are supported. Any other camera type will return `null`.
*/
public override getDefaultPointOfView(
params: Parameters<HasDefaultPointOfView['getDefaultPointOfView']>[0],
): ReturnType<HasDefaultPointOfView['getDefaultPointOfView']> {
if (isPerspectiveCamera(params.camera)) {
const origin = ORIGIN.clone();
const target = origin.clone().add(FORWARD);
this.object3d.updateMatrixWorld(true);
origin.applyMatrix4(this.object3d.matrixWorld);
target.applyMatrix4(this.object3d.matrixWorld);
const result: PointOfView = { origin, target, orthographicZoom: 1 };
return Object.freeze(result);
}
return null;
}
/**
* Sets the orientation of the sphere.
*
* Note: this overrides the current orientation of the root object.
*
* @param params - The parameters. If undefined, the orientation is reset to the default orientation.
*/
public setOrientation(params?: HeadingPitchRollLike): void {
let baseOrientation: Quaternion | Matrix4 = IDENTITY_QUATERNION;
if (this.instance.coordinateSystem.isEpsg(4978)) {
this.object3d.updateMatrixWorld(true);
// Since we are in the WGS84 geocentric coordinate system,
// we have to set the base orientation to match the local tangent coordinates (ENU)
// https://en.wikipedia.org/wiki/Local_tangent_plane_coordinates,
// so that the orientation set with .setOrientation() will start from the this value.
// This is necessary because the heading/pitch/roll values are offsets from the local frame.
baseOrientation = Ellipsoid.WGS84.getEastNorthUpMatrixFromCartesian(
this.object3d.getWorldPosition(tmpVector3),
);
}
// Reset to default orientation.
this.object3d.quaternion.copy(getQuaternion(baseOrientation));
// https://developers.google.com/streetview/spherical-metadata#euler_overview
if (params) {
const heading = params.heading ?? 0;
const pitch = params.pitch ?? 0;
const roll = params.roll ?? 0;
this.object3d.rotateZ(MathUtils.degToRad(-heading)); // Note the negative sign
this.object3d.rotateX(MathUtils.degToRad(pitch));
this.object3d.rotateY(MathUtils.degToRad(roll));
}
this.object3d.updateMatrixWorld(true);
}
}
export default SphericalPanorama;