UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

985 lines (803 loc) 32 kB
/* * Copyright (c) 2015-2018, IGN France. * Copyright (c) 2018-2026, Giro3D team. * SPDX-License-Identifier: MIT */ import { Group, MathUtils, Matrix4, Mesh, MeshBasicMaterial, Ray, RGBAFormat, Sphere, SphereGeometry, UnsignedByteType, Vector2, Vector3, type Box3, type BufferGeometry, type ColorRepresentation, type Intersection, type Object3D, type Object3DEventMap, type Raycaster, type Texture, type WebGLRenderer, type WebGLRenderTarget, } from 'three'; import { type OBB } from 'three/examples/jsm/Addons.js'; import type Disposable from '../../core/Disposable'; import type Ellipsoid from '../../core/geographic/Ellipsoid'; import type Extent from '../../core/geographic/Extent'; import type GetElevationOptions from '../../core/GetElevationOptions'; import type ElevationLayer from '../../core/layer/ElevationLayer'; import type Layer from '../../core/layer/Layer'; import type MemoryUsage from '../../core/MemoryUsage'; import type { GetMemoryUsageContext } from '../../core/MemoryUsage'; import type UniqueOwner from '../../core/UniqueOwner'; import type LayeredMaterial from '../../renderer/LayeredMaterial'; import type { MaterialOptions } from '../../renderer/LayeredMaterial'; import type RenderingState from '../../renderer/RenderingState'; import type ShadowLayeredMaterial from '../../renderer/ShadowLayeredMaterial'; import type View from '../../renderer/View'; import type TileCoordinate from './TileCoordinate'; import type TileGeometry from './TileGeometry'; import type { TileGeometryBuilder } from './TileGeometry'; import type { NeighbourList } from './TileIndex'; import type TileVolume from './TileVolume'; import HeightMap from '../../core/HeightMap'; import { isElevationLayer } from '../../core/layer/ElevationLayer'; import OffsetScale from '../../core/OffsetScale'; import Rect from '../../core/Rect'; import { intoUniqueOwner } from '../../core/UniqueOwner'; import OBBHelper from '../../helpers/OBBHelper'; import { readRGRenderTargetIntoRGBAU8Buffer } from '../../renderer/composition/WebGLComposer'; import MaterialUtils from '../../renderer/MaterialUtils'; import MemoryTracker from '../../renderer/MemoryTracker'; import { isPerspectiveCamera } from '../../utils/predicates'; import { nonNull } from '../../utils/tsutils'; const ray = new Ray(); const inverseMatrix = new Matrix4(); const THIS_RECT = new Rect(0, 1, 0, 1); const tmpSphere = new Sphere(); const sphereGeometry = new SphereGeometry(1, 32, 16); const helperMaterial = new MeshBasicMaterial({ color: '#75eba8', depthTest: false, depthWrite: false, wireframe: true, transparent: true, }); const noRaycast = (): void => {}; const NO_NEIGHBOUR = -99; const NO_OFFSET_SCALE = new OffsetScale(0, 0, 0, 0); const tempVec2 = new Vector2(); const tempVec3 = new Vector3(); const tempAbsolutePosition = new Vector3(); export interface TileMeshEventMap extends Object3DEventMap { 'visibility-changed': unknown; dispose: unknown; } class TileMesh extends Mesh<TileGeometry, LayeredMaterial, TileMeshEventMap> implements Disposable, MemoryUsage { public readonly isTileMesh = true as const; public override readonly type = 'TileMesh' as const; public readonly isMemoryUsage = true as const; public readonly extent: Extent; public readonly textureSize: Vector2; private _verticalScaling = 1; public override customDepthMaterial: ShadowLayeredMaterial; public override customDistanceMaterial: ShadowLayeredMaterial; public readonly coordinate: TileCoordinate; private readonly _extentDimensions: Vector2; private readonly _geometryBuilder: TileGeometryBuilder<TileGeometry>; private readonly _volume: TileVolume; private readonly _renderer: WebGLRenderer; private readonly _onElevationChanged: (tile: this) => void; private _heightMap: UniqueOwner<HeightMap, this> | null = null; private _enableTerrainDeformation: boolean; private _tileGeometry: TileGeometry; private _segments: number; private _skirtDepth: number | undefined; private _minmax: { min: number; max: number } = { min: -Infinity, max: +Infinity }; private _shouldUpdateHeightMap = false; private _childTiles: [TileMesh | null, TileMesh | null, TileMesh | null, TileMesh | null] = [ null, null, null, null, ]; private readonly _helpers: { root: Group | null; boundingSphere?: Mesh<SphereGeometry, MeshBasicMaterial>; color: ColorRepresentation; colliderMesh?: Mesh<BufferGeometry, MeshBasicMaterial, Object3DEventMap>; boundingBox?: OBBHelper; } = { root: null, color: 'cyan', }; private _elevationLayerInfo: { layer: ElevationLayer; offsetScale: OffsetScale; renderTarget: WebGLRenderTarget<Texture>; } | null = null; public disposed = false; public isLeaf = true; public getMemoryUsage(context: GetMemoryUsageContext): void { this.material?.getMemoryUsage(context); // We only count what we own, otherwise the same heightmap will be counted more than once. if (this._heightMap && this._heightMap.owner === this) { context.objects.set(`heightmap-${this._heightMap.owner.id}`, { cpuMemory: this._heightMap.payload.buffer.byteLength, gpuMemory: 0, }); } this.geometry.getMemoryUsage(context); } public get boundingBox(): Box3 { if (!this._enableTerrainDeformation || this._elevationLayerInfo?.layer.visible !== true) { this._volume.setElevationRange({ min: 0, max: 0 }); } else { this._volume.setElevationRange(this.minmax); } return this._volume.localBox; } /** * The LOD. Root nodes have LOD 0. */ public get lod(): number { return this.coordinate.z; } public getOBB(): OBB { return this._volume.getOBB(this.matrixWorld); } public getWorldSpaceBoundingBox(target: Box3): Box3 { const local = this._volume.getLocalBoundingBox(target); this.updateMatrixWorld(true); local.applyMatrix4(this.matrixWorld); return local; } public getWorldSpaceBoundingSphere(target: Sphere): Sphere { this.updateWorldMatrix(true, false); return this._volume.getWorldSpaceBoundingSphere(target, this.matrixWorld); } public getBoundingBoxCorners(): Vector3[] { this.updateWorldMatrix(true, false); return this._volume.getWorldSpaceCorners(this.matrixWorld); } /** * Creates an instance of TileMesh. */ public constructor(params: { geometryBuilder: TileGeometryBuilder<TileGeometry>; volume: TileVolume; /** The tile material. */ material: LayeredMaterial; depthMaterial: ShadowLayeredMaterial; distanceMaterial: ShadowLayeredMaterial; /** The tile extent. */ extent: Extent; /** The subdivisions. */ segments: number; skirtDepth?: number; /** The tile coordinate. */ coord: TileCoordinate; /** The texture size. */ textureSize: Vector2; ellipsoid?: Ellipsoid; renderer: WebGLRenderer; enableTerrainDeformation: boolean; onElevationChanged: (tile: TileMesh) => void; }) { super( params.geometryBuilder.build({ extent: params.extent, tile: params.coord }), params.material, ); this._geometryBuilder = params.geometryBuilder; this._tileGeometry = this.geometry; this._segments = params.segments; this._skirtDepth = params.skirtDepth; this._renderer = params.renderer; this._onElevationChanged = params.onElevationChanged; this.matrixAutoUpdate = false; this.coordinate = params.coord; this.extent = params.extent; this.textureSize = params.textureSize; this._enableTerrainDeformation = params.enableTerrainDeformation; this.customDepthMaterial = params.depthMaterial; this.customDistanceMaterial = params.distanceMaterial; if (!this.geometry.boundingBox) { this.geometry.computeBoundingBox(); } this._volume = params.volume; const { z, x, y } = this.coordinate; this.name = `tile @ (z=${z}, x=${x}, y=${y})`; this.frustumCulled = false; // Layer this.setDisplayed(false); this.material.setUuid(this.id); const dim = params.extent.dimensions(); this._extentDimensions = dim; // Sets the default bbox volume this.setBBoxZ(-0.5, +0.5); MemoryTracker.track(this, this.name); this.updateSkirtParameters(); } public override onBeforeShadow(): void { this.customDepthMaterial.onBeforeRender(); this.customDistanceMaterial.onBeforeRender(); } private updateSkirtParameters(): void { const skirtDepth = this._skirtDepth; if (skirtDepth != null) { this.forEachMaterial(material => { MaterialUtils.setDefine(material, 'ENABLE_SKIRTS', true); const vertexCount = this.geometry.vertexCount; const rowSize = this.segments + 1; const firstSkirtVertex = rowSize * rowSize; const lastSkirtVertex = vertexCount - 1; material.uniforms.skirtVertexRange.value = new Vector2( firstSkirtVertex, lastSkirtVertex, ); material.uniforms.skirtElevation.value = skirtDepth; }); } else { this.forEachMaterial(material => { MaterialUtils.setDefine(material, 'ENABLE_SKIRTS', false); }); } } public setVerticalScaling(scaling: number): void { this._verticalScaling = scaling; this.material.setElevationScaling(scaling); } public get absolutePosition(): Vector3 { return this.geometry.origin; } public get showColliderMesh(): boolean { if (!this._helpers.colliderMesh) { return false; } return this._helpers.colliderMesh.material.visible; } public set showColliderMesh(visible: boolean) { if (visible && !this._helpers.colliderMesh) { this._helpers.colliderMesh = new Mesh(this.geometry.raycastGeometry, helperMaterial); this._helpers.colliderMesh.matrixAutoUpdate = false; this._helpers.colliderMesh.name = 'collider helper'; this.createHelperRootIfNecessary(); this._helpers.root?.add(this._helpers.colliderMesh); this._helpers.colliderMesh.updateMatrix(); this._helpers.colliderMesh.updateMatrixWorld(true); } if (!visible && this._helpers.colliderMesh) { this._helpers.colliderMesh.removeFromParent(); this._helpers.colliderMesh = undefined; } if (this._helpers.colliderMesh) { this._helpers.colliderMesh.material.visible = visible; } } private deleteBoundingBoxHelper(): void { if (this._helpers.boundingBox != null) { this._helpers.boundingBox.dispose(); this._helpers.boundingBox.removeFromParent(); this._helpers.boundingBox = undefined; } } private deleteBoundingSphereHelper(): void { if (this._helpers.boundingSphere != null) { this._helpers.boundingSphere.removeFromParent(); this._helpers.boundingSphere = undefined; } } private recreateBoundingBoxHelper(): void { this.deleteBoundingBoxHelper(); const obb = this._volume.getOBB(this.matrixWorld); const helper = new OBBHelper(obb, this.helperColor); helper.raycast = noRaycast; this.createHelperRootIfNecessary(); nonNull(this._helpers.root).attach(helper); helper.updateMatrixWorld(true); this._helpers.boundingBox = helper; } private recreateBoundingSphereHelper(): void { this.deleteBoundingSphereHelper(); this._helpers.boundingSphere = new Mesh( sphereGeometry, new MeshBasicMaterial({ color: this.helperColor, wireframe: true }), ); this._helpers.boundingSphere.rotateX(MathUtils.degToRad(90)); this._helpers.boundingSphere.raycast = noRaycast; const sphere = this._volume.getWorldSpaceBoundingSphere(tmpSphere, this.matrixWorld); this._helpers.boundingSphere.scale.set(sphere.radius, sphere.radius, sphere.radius); this._helpers.boundingSphere.position.copy(sphere.center); this.createHelperRootIfNecessary(); nonNull(this._helpers.root).attach(this._helpers.boundingSphere); this._helpers.boundingSphere.updateMatrixWorld(true); } public get showBoundingBox(): boolean { return this._helpers.boundingBox?.visible ?? false; } public set showBoundingBox(show: boolean) { if (show && this._helpers.boundingBox == null) { this.recreateBoundingBoxHelper(); } else if (!show && this._helpers.boundingBox != null) { this.deleteBoundingBoxHelper(); } } public get showBoundingSphere(): boolean { return this._helpers.boundingSphere?.visible ?? false; } public set showBoundingSphere(show: boolean) { if (show && this._helpers.boundingSphere == null) { this.recreateBoundingSphereHelper(); } else if (!show && this._helpers.boundingSphere != null) { this.deleteBoundingSphereHelper(); } } public get helperColor(): ColorRepresentation { return this._helpers.color; } public set helperColor(color: ColorRepresentation) { this._helpers.color = color; if (this.showBoundingBox) { this.recreateBoundingBoxHelper(); } if (this.showBoundingSphere) { this.recreateBoundingSphereHelper(); } } public get segments(): number { return this._segments; } public set segments(v: number) { if (this._segments !== v) { this._segments = v; this.forEachMaterial(material => (material.segments = v)); this.createGeometry(); this._shouldUpdateHeightMap = true; } } private createHelperRootIfNecessary(): void { if (!this._helpers.root) { this._helpers.root = new Group(); this._helpers.root.name = 'helpers'; this.add(this._helpers.root); this._helpers.root.updateMatrixWorld(true); } } private createGeometry(): void { this.geometry.dispose(); this.geometry = this._geometryBuilder.build({ extent: this.extent, tile: this.coordinate }); this._tileGeometry = this.geometry; if (this._helpers.colliderMesh) { this._helpers.colliderMesh.geometry = this.geometry.raycastGeometry; } this.updateSkirtParameters(); } public onLayerVisibilityChanged(layer: Layer): void { if (isElevationLayer(layer)) { this._shouldUpdateHeightMap = true; } } public addChildTile(tile: TileMesh): void { // The absolute position here means "absolute position in the cartographic coordinate system", not in the scene. const absolutePosition = tempAbsolutePosition.copy(tile.absolutePosition); tile.position.copy(absolutePosition.sub(this.absolutePosition)); this.add(tile); tile.updateMatrix(); tile.updateMatrixWorld(); const center = tile.extent.centerAsVector2(tempVec2); const quadrant = this.extent.getQuadrant(center.x, center.y); this._childTiles[quadrant] = tile; this.isLeaf = false; if (this._heightMap) { const heightMap = this._heightMap.payload; const inheritedHeightMap = heightMap.clone(); const offsetScale = tile.extent.offsetToParent(this.extent); heightMap.offsetScale.combine(offsetScale, inheritedHeightMap.offsetScale); tile.inheritHeightMap(intoUniqueOwner(inheritedHeightMap, this)); } } public reorderLayers(): void { this.material.reorderLayers(); } /** * Checks that the given raycaster intersects with this tile's volume. */ private checkRayVolumeIntersection(raycaster: Raycaster): boolean { const matrixWorld = this.matrixWorld; // convert ray to local space of mesh inverseMatrix.copy(matrixWorld).invert(); ray.copy(raycaster.ray).applyMatrix4(inverseMatrix); // test with bounding box in local space // Note that we are not using the bounding box of the geometry, because at this moment, // the mesh might still be completely flat, as the heightmap might not be computed yet. // This is the whole point of this method: to avoid computing the heightmap if not necessary. // So we are using the logical bounding box provided by the volume. return ray.intersectsBox(this.boundingBox); } public override raycast(raycaster: Raycaster, intersects: Intersection[]): void { if (!this.material.visible) { return; } // Updating the heightmap is quite costly operation that requires a texture readback. // Let's do it only if the ray intersects the volume of this tile. if (this.checkRayVolumeIntersection(raycaster)) { this.updateHeightMapIfNecessary(); // We have to distinguish between the rendered geometry and the raycasting geometry. // However, three.js does not let use choose which will be used for raycasting, // so we temporarily swap the geometry with the raycast geometry to perform raycasting. // @ts-expect-error type mismatch is expected and transient this.geometry = this._tileGeometry.raycastGeometry; super.raycast(raycaster, intersects); this.geometry = this._tileGeometry; } } private updateHeightMapIfNecessary(): void { if (this._shouldUpdateHeightMap) { this._shouldUpdateHeightMap = false; if (this._elevationLayerInfo) { this.createHeightMap( this._elevationLayerInfo.renderTarget, this._elevationLayerInfo.offsetScale, ); const shouldHeightmapBeActive = this._elevationLayerInfo.layer.visible && this._enableTerrainDeformation; if (shouldHeightmapBeActive) { this.applyHeightMap(); } else { this.resetHeights(); } } } } /** * @param neighbour - The neighbour. * @param location - Its location in the neighbour array. */ private processNeighbour(neighbour: TileMesh, location: number): void { const diff = neighbour.lod - this.lod; const neighbourTexture = neighbour.material.getElevationTexture(); const neighbourOffsetScale = neighbour.material.getElevationOffsetScale(); const offsetScale = this.extent.offsetToParent(neighbour.extent); const nOffsetScale = neighbourOffsetScale.combine(offsetScale); this.forEachMaterial(material => { material.updateNeighbour(location, diff, nOffsetScale, neighbourTexture); }); } /** * @param neighbours - The neighbours. */ public processNeighbours(neighbours: NeighbourList<TileMesh>): void { for (let i = 0; i < neighbours.length; i++) { const neighbour = neighbours[i]; if (neighbour != null && neighbour.material != null && neighbour.material.visible) { this.processNeighbour(neighbour, i); } else { this.forEachMaterial(material => material.updateNeighbour(i, NO_NEIGHBOUR, NO_OFFSET_SCALE, null), ); } } } public update(materialOptions: MaterialOptions): void { if (this._heightMap && this._elevationLayerInfo) { if (this._enableTerrainDeformation !== materialOptions.terrain.enabled) { this._enableTerrainDeformation = materialOptions.terrain.enabled; this._shouldUpdateHeightMap = true; } } this.helperColor = materialOptions.helperColor ?? 'cyan'; this.showColliderMesh = materialOptions.showColliderMeshes ?? false; this.showBoundingBox = materialOptions.showBoundingBoxes ?? false; this.showBoundingSphere = materialOptions.showBoundingSpheres ?? false; } public isVisible(): boolean { return this.visible; } public setDisplayed(show: boolean): void { const currentVisibility = this.material.visible; this.material.visible = show && this.material.update(); if (this._helpers.root) { if (this._helpers.boundingBox) { this._helpers.boundingBox.color = show ? this.helperColor : 'gray'; } } if (currentVisibility !== show) { this.dispatchEvent({ type: 'visibility-changed' }); } } /** * @param v - The new opacity. */ public set opacity(v: number) { this.material.opacity = v; } public setVisibility(show: boolean): void { const currentVisibility = this.visible; this.visible = show; if (currentVisibility !== show) { this.dispatchEvent({ type: 'visibility-changed' }); } } public isDisplayed(): boolean { return this.material.visible; } /** * Updates the rendering state of the tile's material. * * @param state - The new rendering state. */ public changeState(state: RenderingState): void { this.material.changeState(state); } public static applyChangeState(o: Object3D, s: RenderingState): void { if ((o as TileMesh).isTileMesh) { (o as TileMesh).changeState(s); } } public pushRenderState(state: RenderingState): () => void { if (this.material.uniforms.renderingState.value === state) { return (): void => { /** do nothing */ }; } const oldState = this.material.uniforms.renderingState.value; this.traverse(n => TileMesh.applyChangeState(n, state)); return (): void => { this.traverse(n => TileMesh.applyChangeState(n, oldState)); }; } public canProcessColorLayer(): boolean { if (!this._elevationLayerInfo) { // No elevation layer that prevents loading color data return true; } return this._elevationLayerInfo.layer.isLoaded(this.id); } public removeElevationTexture(): void { this._elevationLayerInfo = null; this._shouldUpdateHeightMap = true; this.material.removeElevationLayer(); } public setElevationTexture( layer: ElevationLayer, elevation: { texture: Texture; pitch: OffsetScale; min?: number; max?: number; renderTarget: WebGLRenderTarget; }, ): void { if (this.disposed) { return; } this._elevationLayerInfo = { layer, offsetScale: elevation.pitch, renderTarget: elevation.renderTarget, }; this.material.setElevationTexture(layer, elevation); this.setBBoxZ(elevation.min, elevation.max); this._shouldUpdateHeightMap = true; this._onElevationChanged(this); } public getScreenPixelSize(view: View, target?: Vector2): Vector2 { target = target ?? new Vector2(); const sphere = this.getWorldSpaceBoundingSphere(tmpSphere); const distance = sphere.center.distanceTo(view.camera.getWorldPosition(tempVec3)); let height: number; let width: number; const camera = view.camera; if (isPerspectiveCamera(camera)) { const fovRads = MathUtils.degToRad(camera.fov); height = 2 * Math.tan(fovRads / 2) * distance; width = height * camera.aspect; } else { height = Math.abs(camera.top - camera.bottom); width = Math.abs(camera.right - camera.left); } const diameter = sphere.radius * 2; const wRatio = diameter / width; const hRatio = diameter / height; target.setX(Math.ceil(wRatio * view.width)); target.setY(Math.ceil(hRatio * view.height)); return target; } private createHeightMap(renderTarget: WebGLRenderTarget, offsetScale: OffsetScale): void { const outputHeight = Math.floor(renderTarget.height); const outputWidth = Math.floor(renderTarget.width); // One millimeter const precision = 0.001; // To ensure that all values are positive before encoding const offset = -this._minmax.min; const buffer = readRGRenderTargetIntoRGBAU8Buffer({ renderTarget, renderer: this._renderer, outputWidth, outputHeight, precision, offset, }); const heightMap = new HeightMap( buffer, outputWidth, outputHeight, offsetScale, RGBAFormat, UnsignedByteType, precision, offset, this._verticalScaling, ); this._heightMap = intoUniqueOwner(heightMap, this); } private inheritHeightMap(heightMap: UniqueOwner<HeightMap, this>): void { this._heightMap = heightMap; this._shouldUpdateHeightMap = true; // Let's get a more precise minmax from the inherited heightmap, but // only on the region of the inherited heightmap that matches this tile's extent // (otherwise this would not provide any benefit at all); const minmax = heightMap.payload.getMinMax(THIS_RECT); if (minmax != null) { this._minmax = minmax; } } private resetHeights(): void { this.geometry.resetHeights(); this.setBBoxZ(0, 0); this._onElevationChanged(this); } /** @internal */ public applyHeightMap(): void { if (!this._heightMap) { return; } const { min, max } = this.geometry.applyHeightMap(this._heightMap.payload); if (min > this._minmax.min && max < this._minmax.max) { this.setBBoxZ(min, max); } if (this._helpers.colliderMesh) { this._helpers.colliderMesh.geometry = this.geometry.raycastGeometry; } this._onElevationChanged(this); } public setBBoxZ(min: number | undefined, max: number | undefined): void { // 0 is an acceptable value if (min == null || max == null) { return; } this._minmax = { min, max }; if (this._skirtDepth != null) { this._minmax.min = Math.min(this._skirtDepth, this._minmax.min); } this.updateVolume(min, max); } public traverseTiles(callback: (descendant: TileMesh) => void): void { this.traverse(obj => { if (isTileMesh(obj)) { callback(obj); } }); } /** * Removes the child tiles and returns the detached tiles. */ public detachChildren(): TileMesh[] { const childTiles = this.children.filter(c => isTileMesh(c)) as TileMesh[]; childTiles.forEach(c => c.dispose()); this.remove(...childTiles); this.isLeaf = true; return childTiles; } private updateVolume(min: number, max: number): void { this._volume.setElevationRange({ min, max }); if (this.showBoundingBox) { this.recreateBoundingBoxHelper(); } if (this.showBoundingSphere) { this.recreateBoundingSphereHelper(); } } public get minmax(): { min: number; max: number } { const range = Math.abs(this._minmax.max - this._minmax.min); const width = this._extentDimensions.width; const height = this._extentDimensions.height; const RATIO = 3; // If the current volume is very elongated in the vertical axis, // this can cause excessive subdivisions of the tile. Let's compute // the heightmap to get a more precise min/max and hopefully a tighter // volume. Note that the heightmap will be computed only if it does not // exist, avoiding unnecessary computations. if (range / Math.max(width, height) > RATIO) { this.updateHeightMapIfNecessary(); } return this._minmax; } public getExtent(): Extent { return this.extent; } public getElevation( params: GetElevationOptions, ): { elevation: number; resolution: number } | null { this.updateHeightMapIfNecessary(); if (this._heightMap) { const uv = this.extent.offsetInExtent(params.coordinates, tempVec2); const heightMap = this._heightMap.payload; const elevation = heightMap.getValue(uv.x, uv.y); if (elevation != null) { const dims = this.extent.dimensions(tempVec2); const xRes = dims.x / heightMap.width; const yRes = dims.y / heightMap.height; const resolution = Math.min(xRes, yRes); return { elevation, resolution }; } } return null; } /** * Search for a common ancestor between this tile and another one. It goes * through parents on each side until one is found. * * @param tile - the tile to evaluate * @returns the resulting common ancestor */ public findCommonAncestor(tile: TileMesh): TileMesh | null { if (tile == null) { return null; } if (tile.lod === this.lod) { if (tile.id === this.id) { return tile; } if (tile.lod !== 0) { return (this.parent as TileMesh).findCommonAncestor(tile.parent as TileMesh); } return null; } if (tile.lod < this.lod) { return (this.parent as TileMesh).findCommonAncestor(tile); } return this.findCommonAncestor(tile.parent as TileMesh); } public isAncestorOf(tile: TileMesh): boolean { return tile.findCommonAncestor(this) === this; } private forEachMaterial(callbackFn: (material: LayeredMaterial) => void): void { callbackFn(this.material); callbackFn(this.customDepthMaterial); callbackFn(this.customDistanceMaterial); } public getLeafThatContains(x: number, y: number): TileMesh | undefined { if (!this.extent.isXYInside(x, y)) { throw new Error('this tile does not contain the coordinates'); } if (this.isLeaf) { return this; } const quadrant = this.extent.getQuadrant(x, y); return this._childTiles[quadrant]?.getLeafThatContains(x, y); } public dispose(): void { if (this.disposed) { return; } this.disposed = true; this.dispatchEvent({ type: 'dispose' }); this.forEachMaterial(m => m.dispose()); this.geometry.dispose(); } } export function isTileMesh(o: unknown): o is TileMesh { return (o as TileMesh).isTileMesh; } export default TileMesh;