UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

1,502 lines (1,306 loc) 72.1 kB
/* * 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():