@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
1,502 lines (1,306 loc) • 72.1 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import {
Box3,
Color,
FrontSide,
MathUtils,
Matrix4,
Raycaster,
UnsignedByteType,
Vector2,
Vector3,
type Camera,
type ColorRepresentation,
type Intersection,
type Mesh,
type Object3D,
type Side,
type TextureDataType,
} from 'three';
import type ColorimetryOptions from '../core/ColorimetryOptions';
import type ColorMap from '../core/ColorMap';
import type Context from '../core/Context';
import type ContourLineOptions from '../core/ContourLineOptions';
import type ElevationProvider from '../core/ElevationProvider';
import type ElevationRange from '../core/ElevationRange';
import type ElevationSample from '../core/ElevationSample';
import type Extent from '../core/geographic/Extent';
import type GetElevationOptions from '../core/GetElevationOptions';
import type GetElevationResult from '../core/GetElevationResult';
import type GraticuleOptions from '../core/GraticuleOptions';
import type HasDefaultPointOfView from '../core/HasDefaultPointOfView';
import type ColorLayer from '../core/layer/ColorLayer';
import type ElevationLayer from '../core/layer/ElevationLayer';
import type HasLayers from '../core/layer/HasLayers';
import type Layer from '../core/layer/Layer';
import type { LayerEvents } from '../core/layer/Layer';
import type MemoryUsage from '../core/MemoryUsage';
import type Pickable from '../core/picking/Pickable';
import type PickableFeatures from '../core/picking/PickableFeatures';
import type PickOptions from '../core/picking/PickOptions';
import type PointOfView from '../core/PointOfView';
import type { SSE } from '../core/ScreenSpaceError';
import type TerrainOptions from '../core/TerrainOptions';
import type RenderingState from '../renderer/RenderingState';
import type { EntityUserData } from './Entity';
import type { Entity3DOptions } from './Entity3D';
import type MapLightingOptions from './MapLightingOptions';
import type { TileGeometryBuilder } from './tiles/TileGeometry';
import type TileVolume from './tiles/TileVolume';
import { defaultColorimetryOptions } from '../core/ColorimetryOptions';
import Coordinates from '../core/geographic/Coordinates';
import CoordinateSystem from '../core/geographic/CoordinateSystem';
import { isColorLayer } from '../core/layer/ColorLayer';
import { isElevationLayer } from '../core/layer/ElevationLayer';
import { isLayer } from '../core/layer/Layer';
import { type GetMemoryUsageContext } from '../core/MemoryUsage';
import { isPickableFeatures } from '../core/picking/PickableFeatures';
import traversePickingCircle from '../core/picking/PickingCircle';
import pickTilesAt, { type MapPickResult } from '../core/picking/PickTilesAt';
import ScreenSpaceError from '../core/ScreenSpaceError';
import {
DEFAULT_ENABLE_STITCHING,
DEFAULT_ENABLE_TERRAIN,
DEFAULT_MAP_SEGMENTS,
} from '../core/TerrainOptions';
import ColorMapAtlas from '../renderer/ColorMapAtlas';
import LayeredMaterial, {
DEFAULT_AZIMUTH,
DEFAULT_GRATICULE_COLOR,
DEFAULT_GRATICULE_STEP,
DEFAULT_GRATICULE_THICKNESS,
DEFAULT_HILLSHADING_INTENSITY,
DEFAULT_HILLSHADING_ZFACTOR,
DEFAULT_ZENITH,
type MaterialOptions,
} from '../renderer/LayeredMaterial';
import ShadowLayeredMaterial from '../renderer/ShadowLayeredMaterial';
import { computeDistanceToFitSphere, computeZoomToFitSphere } from '../renderer/View';
import Capabilities from '../utils/Capabilities';
import { isOrthographicCamera, isPerspectiveCamera } from '../utils/predicates';
import TextureGenerator from '../utils/TextureGenerator';
import { nonNull } from '../utils/tsutils';
import Entity3D, { type Entity3DEventMap } from './Entity3D';
import { MapLightingMode } from './MapLightingOptions';
import EllipsoidTileGeometryBuilder from './tiles/EllipsoidTileGeometryBuilder';
import PlanarTileGeometryBuilder from './tiles/PlanarTileGeometryBuilder';
import PlanarTileVolume from './tiles/PlanarTileVolume';
import TileIndex, { type NeighbourList } from './tiles/TileIndex';
import TileMesh, { isTileMesh } from './tiles/TileMesh';
export { type MapLightingOptions };
/**
* Interface for Map tiles.
*/
export interface Tile extends Mesh {
/**
* The level of detail (LOD) of the tile. LOD 0 means the tile is a root tile.
*/
lod: number;
/**
* The geographic extent of the tile. If the tile has LOD 0, then it is the same as the extent of its parent map.
*/
extent: Extent;
}
/**
* A function that allows subdivision of the specified tile.
* If the function returns `true`, the node can be subdivided.
*/
export type MapSubdivisionStrategy = (
/**
* The tile to subdivide.
*/
tile: Readonly<Tile>,
context: { entity: Readonly<Map>; layers: readonly Readonly<Layer>[] },
) => boolean;
/**
* Allows subdivision if:
* - **if elevation layer present and active and terrain deformation is enabled**: wait for all elevation layers to have finished loading the tile,
* - otherwise allow subdivision without condition
*/
export const defaultMapSubdivisionStrategy: MapSubdivisionStrategy = (tile, context) => {
// No problem subdividing if terrain deformation is disabled,
// since bounding boxes are always up to date (as they don't have an elevation component).
if (!context.entity.terrain.enabled) {
return true;
}
// We have to wait until elevation data is loaded for this
// tile so that the bounding box is up to date.
return context.layers.every(
layer =>
!layer.visible ||
isColorLayer(layer) ||
(isElevationLayer(layer) && layer.isLoaded(tile.id)),
);
};
/**
* Allows subdivision if all layers have loaded this node, regardless the type of the layer.
* This strategy has a greater memory, network and CPU consumption than the default strategy,
* but can be useful to ensure a smooth rendering of tiles without visible flicker or "jumps".
*/
export const allLayersLoadedSubdivisionStrategy: MapSubdivisionStrategy = (tile, context) => {
return context.layers.every(layer => layer.isLoaded(tile.id));
};
/**
* The default background color of maps.
*/
export const DEFAULT_MAP_BACKGROUND_COLOR: ColorRepresentation = '#0a3b59';
/**
* The default tile subdivision threshold.
*/
export const DEFAULT_SUBDIVISION_THRESHOLD = 1.5;
/**
* Comparison function to order layers.
*/
export type LayerCompareFn = (a: Layer, b: Layer) => number;
const IDENTITY = new Matrix4().identity();
/**
* A predicate to determine if the given tile can be used as a neighbour for stitching purposes.
*/
function isStitchableNeighbour(neighbour: TileMesh): boolean {
return (
!neighbour.disposed &&
neighbour.visible &&
neighbour.material.visible &&
neighbour.material.getElevationTexture() != null
);
}
/**
* The maximum supported aspect ratio for the map tiles, before we stop trying to create square
* tiles. This is a safety measure to avoid huge number of root tiles when the extent is a very
* elongated rectangle. If the map extent has a greater ratio than this value, the generated tiles
* will not be square-ish anymore.
*/
const MAX_SUPPORTED_ASPECT_RATIO = 10;
const tmpVector = new Vector3();
const tmpBox3 = new Box3();
const tempNDC = new Vector2();
const tempDims = new Vector2();
const tempElevationCoords = new Coordinates(CoordinateSystem.epsg4326, 0, 0);
const tempCanvasCoords = new Vector2();
const tmpSseSizes: [number, number] = [0, 0];
const tmpIntersectList: Intersection<TileMesh>[] = [];
const tmpNeighbours: NeighbourList<TileMesh> = [null, null, null, null, null, null, null, null];
function getContourLineOptions(
input?: boolean | Partial<ContourLineOptions>,
): Required<ContourLineOptions> {
if (input == null) {
// Default values
return {
enabled: false,
thickness: 1,
interval: 100,
secondaryInterval: 20,
color: new Color(0, 0, 0),
opacity: 1,
};
}
if (typeof input === 'boolean') {
// Default values
return {
enabled: true,
thickness: 1,
interval: 100,
secondaryInterval: 20,
color: new Color(0, 0, 0),
opacity: 1,
};
}
return {
enabled: input.enabled ?? false,
thickness: input.thickness ?? 1,
interval: input.interval ?? 100,
secondaryInterval: input.secondaryInterval ?? 20,
color: input.color ?? new Color(0, 0, 0),
opacity: input.opacity ?? 1,
};
}
function getTerrainOptions(
input: boolean | Partial<TerrainOptions> | undefined,
defaultValue: Readonly<TerrainOptions>,
): Required<TerrainOptions> {
if (input == null) {
// Default values
return { ...defaultValue };
}
if (typeof input === 'boolean') {
return {
enabled: input,
stitching: defaultValue.stitching,
segments: defaultValue.segments,
skirts: { enabled: false, depth: 0 },
};
}
return {
enabled: input.enabled ?? defaultValue.enabled,
stitching: input.stitching ?? defaultValue.stitching,
segments: input.segments ?? defaultValue.segments,
skirts: input.skirts ?? defaultValue.skirts,
};
}
function getGraticuleOptions(
input?: boolean | Partial<GraticuleOptions>,
): Required<GraticuleOptions> {
if (input == null) {
// Default values
return {
enabled: false,
color: DEFAULT_GRATICULE_COLOR,
xStep: DEFAULT_GRATICULE_STEP,
yStep: DEFAULT_GRATICULE_STEP,
xOffset: 0,
yOffset: 0,
thickness: DEFAULT_GRATICULE_THICKNESS,
opacity: 1,
};
}
if (typeof input === 'boolean') {
return {
enabled: input,
color: DEFAULT_GRATICULE_COLOR,
xStep: DEFAULT_GRATICULE_STEP,
yStep: DEFAULT_GRATICULE_STEP,
xOffset: 0,
yOffset: 0,
thickness: DEFAULT_GRATICULE_THICKNESS,
opacity: 1,
};
}
return {
enabled: input.enabled ?? true,
color: input.color ?? DEFAULT_GRATICULE_COLOR,
thickness: input.thickness ?? DEFAULT_GRATICULE_THICKNESS,
xStep: input.xStep ?? DEFAULT_GRATICULE_STEP,
yStep: input.yStep ?? DEFAULT_GRATICULE_STEP,
xOffset: input.xOffset ?? 0,
yOffset: input.yOffset ?? 0,
opacity: input.opacity ?? 1,
};
}
function getColorimetryOptions(input?: ColorimetryOptions): ColorimetryOptions {
return input ?? defaultColorimetryOptions();
}
function getLightingOptions(
input: boolean | Partial<MapLightingOptions> | undefined,
defaultValue: Readonly<Required<MapLightingOptions>>,
): Required<MapLightingOptions> {
if (input == null) {
// Default values
return { ...defaultValue };
}
if (typeof input === 'boolean') {
// Default values
return { ...defaultValue, enabled: input };
}
return {
enabled: input.enabled ?? defaultValue.enabled,
mode: input.mode ?? defaultValue.mode,
elevationLayersOnly: input.elevationLayersOnly ?? defaultValue.elevationLayersOnly,
hillshadeAzimuth: input.hillshadeAzimuth ?? defaultValue.hillshadeAzimuth,
hillshadeZenith: input.hillshadeZenith ?? defaultValue.hillshadeZenith,
hillshadeIntensity: input.hillshadeIntensity ?? defaultValue.hillshadeIntensity,
zFactor: input.zFactor ?? defaultValue.zFactor,
};
}
/**
* Compute the best image size for tiles, taking into account the extent ratio.
* In other words, rectangular tiles will have more pixels in their longest side.
*
* @param extent - The map extent.
*/
function computeImageSize(extent: Extent): Vector2 {
const baseSize = 512;
const dims = extent.dimensions(tempDims);
const ratio = dims.x / dims.y;
if (Math.abs(ratio - 1) < 0.01) {
// We have a square tile
return new Vector2(baseSize, baseSize);
}
if (ratio > 1) {
const actualRatio = Math.min(ratio, MAX_SUPPORTED_ASPECT_RATIO);
// We have an horizontal tile
return new Vector2(Math.round(baseSize * actualRatio), baseSize);
}
const actualRatio = Math.min(1 / ratio, MAX_SUPPORTED_ASPECT_RATIO);
// We have a vertical tile
return new Vector2(baseSize, Math.round(baseSize * actualRatio));
}
function getWidestDataType(layers: Layer[]): TextureDataType {
// Select the type that can contain all the layers (i.e the widest data type.)
let currentSize = -1;
let result: TextureDataType = UnsignedByteType;
for (let i = 0; i < layers.length; i++) {
const layer = layers[i];
const type = layer.getRenderTargetDataType();
const size = TextureGenerator.getBytesPerChannel(type);
if (size > currentSize) {
currentSize = size;
result = type;
}
}
return result;
}
export interface MapEventMap extends Entity3DEventMap {
/** Fires when a the layer ordering changes. */
'layer-order-changed': unknown;
/** Fires when a layer is added to the map. */
'layer-added': { layer: Layer };
/** Fires when a layer is removed from the map. */
'layer-removed': { layer: Layer };
/** Fires when the visibility of a layer present on this map changes. */
'layer-visibility-changed': { layer: Layer };
/** Fires when elevation data has changed on a specific extent of the map. */
'elevation-changed': { extent: Extent };
/** Fires when (final, non-interim) elevation data has been loaded for a specific tile */
'elevation-loaded': { tile: Tile };
/** Fires when all tiles are painted */
'paint-complete': unknown;
/** Fires when a tile is created. */
'tile-created': { tile: Tile };
/** Fires when a tile is deleted. */
'tile-deleted': { tile: Tile };
}
/**
* Constructor options for the {@link Map} entity.
*/
export interface MapOptions extends Entity3DOptions {
/**
* The geographic extent of the map.
*
* Note: It must have the same CRS as the instance this map will be added to.
*/
extent: Extent;
/**
* Maximum tile depth of the map. If `undefined`, there is no limit to the subdivision
* of the map.
* @defaultValue undefined
*/
maxSubdivisionLevel?: number;
/**
* Lighting and shading parameters.
* @defaultValue `undefined` (lighting is disabled)
*/
lighting?: boolean | MapLightingOptions;
/**
* Enables contour lines. If `undefined` or `false`, contour lines
* are not displayed.
*
* Note: this option has no effect if the map does not contain an elevation layer.
* @defaultValue `undefined` (contour lines are disabled)
*/
contourLines?: boolean | Partial<ContourLineOptions>;
/**
* The graticule options.
* @defaultValue undefined (graticule is disabled).
*/
graticule?: boolean | Partial<GraticuleOptions>;
/**
* The colorimetry for the whole map.
* Those are distinct from the individual layers' own colorimetry.
* @defaultValue undefined
*/
colorimetry?: ColorimetryOptions;
/**
* The sidedness of the map surface:
* - `FrontSide` will only display the "above ground" side of the map (in cartesian maps),
* or the outer shell of the map (in globe settings).
* - `BackSide` will only display the "underground" side of the map (in cartesian maps),
* or the inner shell of the map (in globe settings).
* - `DoubleSide` will display both sides of the map.
* @defaultValue `FrontSide`
*/
side?: Side;
/**
* Enable or disable depth testing on materials.
* @defaultValue true
*/
depthTest?: boolean;
/**
* Options for geometric terrain rendering.
*/
terrain?: boolean | Partial<TerrainOptions>;
/**
* If `true`, parts of the map that relate to no-data elevation
* values are not displayed. Note: you should only set this value to `true` if
* an elevation layer is present, otherwise the map will never be displayed.
* @defaultValue false
*/
discardNoData?: boolean;
/**
* The color of the map when no color layers are present.
* @defaultValue {@link DEFAULT_MAP_BACKGROUND_COLOR}
*/
backgroundColor?: ColorRepresentation;
/**
* The opacity of the map background.
* @defaultValue 1 (opaque)
*/
backgroundOpacity?: number;
/**
* Show the map tiles' borders.
* @defaultValue false
*/
showOutline?: boolean;
/**
* The color of the tile borders.
* @defaultValue red
*/
outlineColor?: ColorRepresentation;
/**
* The optional elevation range of the map. The map will not be
* rendered for elevations outside of this range.
* Note: this feature is only useful if an elevation layer is added to this map.
* @defaultValue undefined (elevation range is disabled)
*/
elevationRange?: ElevationRange;
/**
* Force using texture atlases even when not required.
* @defaultValue false
*/
forceTextureAtlases?: boolean;
/**
* The threshold before which a map tile is subdivided.
* @defaultValue {@link DEFAULT_SUBDIVISION_THRESHOLD}
*/
subdivisionThreshold?: number;
/**
* If `true`, the map will cast shadow.
* @defaultValue true
*/
castShadow?: boolean;
/**
* If `true`, the map will receive shadow.
* Note: only available if {@link lighting.mode} is {@link MapLightingMode.LightBased}
* @defaultValue true
*/
receiveShadow?: boolean;
/**
* The subdivision strategy used to subdivide map tiles.
* @defaultValue {@link defaultMapSubdivisionStrategy}
*/
subdivisionStrategy?: MapSubdivisionStrategy;
}
interface ObjectOptions {
castShadow: boolean;
receiveShadow: boolean;
}
/**
* A map is an {@link Entity3D} that represents a flat surface displaying one or more {@link core.layer.Layer | layer(s)}.
*
* ## Supported layers
*
* Maps support various types of layers.
*
* ### Color layers
*
* Maps can contain any number of {@link core.layer.ColorLayer | color layers}, as well as any number of {@link core.layer.MaskLayer | mask layers}.
*
* Color layers are used to display satellite imagery, vector features or any other dataset.
* Mask layers are used to mask parts of a map (like an alpha channel).
*
* ### Elevation layers
*
* Up to one elevation layer can be added to a map, to provide features related to elevation, such
* as terrain deformation, shading, contour lines, etc. Without an elevation layer, the map
* will appear like a flat rectangle on the specified extent.
*
* Note: to benefit from the features given by elevation layers (shading for instance) while keeping
* a flat map, disable terrain in the {@link TerrainOptions}.
*
* 💡 The elevation data can be sampled by the {@link getElevation} method.
*
* ## Picking on maps
*
* Maps can be picked like any other 3D entity, using the {@link entities.Entity3D#pick | pick()} method.
*
* ### GPU-based picking
*
* This is the default method for picking maps. When the user calls {@link entities.Entity3D#pick | pick()},
* the camera's field of view is rendered into a temporary texture, then the pixel(s) around the picked
* point are analyzed to determine the location of the picked point.
*
* The main advantage of this method is that it ignores transparent pixels of the map (such as
* no-data elevation pixels, or transparent color layers).
*
* ### Raycasting-based picking
*
* 💡 This method requires that {@link core.picking.PickOptions.gpuPicking} is disabled.
*
* This method casts a ray that is then intersected with the map's meshes. The first intersection is
* returned.
*
* The main advantage of this method is that it's much faster and puts less pressure on the GPU.
*
* ## Lighting and shadows
*
* The Map currently support two lighting modes:
* - the simplified, hillshade model
* - the dynamic, light-based model, that uses three.js lights
*
* Both modes support casting shadows from the Map (on other objects), but only the light-based
* mode enables Maps to _receive_ shadows (from itself or other objects).
*
* @typeParam UserData - The type of the {@link entities.Entity#userData} property.
*/
class Map<UserData extends EntityUserData = EntityUserData>
extends Entity3D<MapEventMap, UserData>
implements
Pickable<MapPickResult>,
PickableFeatures<unknown, MapPickResult>,
ElevationProvider,
HasLayers,
MemoryUsage
{
public readonly isMap = true as const;
public override readonly type: string = 'Map' as const;
public readonly hasLayers = true as const;
public readonly isPickableFeatures = true as const;
public readonly extent: Extent;
public readonly maxSubdivisionLevel: number;
private readonly _objectOptions: ObjectOptions = { castShadow: true, receiveShadow: true };
private readonly _layers: Layer[] = [];
private readonly _onLayerVisibilityChanged: (event: { target: Layer }) => void;
private readonly _onTileElevationChanged: (tile: TileMesh) => void;
private readonly _rootTiles: TileMesh[];
private readonly _allTiles: Set<TileMesh> = new Set();
private readonly _tileIndex: TileIndex<TileMesh>;
private readonly _layerIndices: globalThis.Map<string, number>;
private readonly _cachedTraversals: globalThis.Map<Object3D, TileMesh[]> = new globalThis.Map();
private readonly _layerIds: Set<string> = new Set();
private readonly _materialOptions: MaterialOptions;
private readonly _subdivisionStrategy: MapSubdivisionStrategy;
private readonly _onNodeComplete: (e: LayerEvents['node-complete']) => void;
private _paintCompleteTimeout: NodeJS.Timeout | null = null;
private _geometryBuilder: TileGeometryBuilder | null = null;
private _hasElevationLayer = false;
private _elevationScaling = 1;
private _colorAtlasDataType: TextureDataType = UnsignedByteType;
private _wireframe = false;
private _subdivisionThreshold;
public override getMemoryUsage(context: GetMemoryUsageContext): void {
this._layers.forEach(layer => layer.getMemoryUsage(context));
this._allTiles.forEach(tile => tile.getMemoryUsage(context));
}
/**
* Constructs a Map object.
*
* @param options - Constructor options.
*/
public constructor(options: MapOptions) {
super(options);
this._rootTiles = [];
this._layerIndices = new window.Map();
if (!options.extent.isValid()) {
throw new Error(
'Invalid extent: minX must be less than maxX and minY must be less than maxY.',
);
}
this.extent = options.extent;
this._onNodeComplete = this.onNodeComplete.bind(this);
this._subdivisionStrategy = options.subdivisionStrategy ?? defaultMapSubdivisionStrategy;
this._subdivisionThreshold = options.subdivisionThreshold ?? DEFAULT_SUBDIVISION_THRESHOLD;
this.maxSubdivisionLevel = options.maxSubdivisionLevel ?? 30;
this._onTileElevationChanged = this.onTileElevationChanged.bind(this);
this._onLayerVisibilityChanged = this.onLayerVisibilityChanged.bind(this);
this._materialOptions = {
showColliderMeshes: false,
showBoundingSpheres: false,
helperColor: 'cyan',
showBoundingBoxes: false,
forceTextureAtlases: options.forceTextureAtlases ?? false,
lighting: getLightingOptions(options.lighting, this.getDefaultLightingOptions()),
contourLines: getContourLineOptions(options.contourLines),
discardNoData: options.discardNoData ?? false,
side: options.side ?? FrontSide,
depthTest: options.depthTest ?? true,
showTileOutlines: options.showOutline ?? false,
terrain: getTerrainOptions(options.terrain, this.getDefaultTerrainOptions()),
colorimetry: getColorimetryOptions(options.colorimetry),
graticule: getGraticuleOptions(options.graticule),
colorMapAtlas: null,
elevationRange: options.elevationRange ?? null,
backgroundOpacity: options.backgroundOpacity ?? 1,
tileOutlineColor: new Color(options.outlineColor ?? '#ff0000'),
backgroundColor:
options.backgroundColor !== undefined
? new Color(options.backgroundColor)
: new Color(DEFAULT_MAP_BACKGROUND_COLOR),
};
this._tileIndex = new TileIndex();
}
public get tileIndex(): Readonly<TileIndex<TileMesh>> {
return this._tileIndex;
}
/**
* Gets the tiles at the root of the hierarchy (i.e LOD 0).
*/
public get rootTiles(): Readonly<TileMesh[]> {
return this._rootTiles;
}
/**
* Returns `true` if this map is currently processing data.
*/
public override get loading(): boolean {
return this._layers.some(l => l.loading);
}
private onNodeComplete(e: LayerEvents['node-complete']): void {
if (this._paintCompleteTimeout) {
clearTimeout(this._paintCompleteTimeout);
}
if (isElevationLayer(e.layer)) {
this.dispatchEvent({ type: 'elevation-loaded', tile: e.node as unknown as Tile });
}
this._paintCompleteTimeout = setTimeout(this.evaluatePaintComplete.bind(this), 500);
}
private evaluatePaintComplete(): void {
let complete = true;
this.traverseTiles(tile => {
if (tile.visible && tile.material.visible) {
const tileComplete = this._layers
.filter(l => l.visible)
.every(l => l.isLoaded(tile.id));
if (!tileComplete) {
complete = false;
}
}
});
if (complete) {
this.dispatchEvent({ type: 'paint-complete' });
}
}
/**
* Gets the loading progress (between 0 and 1) of the map. This is the average progress of all
* layers in this map.
* Note: if no layer is present, this will always be 1.
* Note: This value is only meaningful is {@link loading} is `true`.
*/
public override get progress(): number {
if (this._layers.length === 0) {
return 1;
}
const sum = this._layers.reduce((accum, layer) => accum + layer.progress, 0);
return sum / this._layers.length;
}
/**
* Gets or sets depth testing on materials.
*/
public get depthTest(): boolean {
return this._materialOptions.depthTest;
}
public set depthTest(v: boolean) {
this._materialOptions.depthTest = v;
}
/**
* Gets or sets the background opacity.
*/
public get backgroundOpacity(): number {
return this._materialOptions.backgroundOpacity;
}
public set backgroundOpacity(opacity: number) {
this._materialOptions.backgroundOpacity = opacity;
}
/**
* Gets or sets the terrain options.
*/
public get terrain(): Required<TerrainOptions> {
return this._materialOptions.terrain;
}
public set terrain(terrain: TerrainOptions) {
this._materialOptions.terrain = getTerrainOptions(terrain, this.getDefaultTerrainOptions());
}
/**
* The global factor that drives SSE (screen space error) computation. The lower this value, the
* sooner a tile is subdivided. Note: changing this scale to a value less than 1 can drastically
* increase the number of tiles displayed in the scene, and can even lead to WebGL crashes.
*
* @defaultValue {@link DEFAULT_SUBDIVISION_THRESHOLD}
*/
public get subdivisionThreshold(): number {
return this._subdivisionThreshold;
}
public set subdivisionThreshold(v: number) {
this._subdivisionThreshold = v;
}
/**
* Gets or sets the sidedness of the map surface:
* - `FrontSide` will only display the "above ground" side of the map (in cartesian maps),
* or the outer shell of the map (in globe settings).
* - `BackSide` will only display the "underground" side of the map (in cartesian maps),
* or the inner shell of the map (in globe settings).
* - `DoubleSide` will display both sides of the map.
* @defaultValue `FrontSide`
*/
public get side(): Side {
return this._materialOptions.side;
}
public set side(newSide: Side) {
this._materialOptions.side = newSide;
}
/**
* Toggles discard no-data pixels.
*/
public get discardNoData(): boolean {
return this._materialOptions.discardNoData;
}
public set discardNoData(opacity: boolean) {
this._materialOptions.discardNoData = opacity;
}
/**
* Gets or sets the background color.
*/
public get backgroundColor(): Color {
return this._materialOptions.backgroundColor;
}
public set backgroundColor(c: ColorRepresentation) {
this._materialOptions.backgroundColor = new Color(c);
}
/**
* Gets or sets graticule options.
*/
public get graticule(): Required<GraticuleOptions> {
return this._materialOptions.graticule;
}
public set graticule(opts: GraticuleOptions) {
this._materialOptions.graticule = getGraticuleOptions(opts);
}
private updateObject(obj: Object3D): void {
const opts = this._objectOptions;
obj.castShadow = opts.castShadow;
obj.receiveShadow = opts.receiveShadow;
}
private updateObjectOption<K extends keyof ObjectOptions>(
key: K,
value: ObjectOptions[K],
): void {
if (this._objectOptions[key] !== value) {
this._objectOptions[key] = value;
this.traverse(o => this.updateObject(o));
this.notifyChange(this);
}
}
/**
* Toggles the `.castShadow` property on objects generated by this entity.
*/
public get castShadow(): boolean {
return this._objectOptions.castShadow;
}
public set castShadow(v: boolean) {
this.updateObjectOption('castShadow', v);
}
/**
* Toggles the `.receiveShadow` property on objects generated by this entity.
*
* Note that map tiles will receive shadows only if {@link lighting} mode is set to {@link MapLightingMode.LightBased}.
*/
public get receiveShadow(): boolean {
return this._objectOptions.receiveShadow;
}
public set receiveShadow(v: boolean) {
this.updateObjectOption('receiveShadow', v);
}
/**
* Gets or sets lighting options.
*/
public get lighting(): Required<MapLightingOptions> {
return this._materialOptions.lighting;
}
public set lighting(opts: MapLightingOptions) {
this._materialOptions.lighting = getLightingOptions(opts, this.getDefaultLightingOptions());
}
/**
* Gets or sets colorimetry options.
*/
public get colorimetry(): Required<ColorimetryOptions> {
return this._materialOptions.colorimetry;
}
public set colorimetry(opts: ColorimetryOptions) {
this._materialOptions.colorimetry = opts;
}
/**
* Gets or sets elevation range.
*/
public get elevationRange(): ElevationRange | null {
return this._materialOptions.elevationRange;
}
public set elevationRange(range: ElevationRange | null) {
this._materialOptions.elevationRange = range;
}
/**
* Shows tile outlines.
*/
public get showTileOutlines(): boolean {
return this._materialOptions.showTileOutlines;
}
public set showTileOutlines(show: boolean) {
this._materialOptions.showTileOutlines = show;
}
/**
* Gets or sets tile outline color.
*/
public get tileOutlineColor(): Color {
return this._materialOptions.tileOutlineColor;
}
public set tileOutlineColor(color: ColorRepresentation) {
this._materialOptions.tileOutlineColor = new Color(color);
}
/**
* Gets or sets contour line options.
*/
public get contourLines(): Required<ContourLineOptions> {
return this._materialOptions.contourLines;
}
public set contourLines(opts: ContourLineOptions) {
this._materialOptions.contourLines = getContourLineOptions(opts);
}
/**
* Shows volumes of tiles.
*/
public get showBoundingBoxes(): boolean {
return this._materialOptions.showBoundingBoxes;
}
public set showBoundingBoxes(show: boolean) {
if (this._materialOptions.showBoundingBoxes !== show) {
this._materialOptions.showBoundingBoxes = show;
this.notifyChange(this);
}
}
/**
* Shows volumes of tiles.
*/
public get showBoundingSpheres(): boolean {
return this._materialOptions.showBoundingSpheres;
}
public set showBoundingSpheres(show: boolean) {
if (this._materialOptions.showBoundingSpheres !== show) {
this._materialOptions.showBoundingSpheres = show;
this.notifyChange(this);
}
}
/**
* Shows volumes of tiles.
*/
public get helperColor(): ColorRepresentation {
return this._materialOptions.helperColor;
}
public set helperColor(color: ColorRepresentation) {
if (this._materialOptions.helperColor !== color) {
this._materialOptions.helperColor = color;
this.notifyChange(this);
}
}
/**
* Shows meshes used for raycasting purposes.
*/
public get showColliderMeshes(): boolean {
return this._materialOptions.showColliderMeshes;
}
public set showColliderMeshes(show: boolean) {
if (this._materialOptions.showColliderMeshes !== show) {
this._materialOptions.showColliderMeshes = show;
this.notifyChange(this);
}
}
public get segments(): number {
return this._materialOptions.terrain.segments;
}
public set segments(v: number) {
if (this._materialOptions.terrain.segments !== v) {
if (MathUtils.isPowerOfTwo(v) && v >= 1 && v <= 128) {
this._materialOptions.terrain.segments = v;
if (
this._geometryBuilder instanceof PlanarTileGeometryBuilder ||
this._geometryBuilder instanceof EllipsoidTileGeometryBuilder
) {
this._geometryBuilder.segments = v;
}
this.updateGeometries();
this.notifyChange(this);
} else {
throw new Error(
'invalid segments. Must be a power of two between 1 and 128 included',
);
}
}
}
/**
* Displays the map tiles in wireframe.
*/
public get wireframe(): boolean {
return this._wireframe;
}
public set wireframe(v: boolean) {
if (v !== this._wireframe) {
this._wireframe = v;
this.traverseTiles(tile => {
tile.material.wireframe = v;
});
}
}
private subdivideNode(context: Context, node: TileMesh): void {
if (!node.children.some(n => isTileMesh(n))) {
const extents = node.extent.split(2, 2);
let i = 0;
const { x, y, z } = node.coordinate;
for (const extent of extents) {
let child: TileMesh;
if (i === 0) {
child = this.requestNewTile(extent, node, z + 1, 2 * x + 0, 2 * y + 0);
} else if (i === 1) {
child = this.requestNewTile(extent, node, z + 1, 2 * x + 0, 2 * y + 1);
} else if (i === 2) {
child = this.requestNewTile(extent, node, z + 1, 2 * x + 1, 2 * y + 0);
} else {
child = this.requestNewTile(extent, node, z + 1, 2 * x + 1, 2 * y + 1);
}
// inherit our parent's textures
for (const e of this.getElevationLayers()) {
e.update(context, child);
}
for (const c of this.getColorLayers()) {
c.update(context, child);
}
child.update(this._materialOptions);
child.updateMatrixWorld(true);
i++;
}
this.notifyChange(node);
}
}
private updateGeometries(): void {
this.traverseTiles(tile => {
tile.segments = this.segments;
});
}
/**
* Gets the number of vertical and horizontal subdivisions for the root tile matrix.
*/
protected getRootTileMatrix(): { x: number; y: number } {
return nonNull(this._geometryBuilder).rootTileMatrix;
}
public override preprocess(): Promise<void> {
if (!this.extent.crs.equals(this.getComposerProjection())) {
throw new Error(
`The extent of this map is not in the correct CRS. Expected: ${this.getComposerProjection().id}, got: ${this.extent.crs.id}`,
);
}
this._geometryBuilder = this.getGeometryBuilder();
const subdivs = this.getRootTileMatrix();
// If the map is not square, we want to have more than a single
// root tile to avoid elongated tiles that hurt visual quality and SSE computation.
const rootExtents = this.extent.split(subdivs.x, subdivs.y);
let i = 0;
for (const root of rootExtents) {
if (subdivs.x > subdivs.y) {
this._rootTiles.push(this.requestNewTile(root, undefined, 0, i, 0));
} else if (subdivs.y > subdivs.x) {
this._rootTiles.push(this.requestNewTile(root, undefined, 0, 0, i));
} else {
this._rootTiles.push(this.requestNewTile(root, undefined, 0, 0, 0));
}
i++;
}
for (const tile of this._rootTiles) {
this.object3d.add(tile);
tile.updateMatrixWorld(false);
}
return Promise.resolve();
}
/**
* Compute the best image size for tiles, taking into account the extent ratio.
* In other words, rectangular tiles will have more pixels in their longest side.
*
* @param extent - The tile extent.
*/
protected getTextureSize(extent: Extent): Vector2 {
return computeImageSize(extent);
}
protected getTileDimensions(extent: Extent): Vector2 {
return extent.dimensions();
}
protected get isEllipsoidal(): boolean {
return false;
}
protected getComposerProjection(): CoordinateSystem {
return this.instance.coordinateSystem;
}
protected getGeometryBuilder(): TileGeometryBuilder {
return new PlanarTileGeometryBuilder({
extent: this.extent,
maxAspectRatio: MAX_SUPPORTED_ASPECT_RATIO,
segments: this.segments,
skirtDepth: this.terrain.skirts.enabled ? this.terrain.skirts.depth : undefined,
});
}
private requestNewTile(
extent: Extent,
parent: TileMesh | undefined,
z: number,
x = 0,
y = 0,
): TileMesh {
const textureSize = this.getTextureSize(extent);
const materialOptions = {
renderer: this.instance.renderer,
options: this._materialOptions,
textureSize,
extent,
tileDimensions: this.getTileDimensions(extent),
getIndexFn: this.getIndex.bind(this),
textureDataType: this._colorAtlasDataType,
hasElevationLayer: this._hasElevationLayer,
maxTextureImageUnits: Capabilities.getMaxTextureUnitsCount(),
isGlobe: this.isEllipsoidal,
};
const material = new LayeredMaterial(materialOptions);
const depthMaterial = new ShadowLayeredMaterial({
...materialOptions,
source: material,
shadowMode: 'depth',
});
const distanceMaterial = new ShadowLayeredMaterial({
...materialOptions,
source: material,
shadowMode: 'distance',
});
const tile = new TileMesh({
renderer: this.instance.renderer,
material,
depthMaterial,
distanceMaterial,
extent,
textureSize,
segments: this.segments,
coord: { z, x, y },
skirtDepth: this.terrain.skirts.enabled ? this.terrain.skirts.depth : undefined,
enableTerrainDeformation: this._materialOptions.terrain.enabled ?? true,
onElevationChanged: this._onTileElevationChanged,
geometryBuilder: nonNull(this._geometryBuilder),
volume: this.createTileVolume(extent),
});
tile.setVerticalScaling(this._elevationScaling);
this._allTiles.add(tile);
this._tileIndex.addTile(tile);
this._cachedTraversals.clear();
tile.material.opacity = this.opacity;
const position = tile.absolutePosition;
tile.position.copy(position);
tile.opacity = this.opacity;
tile.setVisibility(false);
tile.updateMatrix();
tile.material.wireframe = this.wireframe || false;
if (parent) {
tile.setBBoxZ(parent.minmax.min, parent.minmax.max);
} else {
const { min, max } = this.getElevationMinMax();
tile.setBBoxZ(min, max);
}
this.updateObject(tile);
this.dispatchEvent({ type: 'tile-created', tile });
this.onObjectCreated(tile);
if (parent) {
parent.addChildTile(tile);
}
return tile;
}
protected createTileVolume(extent: Extent): TileVolume {
return new PlanarTileVolume({ extent, range: { min: -1, max: +1 } });
}
private onTileElevationChanged(tile: TileMesh): void {
this.dispatchEvent({ type: 'elevation-changed', extent: tile.extent });
}
/**
* Sets the render state of the map.
*
* @internal
* @param state - The new state.
* @returns The function to revert to the previous state.
*/
public setRenderState(state: RenderingState): () => void {
const restores = this._rootTiles.map(n => n.pushRenderState(state));
return () => {
restores.forEach(r => r());
};
}
public override pick(coordinates: Vector2, options?: PickOptions): MapPickResult[] {
if (options?.gpuPicking === true) {
return pickTilesAt(this.instance, coordinates, this, options);
} else {
return this.pickUsingRaycast(coordinates, options);
}
}
private raycastAtCoordinate(
coordinates: Vector2,
results: MapPickResult[],
options?: PickOptions,
): void {
const normalized = this.instance.canvasToNormalizedCoords(coordinates, tempNDC);
const raycaster = new Raycaster();
raycaster.setFromCamera(normalized, this.instance.view.camera);
tmpIntersectList.length = 0;
this.raycast(raycaster, tmpIntersectList);
const filter = options?.filter ?? ((): boolean => true);
if (tmpIntersectList.length > 0) {
tmpIntersectList.sort((a, b) => a.distance - b.distance);
const intersect = tmpIntersectList[0];
const { x, y, z } = intersect.point;
const pickResult: MapPickResult = {
isMapPickResult: true,
coord: new Coordinates(this.instance.coordinateSystem, x, y, z),
entity: this,
...intersect,
};
if (filter(pickResult)) {
results.push(pickResult);
}
}
}
protected getDefaultTerrainOptions(): Readonly<TerrainOptions> {
return {
enabled: DEFAULT_ENABLE_TERRAIN,
stitching: DEFAULT_ENABLE_STITCHING,
segments: DEFAULT_MAP_SEGMENTS,
skirts: { enabled: false, depth: 0 },
};
}
protected getDefaultLightingOptions(): Readonly<Required<MapLightingOptions>> {
return {
enabled: false,
mode: MapLightingMode.Hillshade,
elevationLayersOnly: false,
hillshadeIntensity: DEFAULT_HILLSHADING_INTENSITY,
zFactor: DEFAULT_HILLSHADING_ZFACTOR,
hillshadeAzimuth: DEFAULT_AZIMUTH,
hillshadeZenith: DEFAULT_ZENITH,
};
}
private pickUsingRaycast(coordinates: Vector2, options?: PickOptions): MapPickResult[] {
const results: MapPickResult[] = [];
const radius = options?.radius;
if (radius == null || radius === 0) {
this.raycastAtCoordinate(coordinates, results, options);
} else {
const originX = coordinates.x;
const originY = coordinates.y;
traversePickingCircle(radius, (x, y) => {
tempCanvasCoords.set(originX + x, originY + y);
this.raycastAtCoordinate(tempCanvasCoords, results, options);
return null;
});
}
return results;
}
/**
* Perform raycasting on visible tiles.
* @param raycaster - The THREE raycaster.
* @param intersects - The intersections array to populate with intersections.
*/
public raycast(raycaster: Raycaster, intersects: Intersection<TileMesh>[]): void {
this.traverseTiles(tile => {
if (!tile.disposed && tile.visible && tile.material.visible) {
tile.raycast(raycaster, intersects);
}
});
intersects.sort((a, b) => a.distance - b.distance);
}
public pickFeaturesFrom(pickedResult: MapPickResult, options?: PickOptions): unknown[] {
const result: unknown[] = [];
for (const layer of this._layers) {
if (isPickableFeatures(layer)) {
const res = layer.pickFeaturesFrom(pickedResult, options);
result.push(...res);
}
}
pickedResult.features = result;
return result;
}
public override preUpdate(context: Context, changeSources: Set<unknown>): TileMesh[] {
super.preUpdate(context, changeSources);
this._materialOptions.colorMapAtlas?.update();
this._tileIndex.update();
if (changeSources.has(undefined) || changeSources.size === 0) {
return this._rootTiles;
}
let commonAncestor: TileMesh | null = null;
for (const source of changeSources.values()) {
if ((source as Camera).isCamera) {
// if the change is caused by a camera move, no need to bother
// to find common ancestor: we need to update the whole tree:
// some invisible tiles may now be visible
return this._rootTiles;
}
if (isTileMesh(source)) {
if (!commonAncestor) {
commonAncestor = source;
} else {
commonAncestor = source.findCommonAncestor(commonAncestor);
if (!commonAncestor) {
return this._rootTiles;
}
}
if (commonAncestor.material == null) {
commonAncestor = null;
}
}
}
if (commonAncestor) {
return [commonAncestor];
}
return this._rootTiles;
}
/**
* Sort the color layers according to the comparator function.
*
* @param compareFn - The comparator function.
*/
public sortColorLayers(compareFn: LayerCompareFn): void {
if (compareFn == null) {
throw new Error('missing comparator function');
}
this._layers.sort((a, b) => {
if (isColorLayer(a) && isColorLayer(b)) {
return compareFn(a, b);
}
// Sorting elevation layers has no effect currently, so by convention
// we push them to the start of the list.
if (isElevationLayer(a) && isElevationLayer(b)) {
return 0;
}
if (isElevationLayer(a)) {
return -1;
}
return 1;
});
this.reorderLayers();
}
/**
* Moves the layer closer to the foreground.
*
* Note: this only applies to color layers.
*
* @param layer - The layer to move.
* @throws If the layer is not present in the map.
* @example
* map.addLayer(foo);
* map.addLayer(bar);
* map.addLayer(baz);
* // Layers (back to front) : foo, bar, baz
*
* map.moveLayerUp(foo);
* // Layers (back to front) : bar, foo, baz
*/
public moveLayerUp(layer: ColorLayer): void {
const position = this._layers.indexOf(layer);
if (position === -1) {
throw new Error('The layer is not present in the map.');
}
if (position < this._layers.length - 1) {
const next = this._layers[position + 1];
this._layers[position + 1] = layer;
this._layers[position] = next;
this.reorderLayers();
}
}
public override onRenderingContextRestored():