UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

1,478 lines (1,416 loc) 45.7 kB
import { Box3, Color, FrontSide, Group, MathUtils, Matrix4, Quaternion, Raycaster, UnsignedByteType, Vector2, Vector3 } from 'three'; import { defaultColorimetryOptions } from '../core/ColorimetryOptions'; import Coordinates from '../core/geographic/Coordinates'; import ColorLayer, { isColorLayer } from '../core/layer/ColorLayer'; import ElevationLayer, { isElevationLayer } from '../core/layer/ElevationLayer'; import Layer from '../core/layer/Layer'; import { isPickableFeatures } from '../core/picking/PickableFeatures'; import traversePickingCircle from '../core/picking/PickingCircle'; import pickTilesAt from '../core/picking/PickTilesAt'; import ScreenSpaceError from '../core/ScreenSpaceError'; import Capabilities from '../core/system/Capabilities'; import { DEFAULT_ENABLE_CPU_TERRAIN, DEFAULT_ENABLE_STITCHING, DEFAULT_ENABLE_TERRAIN } from '../core/TerrainOptions'; import TileIndex from '../core/TileIndex'; import TileMesh, { isTileMesh } from '../core/TileMesh'; import AtlasBuilder from '../renderer/AtlasBuilder'; 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 } from '../renderer/LayeredMaterial'; import TextureGenerator from '../utils/TextureGenerator'; import { nonNull } from '../utils/tsutils'; import Entity3D from './Entity3D'; import { MapLightingMode } from './MapLightingOptions'; /** * The default background color of maps. */ export const DEFAULT_MAP_BACKGROUND_COLOR = '#0a3b59'; /** * The default tile subdivision threshold. */ export const DEFAULT_SUBDIVISION_THRESHOLD = 1.5; /** * The default number of segments in a map's tile. */ export const DEFAULT_MAP_SEGMENTS = 32; /** * Comparison function to order layers. */ 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) { 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 tempCanvasCoords = new Vector2(); const tmpSseSizes = [0, 0]; const tmpIntersectList = []; const tmpNeighbours = [null, null, null, null, null, null, null, null]; function getContourLineOptions(input) { 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) { if (input == null) { // Default values return { enabled: DEFAULT_ENABLE_TERRAIN, stitching: DEFAULT_ENABLE_STITCHING, enableCPUTerrain: DEFAULT_ENABLE_CPU_TERRAIN }; } if (typeof input === 'boolean') { return { enabled: input, stitching: DEFAULT_ENABLE_STITCHING, enableCPUTerrain: DEFAULT_ENABLE_CPU_TERRAIN }; } return { enabled: input.enabled ?? DEFAULT_ENABLE_TERRAIN, stitching: input.stitching ?? DEFAULT_ENABLE_STITCHING, enableCPUTerrain: input.enableCPUTerrain ?? DEFAULT_ENABLE_CPU_TERRAIN }; } function getGraticuleOptions(input) { 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) { return input ?? defaultColorimetryOptions(); } function getLightingOptions(input, defaultValue) { 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 }; } function selectBestSubdivisions(extent) { const dims = extent.dimensions(); const ratio = dims.x / dims.y; let x = 1; let y = 1; if (ratio > 1) { // Our extent is an horizontal rectangle x = Math.min(Math.round(ratio), MAX_SUPPORTED_ASPECT_RATIO); } else if (ratio < 1) { // Our extent is an vertical rectangle y = Math.min(Math.round(1 / ratio), MAX_SUPPORTED_ASPECT_RATIO); } return { x, y }; } /** * 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) { const baseSize = 512; const dims = extent.dimensions(); 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) { // Select the type that can contain all the layers (i.e the widest data type.) let currentSize = -1; let result = 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; } /** * 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}. * * 💡 If the {@link TerrainOptions.enableCPUTerrain} is enabled, 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. * * However, if {@link TerrainOptions.enableCPUTerrain} is enabled, then the map provides an alternate * methods for: raycasting-based picking, in addition to GPU-based picking. * * ### 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 TerrainOptions.enableCPUTerrain} is enabled, and 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 extends Entity3D { isMap = true; type = 'Map'; hasLayers = true; _objectOptions = { castShadow: true, receiveShadow: true }; _hasElevationLayer = false; _subdivisions = null; _colorAtlasDataType = UnsignedByteType; _imageSize = null; _wireframe = false; _layers = []; /** @internal */ /** @internal */ allTiles = new Set(); _layerIds = new Set(); /** @internal */ isPickableFeatures = true; /** @internal */ /** * 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} */ getMemoryUsage(context) { this._layers.forEach(layer => layer.getMemoryUsage(context)); this.geometryPool.forEach(geometry => geometry.getMemoryUsage(context)); this.allTiles.forEach(tile => tile.getMemoryUsage(context)); } /** * Constructs a Map object. * * @param options - Constructor options. */ constructor(options) { super(options.object3d || new Group()); this.level0Nodes = []; this.geometryPool = new window.Map(); this._layerIndices = new window.Map(); this._atlasInfo = { maxX: 0, maxY: 0, atlas: null }; 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.subdivisionThreshold = options.subdivisionThreshold ?? DEFAULT_SUBDIVISION_THRESHOLD; this.maxSubdivisionLevel = options.maxSubdivisionLevel ?? 30; this._onTileElevationChanged = this.onTileElevationChanged.bind(this); this._onLayerVisibilityChanged = this.onLayerVisibilityChanged.bind(this); this._segments = options.segments ?? DEFAULT_MAP_SEGMENTS; this._materialOptions = { showColliderMeshes: 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), colorimetry: getColorimetryOptions(options.colorimetry), graticule: getGraticuleOptions(options.graticule), segments: this.segments, 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(); } /** * Returns `true` if this map is currently processing data. */ get loading() { return this._layers.some(l => l.loading); } /** * 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`. */ get progress() { 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. */ get depthTest() { return this._materialOptions.depthTest; } set depthTest(v) { this._materialOptions.depthTest = v; } /** * Gets or sets the background opacity. */ get backgroundOpacity() { return this._materialOptions.backgroundOpacity; } set backgroundOpacity(opacity) { this._materialOptions.backgroundOpacity = opacity; } /** * Gets or sets the terrain options. */ get terrain() { return this._materialOptions.terrain; } set terrain(terrain) { this._materialOptions.terrain = getTerrainOptions(terrain); } /** * 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` */ get side() { return this._materialOptions.side; } set side(newSide) { this._materialOptions.side = newSide; } /** * Toggles discard no-data pixels. */ get discardNoData() { return this._materialOptions.discardNoData; } set discardNoData(opacity) { this._materialOptions.discardNoData = opacity; } /** * Gets or sets the background color. */ get backgroundColor() { return this._materialOptions.backgroundColor; } set backgroundColor(c) { this._materialOptions.backgroundColor = new Color(c); } /** * Gets or sets graticule options. */ get graticule() { return this._materialOptions.graticule; } set graticule(opts) { this._materialOptions.graticule = getGraticuleOptions(opts); } updateObject(obj) { const opts = this._objectOptions; obj.castShadow = opts.castShadow; obj.receiveShadow = opts.receiveShadow; } updateObjectOption(key, value) { 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. */ get castShadow() { return this._objectOptions.castShadow; } set castShadow(v) { 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}. */ get receiveShadow() { return this._objectOptions.receiveShadow; } set receiveShadow(v) { this.updateObjectOption('receiveShadow', v); } /** * Gets or sets lighting options. */ get lighting() { return this._materialOptions.lighting; } set lighting(opts) { this._materialOptions.lighting = getLightingOptions(opts, this.getDefaultLightingOptions()); } /** * Gets or sets colorimetry options. */ get colorimetry() { return this._materialOptions.colorimetry; } set colorimetry(opts) { this._materialOptions.colorimetry = opts; } /** * Gets or sets elevation range. */ get elevationRange() { return this._materialOptions.elevationRange; } set elevationRange(range) { this._materialOptions.elevationRange = range; } /** * Shows tile outlines. */ get showTileOutlines() { return this._materialOptions.showTileOutlines; } set showTileOutlines(show) { this._materialOptions.showTileOutlines = show; } /** * Gets or sets tile outline color. */ get tileOutlineColor() { return this._materialOptions.tileOutlineColor; } set tileOutlineColor(color) { this._materialOptions.tileOutlineColor = new Color(color); } /** * Gets or sets contour line options. */ get contourLines() { return this._materialOptions.contourLines; } set contourLines(opts) { this._materialOptions.contourLines = getContourLineOptions(opts); } /** * Shows meshes used for raycasting purposes. */ get showColliderMeshes() { return this._materialOptions.showColliderMeshes; } set showColliderMeshes(show) { this._materialOptions.showColliderMeshes = show; } get segments() { return this._segments; } set segments(v) { if (this._segments !== v) { if (MathUtils.isPowerOfTwo(v) && v >= 1 && v <= 128) { // Delete cached geometries that just became obsolete this.clearGeometryPool(); this._segments = v; this._materialOptions.segments = v; this.updateGeometries(); } else { throw new Error('invalid segments. Must be a power of two between 1 and 128 included'); } } } /** * Displays the map tiles in wireframe. */ get wireframe() { return this._wireframe; } set wireframe(v) { if (v !== this._wireframe) { this._wireframe = v; this.traverseTiles(tile => { tile.material.wireframe = v; }); } } get imageSize() { return this._imageSize; } subdivideNode(context, node) { if (!node.children.some(n => isTileMesh(n))) { const extents = node.extent.split(2, 2); let i = 0; const { x, y, z } = node; for (const extent of extents) { let child; 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); } } clearGeometryPool() { this.geometryPool.forEach(v => v.dispose()); this.geometryPool.clear(); } updateGeometries() { this.traverseTiles(tile => { tile.segments = this.segments; }); } get subdivisions() { return this._subdivisions; } preprocess() { if (this.extent.crs !== this.instance.referenceCrs) { throw new Error('The extent of this map is not in the same CRS as the Instance CRS'); } const subdivs = selectBestSubdivisions(this.extent); this._subdivisions = subdivs; // 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); this._imageSize = computeImageSize(rootExtents[0]); let i = 0; for (const root of rootExtents) { if (subdivs.x > subdivs.y) { this.level0Nodes.push(this.requestNewTile(root, undefined, 0, i, 0)); } else if (subdivs.y > subdivs.x) { this.level0Nodes.push(this.requestNewTile(root, undefined, 0, 0, i)); } else { this.level0Nodes.push(this.requestNewTile(root, undefined, 0, 0, 0)); } i++; } for (const level0 of this.level0Nodes) { this.object3d.add(level0); level0.updateMatrixWorld(false); } return Promise.resolve(); } requestNewTile(extent, parent, level, x = 0, y = 0) { const quaternion = new Quaternion(); const position = extent.centerAsVector3(); // build tile const material = new LayeredMaterial({ renderer: this.instance.renderer, atlasInfo: this._atlasInfo, options: this._materialOptions, extent, textureSize: nonNull(this._imageSize), tileDimensions: extent.dimensions(), getIndexFn: this.getIndex.bind(this), textureDataType: this._colorAtlasDataType, hasElevationLayer: this._hasElevationLayer, maxTextureImageUnits: Capabilities.getMaxTextureUnitsCount() }); const tile = new TileMesh({ geometryPool: this.geometryPool, instance: this.instance, material, extent, textureSize: nonNull(this._imageSize), segments: this.segments, coord: { level, x, y }, enableCPUTerrain: this._materialOptions.terrain.enableCPUTerrain ?? true, enableTerrainDeformation: this._materialOptions.terrain.enabled ?? true, onElevationChanged: this._onTileElevationChanged }); this.allTiles.add(tile); this.tileIndex.addTile(tile); tile.material.opacity = this.opacity; if (parent && parent instanceof TileMesh) { // get parent position from extent const positionParent = parent.extent.centerAsVector3(); // place relative to his parent position.sub(positionParent).applyQuaternion(parent.quaternion.invert()); quaternion.premultiply(parent.quaternion); } tile.position.copy(position); tile.quaternion.copy(quaternion); 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.onObjectCreated(tile); if (parent) { parent.addChildTile(tile); } return tile; } onTileElevationChanged(tile) { 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. */ setRenderState(state) { const restores = this.level0Nodes.map(n => n.pushRenderState(state)); return () => { restores.forEach(r => r()); }; } pick(coordinates, options) { if (options?.gpuPicking === true) { return pickTilesAt(this.instance, coordinates, this, options); } else { return this.pickUsingRaycast(coordinates, options); } } raycastAtCoordinate(coordinates, results, options) { 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 ?? (() => 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 = { isMapPickResult: true, coord: new Coordinates(this.instance.referenceCrs, x, y, z), entity: this, ...intersect }; if (filter(pickResult)) { results.push(pickResult); } } } getDefaultLightingOptions() { return { enabled: false, mode: MapLightingMode.Hillshade, elevationLayersOnly: false, hillshadeIntensity: DEFAULT_HILLSHADING_INTENSITY, zFactor: DEFAULT_HILLSHADING_ZFACTOR, hillshadeAzimuth: DEFAULT_AZIMUTH, hillshadeZenith: DEFAULT_ZENITH }; } pickUsingRaycast(coordinates, options) { const results = []; 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. */ raycast(raycaster, intersects) { this.traverseTiles(tile => { if (!tile.disposed && tile.visible && tile.material.visible) { tile.raycast(raycaster, intersects); } }); intersects.sort((a, b) => a.distance - b.distance); } pickFeaturesFrom(pickedResult, options) { const result = []; for (const layer of this._layers) { if (isPickableFeatures(layer)) { const res = layer.pickFeaturesFrom(pickedResult, options); result.push(...res); } } pickedResult.features = result; return result; } preUpdate(context, changeSources) { this._materialOptions.colorMapAtlas?.update(); this.tileIndex.update(); if (changeSources.has(undefined) || changeSources.size === 0) { return this.level0Nodes; } let commonAncestor = null; for (const source of changeSources.values()) { if (source.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.level0Nodes; } if (isTileMesh(source)) { if (!commonAncestor) { commonAncestor = source; } else { commonAncestor = source.findCommonAncestor(commonAncestor); if (!commonAncestor) { return this.level0Nodes; } } if (commonAncestor.material == null) { commonAncestor = null; } } } if (commonAncestor) { return [commonAncestor]; } return this.level0Nodes; } /** * Sort the color layers according to the comparator function. * * @param compareFn - The comparator function. */ sortColorLayers(compareFn) { 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 */ moveLayerUp(layer) { 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(); } } onRenderingContextRestored() { this._materialOptions.colorMapAtlas?.forceUpdate(); this.forEachLayer(layer => layer.onRenderingContextRestored()); this.notifyChange(this); } /** * Moves the specified layer after the other layer in the list. * * @param layer - The layer to move. * @param target - The target layer. If `null`, then the layer is put at the * beginning of the layer list. * @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.insertLayerAfter(foo, baz); * // Layers (back to front) : bar, baz, foo */ insertLayerAfter(layer, target) { const position = this._layers.indexOf(layer); let afterPosition = target == null ? -1 : this._layers.indexOf(target); if (position === -1) { throw new Error('The layer is not present in the map.'); } if (afterPosition === -1) { afterPosition = 0; } this._layers.splice(position, 1); afterPosition = target == null ? -1 : this._layers.indexOf(target); this._layers.splice(afterPosition + 1, 0, layer); this.reorderLayers(); } /** * Moves the layer closer to the background. * * 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.moveLayerDown(baz); * // Layers (back to front) : foo, baz, bar */ moveLayerDown(layer) { const position = this._layers.indexOf(layer); if (position === -1) { throw new Error('The layer is not present in the map.'); } if (position > 0) { const prev = this._layers[position - 1]; this._layers[position - 1] = layer; this._layers[position] = prev; this.reorderLayers(); } } /** * Returns the position of the layer in the layer list, or -1 if it is not found. * * @param layer - The layer to search. * @returns The index of the layer. */ getIndex(layer) { const value = this._layerIndices.get(layer.id); if (value == null) { return -1; } return value; } reorderLayers() { const layers = this._layers; for (let i = 0; i < layers.length; i++) { const element = layers[i]; this._layerIndices.set(element.id, i); } this.traverseTiles(tile => tile.reorderLayers()); this.dispatchEvent({ type: 'layer-order-changed' }); this.notifyChange(this); } contains(obj) { if (obj.isLayer) { return this._layers.includes(obj); } return false; } update(context, node) { if (!node.parent) { this.disposeTile(node); return undefined; } // do proper culling if (!this.frozen) { node.visible = this.testVisibility(node, context); } if (node.visible) { let requestChildrenUpdate = false; if (!this.frozen) { const worldBox = node.getWorldSpaceBoundingBox(tmpBox3); const size = worldBox.getSize(tmpVector); const geometricError = Math.max(size.x, size.y); const sse = ScreenSpaceError.computeFromBox3(context.view, worldBox, IDENTITY, geometricError, ScreenSpaceError.Mode.MODE_2D); if (this.testTileSSE(node, sse) && this.canSubdivide(node)) { this.subdivideNode(context, node); // display iff children aren't ready node.setDisplayed(false); requestChildrenUpdate = true; } else { node.setDisplayed(true); } } else { requestChildrenUpdate = true; } if (node.material.visible) { node.material.update(this._materialOptions); this.updateMinMaxDistance(context, node); // update uniforms if (!requestChildrenUpdate) { return node.detachChildren(); } } return requestChildrenUpdate ? node.children.filter(n => isTileMesh(n)) : undefined; } node.setDisplayed(false); return node.detachChildren(); } testVisibility(node, context) { node.update(this._materialOptions); const isVisible = context.view.isBox3Visible(node.boundingBox, node.matrixWorld); return isVisible; } postUpdate(context) { this.traverseTiles(tile => { if (tile.visible && tile.material.visible) { this._layers.forEach(layer => layer.update(context, tile)); } }); this._layers.forEach(l => l.postUpdate()); const computeNeighbours = this._materialOptions.terrain.stitching && this._materialOptions.terrain.enabled; if (computeNeighbours) { this.traverseTiles(tile => { if (tile.material.visible) { const neighbours = this.tileIndex.getNeighbours(tile, tmpNeighbours, isStitchableNeighbour); tile.processNeighbours(neighbours); } }); } } registerColorLayer(layer) { const colorLayers = this._layers.filter(l => l instanceof ColorLayer); // rebuild color textures atlas // We use a margin to prevent atlas bleeding. const factor = layer.resolutionFactor * 1.1; const { x, y } = nonNull(this._imageSize); const size = new Vector2(Math.round(x * factor), Math.round(y * factor)); const { atlas, maxX, maxY } = AtlasBuilder.pack(Capabilities.getMaxTextureSize(), colorLayers.map(l => ({ id: l.id, size })), this._atlasInfo.atlas); this._atlasInfo.atlas = atlas; this._atlasInfo.maxX = Math.max(this._atlasInfo.maxX, maxX); this._atlasInfo.maxY = Math.max(this._atlasInfo.maxY, maxY); this._colorAtlasDataType = getWidestDataType(this.getColorLayers()); } updateGlobalMinMax() { const minmax = this.getElevationMinMax(); this.traverseTiles(tile => { tile.setBBoxZ(minmax.min, minmax.max); }); } registerColorMap(colorMap) { if (!this._materialOptions.colorMapAtlas) { this._materialOptions.colorMapAtlas = new ColorMapAtlas(this.instance.renderer); this.traverseTiles(t => { t.material.setColorMapAtlas(this._materialOptions.colorMapAtlas); }); } this._materialOptions.colorMapAtlas.add(colorMap); } /** * Adds a layer, then returns the created layer. * Before using this method, make sure that the map is added in an instance. * If the extent or the projection of the layer is not provided, * those values will be inherited from the map. * * @param layer - the layer to add * @returns a promise resolving when the layer is ready */ async addLayer(layer) { if (!(layer instanceof Layer)) { throw new Error('layer is not an instance of Layer'); } if (this._layerIds.has(layer.id)) { throw new Error(`layer ${layer.name ?? layer.id} is already present in this map`); } this._layerIds.add(layer.id); this._layers.push(layer); await layer.initialize({ instance: this.instance }); layer.addEventListener('visible-property-changed', this._onLayerVisibilityChanged); if (layer instanceof ColorLayer) { this.registerColorLayer(layer); } else if (layer instanceof ElevationLayer) { this._hasElevationLayer = true; this.updateGlobalMinMax(); } if (layer.colorMap) { this.registerColorMap(layer.colorMap); } this.reorderLayers(); this.notifyChange(this); this.dispatchEvent({ type: 'layer-added', layer }); return layer; } onLayerVisibilityChanged(event) { if (event.target instanceof ElevationLayer) { this.dispatchEvent({ type: 'elevation-changed', extent: this.extent }); } this.traverseTiles(tile => { tile.onLayerVisibilityChanged(event.target); }); } /** * Removes a layer from the map. * * @param layer - the layer to remove * @param options - The options. * @returns `true` if the layer was present, `false` otherwise. */ removeLayer(layer, options = {}) { if (layer == null) { return false; } if (this._layerIds.has(layer.id)) { this._layerIds.delete(layer.id); this._layers.splice(this._layers.indexOf(layer), 1); if (layer.colorMap) { this._materialOptions.colorMapAtlas?.remove(layer.colorMap); } if (layer instanceof ElevationLayer) { this._hasElevationLayer = false; } this.traverseTiles(tile => { layer.unregisterNode(tile); }); layer.removeEventListener('visible-property-changed', this._onLayerVisibilityChanged); layer.postUpdate(); this.reorderLayers(); this.dispatchEvent({ type: 'layer-removed', layer }); this.notifyChange(this); if (options.disposeLayer === true) { layer.dispose(); } return true; } return false; } get layerCount() { return this._layers.length; } forEachLayer(callback) { this._layers.forEach(l => callback(l)); } /** * Gets all layers that satisfy the filter predicate. * * @param predicate - the optional predicate. * @returns the layers that matched the predicate or all layers if no predicate was provided. */ getLayers(predicate) { const result = []; for (const layer of this._layers) { if (!predicate || predicate(layer)) { result.push(layer); } } return result; } /** * Gets all color layers in this map. * * @returns the color layers */ getColorLayers() { return this.getLayers(l => l.isColorLayer); } /** * Gets all elevation layers in this map. * * @returns the elevation layers */ getElevationLayers() { return this.getLayers(l => l.isElevationLayer); } /** * Disposes this map and associated unmanaged resources. * * Note: By default, layers in this map are not automatically disposed, except when * `disposeLayers` is `true`. * * @param options - Options. * @param options -.disposeLayers If true, layers are also disposed. */ dispose(options = { disposeLayers: false }) { // Delete cached TileGeometry objects. This is not possible to do // at the TileMesh level because TileMesh objects do not own their geometry, // as it is shared among all tiles at the same depth level. this.clearGeometryPool(); // Dispose all tiles so that every layer will unload data relevant to those tiles. this.traverseTiles(t => this.disposeTile(t)); if (options.disposeLayers === true) { this.getLayers().forEach(layer => layer.dispose()); } this._materialOptions.colorMapAtlas?.dispose(); } disposeTile(tile) { tile.traverseTiles(desc => { desc.dispose(); this.allTiles.delete(desc); }); } /** * Returns the minimal and maximal elevation values in this map, in meters. * * If there is no elevation layer present, returns `{ min: 0, max: 0 }`. * * @returns The min/max value. */ getElevationMinMax() { const elevationLayers = this.getElevationLayers(); if (elevationLayers.length > 0) { let min = null; let max = null; for (const layer of elevationLayers) { const minmax = layer.minmax; if (minmax != null) { if (min == null || max == null) { min = min ?? minmax.min; max = max ?? minmax.max; } else { min = Math.min(min, minmax.min); max = Math.max(max, minmax.max); } } } if (min != null && max != null) { return { min, max }; } } return { min: 0, max: 0 }; } /** * Sample the elevation at the specified coordinate. * * Note: this method does nothing if {@link TerrainOptions.enableCPUTerrain} is not enabled, * or if no elevation layer is present on the map, or if the sampling coordinate is not inside * the map's extent. * * Note: sampling might return more than one sample for any given coordinate. You can sort them * by {@link entities.ElevationSample.resolution | resolution} to select the best sample for your needs. * @param options - The options. * @param result - The result object to populate with the samples. If none is provided, a new * empty result is created. The existing samples in the array are not removed. Useful to * cumulate samples across different maps. * @returns The {@link GetElevationResult} containing the updated sample array. * If the map has no elevation layer or if {@link TerrainOptions.enableCPUTerrain} is not enabled, * this array is left untouched. */ getElevation(options, result = { samples: [], coordinates: options.coordinates }) { result.coordinates = options.coordinates; const coordinates = options.coordinates.as(this.extent.crs); if (!this.extent.isPointInside(coordinates)) { return result; } if (!this._hasElevationLayer) { return result; } const elevationLayer = this.getElevationLayers()[0]; if (!elevationLayer.visible) { return result; } if (!this._materialOptions.terrain.enableCPUTerrain) { console.warn('Map.getElevation() is only supported when TerrainOptions.enableCPUTerrain is enabled'); return result; } this.traverseTiles(tile => { if (tile.extent.isPointInside(coordinates)) { const sample = tile.getElevation(options); if (sample) { result.samples.push({ ...sample, source: this }); } } }); return result; } /** * Traverses all tiles in the hierarchy of this entity. * * @param callback - The callback. * @param root - The raversal root. If undefined, the traversal starts at the root * object of this entity. */ traverseTiles(callback, root = undefined) { const origin = root ?? this.object3d; if (origin != null) { origin.traverse(o => { if (isTileMesh(o)) { callback(o); } }); } } /** * @param node - The node to subdivide. * @returns True if the node can be subdivided. */ canSubdivide(node) { // 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 (!this._materialOptions.terrain.enabled) { return true; } // Prevent subdivision if node is covered by at least one elevation layer // and if node doesn't have a elevation texture yet. for (const e of this.getElevationLayers()) { // If the elevation layer is not ready, we are still waiting for // some information related to the terrain (min/max values). if (!e.ready && e.visible && !e.frozen) { return false; } if (!node.canSubdivide()) { return false; } } if (node.children.some(n => isTileMesh(n))) { // No need to prevent subdivision, since we've already done it before return true; } return true; } testTileSSE(tile, sse) { if (this.maxSubdivisionLevel <= tile.level) { return false; } if (!sse) { return true; } tmpSseSizes[0] = sse.lengths.x * sse.ratio; tmpSseSizes[1] = sse.lengths.y * sse.ratio; const threshold = Math.max(this.imageSize.x, this.imageSize.y); return tmpSseSizes.some(v => v >= threshold * this.subdivisionThreshold); } updateMinMaxDistance(context, node) { const bbox = node.getWorldSpaceBoundingBox(tmpBox3); const distance = context.distance.plane.distanceToPoint(bbox.getCenter(tmpVector)); const radius = bbox.getSize(tmpVector).length() * 0.5; this._distance.min = Math.min(this._distance.min, distance - radius); this._distance.max = Math.max(this._distance.max, distance + radius); } } export function isMap(o) { return o?.isMap; } export default Map;