import type { Side } from 'three';
import {
    Box3,
    FrontSide,
    Matrix4,
    Mesh,
    MeshBasicMaterial,
    Ray,
    RGBAFormat,
    UnsignedByteType,
    Vector2,
    Vector3,
    type Intersection,
    type Object3D,
    type Object3DEventMap,
    type Raycaster,
    type Texture,
    type WebGLRenderTarget,
} from 'three';

import { readRGRenderTargetIntoRGBAU8Buffer } from '../renderer/composition/WebGLComposer';
import type LayeredMaterial from '../renderer/LayeredMaterial';
import type { MaterialOptions } from '../renderer/LayeredMaterial';
import MaterialUtils from '../renderer/MaterialUtils';
import MemoryTracker from '../renderer/MemoryTracker';
import type RenderingState from '../renderer/RenderingState';
import type Disposable from './Disposable';
import type Extent from './geographic/Extent';
import type GetElevationOptions from './GetElevationOptions';
import HeightMap from './HeightMap';
import type Instance from './Instance';
import ElevationLayer from './layer/ElevationLayer';
import type Layer from './layer/Layer';
import type MemoryUsage from './MemoryUsage';
import { type GetMemoryUsageContext } from './MemoryUsage';
import OffsetScale from './OffsetScale';
import Rect from './Rect';
import TileGeometry from './TileGeometry';
import { type NeighbourList } from './TileIndex';
import type UniqueOwner from './UniqueOwner';
import { intoUniqueOwner } from './UniqueOwner';

const ray = new Ray();
const inverseMatrix = new Matrix4();
const THIS_RECT = new Rect(0, 1, 0, 1);

const helperMaterial = new MeshBasicMaterial({
    color: '#75eba8',
    depthTest: false,
    depthWrite: false,
    wireframe: true,
    transparent: true,
});

const NO_NEIGHBOUR = -99;
const NO_OFFSET_SCALE = new OffsetScale(0, 0, 0, 0);
const tempVec2 = new Vector2();
const tempVec3 = new Vector3();

type GeometryPool = Map<string, TileGeometry>;

function makePooledGeometry(pool: GeometryPool, extent: Extent, segments: number, level: number) {
    const key = `${segments}-${level}`;

    const cached = pool.get(key);
    if (cached) {
        return cached;
    }

    const dimensions = extent.dimensions();
    const geometry = new TileGeometry({ dimensions, segments });
    pool.set(key, geometry);
    return geometry;
}

function makeRaycastableGeometry(extent: Extent, segments: number) {
    const dimensions = extent.dimensions();
    const geometry = new TileGeometry({ dimensions, segments });
    return geometry;
}

export interface TileMeshEventMap extends Object3DEventMap {
    'visibility-changed': unknown;
    dispose: unknown;
}

class TileVolume {
    private readonly _localBox: Box3;
    private readonly _owner: Object3D<Object3DEventMap>;

    constructor(options: { extent: Extent; min: number; max: number; owner: Object3D }) {
        const dims = options.extent.dimensions(tempVec2);
        const width = dims.x;
        const height = dims.y;
        const min = new Vector3(-width / 2, -height / 2, options.min);
        const max = new Vector3(+width / 2, +height / 2, options.max);
        this._localBox = new Box3(min, max);
        this._owner = options.owner;
    }

    get centerZ() {
        return this.localBox.getCenter(tempVec3).z;
    }

    get localBox(): Readonly<Box3> {
        return this._localBox;
    }

    /**
     * Gets or set the min altitude, in local coordinates.
     */
    get zMin() {
        return this._localBox.min.z;
    }

    set zMin(v: number) {
        this._localBox.min.setZ(v);
    }

    /**
     * Gets or set the max altitude, in local coordinates.
     */
    get zMax() {
        return this._localBox.max.z;
    }

    set zMax(v: number) {
        this._localBox.max.setZ(v);
    }

    /**
     * Returns the local size of this volume.
     */
    getLocalSize(target: Vector3): Vector3 {
        return this._localBox.getSize(target);
    }

    /**
     * Returns the local bounding box.
     */
    getLocalBoundingBox(target?: Box3): Box3 {
        const result = target ?? new Box3();

        result.copy(this._localBox);

        return result;
    }

    /**
     * Gets the world bounding box, taking into account world transformation.
     */
    getWorldSpaceBoundingBox(target?: Box3): Box3 {
        const result = target ?? new Box3();

        result.copy(this._localBox);

        this._owner.updateWorldMatrix(true, false);

        result.applyMatrix4(this._owner.matrixWorld);

        return result;
    }
}

class TileMesh
    extends Mesh<TileGeometry, LayeredMaterial, TileMeshEventMap>
    implements Disposable, MemoryUsage
{
    readonly isMemoryUsage = true as const;
    private readonly _pool: GeometryPool;
    private readonly _extentDimensions: Vector2;
    private _segments: number;
    readonly type: string = 'TileMesh';
    readonly isTileMesh: boolean = true;
    private _minmax: { min: number; max: number } = { min: -Infinity, max: +Infinity };
    readonly extent: Extent;
    readonly textureSize: Vector2;
    private readonly _volume: TileVolume;
    private _materialSide: Side = FrontSide;
    readonly level: number;
    readonly x: number;
    readonly y: number;
    readonly z: number;
    private _heightMap: UniqueOwner<HeightMap, this> | null = null;
    disposed = false;
    private _enableTerrainDeformation: boolean;
    private readonly _enableCPUTerrain: boolean;
    private readonly _instance: Instance;
    private readonly _onElevationChanged: (tile: this) => void;
    private _shouldUpdateHeightMap = false;
    isLeaf = false;
    private _elevationLayerInfo: {
        layer: ElevationLayer;
        offsetScale: OffsetScale;
        renderTarget: WebGLRenderTarget<Texture>;
    } | null = null;
    private _helperMesh: Mesh<TileGeometry, MeshBasicMaterial, Object3DEventMap> | null = null;

    getMemoryUsage(context: GetMemoryUsageContext) {
        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,
            });
        }
        // If CPU terrain is enabled, then the geometry is owned by this mesh, rather than
        // shared with other meshes in the same map, so we have to count it.
        if (this._enableCPUTerrain) {
            this.geometry.getMemoryUsage(context);
        }
    }

    get boundingBox(): Box3 {
        if (!this._enableTerrainDeformation) {
            this._volume.zMin = 0;
            this._volume.zMax = 0;
        } else {
            this._volume.zMin = this.minmax.min;
            this._volume.zMax = this.minmax.max;
        }
        return this._volume.localBox;
    }

    getWorldSpaceBoundingBox(target: Box3): Box3 {
        return this._volume.getWorldSpaceBoundingBox(target);
    }

    /**
     * Creates an instance of TileMesh.
     *
     * @param options - Constructor options.
     */
    constructor({
        geometryPool,
        material,
        extent,
        segments,
        coord: { level, x = 0, y = 0 },
        textureSize,
        instance,
        enableCPUTerrain,
        enableTerrainDeformation,
        onElevationChanged,
    }: {
        /** The geometry pool to use. */
        geometryPool: GeometryPool;
        /** The tile material. */
        material: LayeredMaterial;
        /** The tile extent. */
        extent: Extent;
        /** The subdivisions. */
        segments: number;
        /** The tile coordinate. */
        coord: { level: number; x: number; y: number };
        /** The texture size. */
        textureSize: Vector2;
        instance: Instance;
        enableCPUTerrain: boolean;
        enableTerrainDeformation: boolean;
        onElevationChanged: (tile: TileMesh) => void;
    }) {
        super(
            // CPU terrain forces geometries to be unique, so cannot be pooled
            enableCPUTerrain
                ? makeRaycastableGeometry(extent, segments)
                : makePooledGeometry(geometryPool, extent, segments, level),
            material,
        );

        this._pool = geometryPool;
        this._segments = segments;
        this._instance = instance;
        this._onElevationChanged = onElevationChanged;

        this.matrixAutoUpdate = false;

        this.level = level;
        this.extent = extent;
        this.textureSize = textureSize;
        this._enableCPUTerrain = enableCPUTerrain;
        this._enableTerrainDeformation = enableTerrainDeformation;

        if (!this.geometry.boundingBox) {
            this.geometry.computeBoundingBox();
        }
        const boundingBox = this.geometry.boundingBox as Box3;

        this._volume = new TileVolume({
            extent,
            owner: this,
            min: boundingBox.min.z,
            max: boundingBox.max.z,
        });

        this.name = `tile @ (z=${level}, x=${x}, y=${y})`;

        this.frustumCulled = false;

        // Layer
        this.setDisplayed(false);

        this.material.setUuid(this.id);
        const dim = extent.dimensions();
        this._extentDimensions = dim;
        this.material.uniforms.tileDimensions.value.set(dim.x, dim.y);

        // Sets the default bbox volume
        this.setBBoxZ(-0.5, +0.5);

        this.x = x;
        this.y = y;
        this.z = level;

        MemoryTracker.track(this, this.name);
    }

    get showHelpers() {
        if (!this._helperMesh) {
            return false;
        }
        return this._helperMesh.material.visible;
    }

    set showHelpers(visible: boolean) {
        if (visible && !this._helperMesh) {
            this._helperMesh = new Mesh(this.geometry, helperMaterial);
            this._helperMesh.matrixAutoUpdate = false;
            this._helperMesh.name = 'collider helper';
            this.add(this._helperMesh);
            this._helperMesh.updateMatrix();
            this._helperMesh.updateMatrixWorld(true);
        }

        if (!visible && this._helperMesh) {
            this._helperMesh.removeFromParent();
            this._helperMesh = null;
        }

        if (this._helperMesh) {
            this._helperMesh.material.visible = visible;
        }
    }

    get segments() {
        return this._segments;
    }

    set segments(v) {
        if (this._segments !== v) {
            this._segments = v;
            this.createGeometry();
            this.material.segments = v;
            if (this._enableCPUTerrain) {
                this._shouldUpdateHeightMap = true;
            }
        }
    }

    private createGeometry() {
        this.geometry = this._enableCPUTerrain
            ? makeRaycastableGeometry(this.extent, this._segments)
            : makePooledGeometry(this._pool, this.extent, this._segments, this.level);
        if (this._helperMesh) {
            this._helperMesh.geometry = this.geometry;
        }
    }

    onLayerVisibilityChanged(layer: Layer) {
        if (layer instanceof ElevationLayer && this._enableCPUTerrain) {
            this._shouldUpdateHeightMap = true;
        }
    }

    addChildTile(tile: TileMesh) {
        this.add(tile);
        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));
        }
    }

    reorderLayers() {
        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);
    }

    override raycast(raycaster: Raycaster, intersects: Intersection[]): void {
        // 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();
            super.raycast(raycaster, intersects);
        }
    }

    private saveMaterialProperties() {
        // Some shadow map rendering forces backside on the material. Since we are not using
        // a distinct material for shadows, we need to reset the side to the correct value after
        // shadows are rendered.
        this._materialSide = this.material.side;
    }

    private restoreMaterialProperties() {
        // Reset shadow map specific defines
        this.material.isMeshDistanceMaterial = false;

        MaterialUtils.setDefine(this.material, 'DEPTH_RENDER', false);
        MaterialUtils.setDefine(this.material, 'DISTANCE_RENDER', false);

        MaterialUtils.setDefine(this.material, 'COLOR_RENDER', true);

        this.material.side = this._materialSide;
    }

    // @ts-expect-error customDepthMaterial is supposed to be a property
    get customDepthMaterial() {
        this.saveMaterialProperties();

        // Instead of using a different material, which would have to be synchronized with
        // the main material, we simply use the main material and set it to depth render.
        MaterialUtils.setDefine(this.material, 'COLOR_RENDER', false);
        MaterialUtils.setDefine(this.material, 'DEPTH_RENDER', true);
        this.material.onBeforeRender();

        return this.material;
    }

    // @ts-expect-error customDistanceMaterial is supposed to be a property
    get customDistanceMaterial() {
        this.saveMaterialProperties();

        this.material.isMeshDistanceMaterial = true;

        // Instead of using a different material, which would have to be synchronized with
        // the main material, we simply use the main material and set it to distance render.
        MaterialUtils.setDefine(this.material, 'COLOR_RENDER', false);
        MaterialUtils.setDefine(this.material, 'DISTANCE_RENDER', true);
        this.material.onBeforeRender();

        return this.material;
    }

    onAfterShadow(): void {
        this.restoreMaterialProperties();
    }

    private updateHeightMapIfNecessary(): void {
        if (this._shouldUpdateHeightMap && this._enableCPUTerrain) {
            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) {
        const diff = neighbour.level - this.level;

        const neighbourTexture = neighbour.material.getElevationTexture();
        const neighbourOffsetScale = neighbour.material.getElevationOffsetScale();

        const offsetScale = this.extent.offsetToParent(neighbour.extent);
        const nOffsetScale = neighbourOffsetScale.combine(offsetScale);

        this.material.updateNeighbour(location, diff, nOffsetScale, neighbourTexture);
    }

    /**
     * @param neighbours - The neighbours.
     */
    processNeighbours(neighbours: NeighbourList<TileMesh>) {
        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.material.updateNeighbour(i, NO_NEIGHBOUR, NO_OFFSET_SCALE, null);
            }
        }
    }

    update(materialOptions: MaterialOptions) {
        if (this._enableCPUTerrain && this._heightMap && this._elevationLayerInfo) {
            if (this._enableTerrainDeformation !== materialOptions.terrain.enabled) {
                this._enableTerrainDeformation = materialOptions.terrain.enabled;
                this._shouldUpdateHeightMap = true;
            }
        }

        this.showHelpers = materialOptions.showColliderMeshes ?? false;
    }

    isVisible() {
        return this.visible;
    }

    setDisplayed(show: boolean) {
        const currentVisibility = this.material.visible;
        this.material.visible = show && this.material.update();
        if (this._helperMesh) {
            this._helperMesh.visible = this.material.visible;
        }
        if (currentVisibility !== show) {
            this.dispatchEvent({ type: 'visibility-changed' });
        }
    }

    /**
     * @param v - The new opacity.
     */
    set opacity(v: number) {
        this.material.opacity = v;
    }

    setVisibility(show: boolean) {
        const currentVisibility = this.visible;
        this.visible = show;
        if (currentVisibility !== show) {
            this.dispatchEvent({ type: 'visibility-changed' });
        }
    }

    isDisplayed() {
        return this.material.visible;
    }

    /**
     * Updates the rendering state of the tile's material.
     *
     * @param state - The new rendering state.
     */
    changeState(state: RenderingState) {
        this.material.changeState(state);
    }

    static applyChangeState(o: Object3D, s: RenderingState) {
        if ((o as TileMesh).isTileMesh) {
            (o as TileMesh).changeState(s);
        }
    }

    pushRenderState(state: RenderingState) {
        if (this.material.uniforms.renderingState.value === state) {
            return () => {
                /** do nothing */
            };
        }

        const oldState = this.material.uniforms.renderingState.value;
        this.traverse(n => TileMesh.applyChangeState(n, state));

        return () => {
            this.traverse(n => TileMesh.applyChangeState(n, oldState));
        };
    }

    canProcessColorLayer(): boolean {
        return this.material.canProcessColorLayer();
    }

    private static canSubdivideTile(tile: TileMesh): boolean {
        let current = tile;
        let ancestorLevel = 0;

        // To be able to subdivide a tile, we need to ensure that we
        // have proper elevation data on this tile (if applicable).
        // Otherwise the newly created tiles will not have a correct bounding box,
        // and this will mess with frustum culling / level of detail selection, in turn leading
        // to dangerous levels of subdivisions (and hundreds/thousands of undesired tiles).
        // On the other hand, we can afford a bit of undesired tiles if it means that
        // the color layers will display correctly.
        const LOD_MARGIN = 3;
        while (ancestorLevel < LOD_MARGIN && current != null) {
            if (
                current != null &&
                current.material != null &&
                current.material.isElevationLayerTextureLoaded()
            ) {
                return true;
            }
            ancestorLevel++;
            if (isTileMesh(current.parent)) {
                current = current.parent as TileMesh;
            } else {
                break;
            }
        }

        return false;
    }

    canSubdivide() {
        return TileMesh.canSubdivideTile(this);
    }

    removeElevationTexture() {
        this._elevationLayerInfo = null;
        this._shouldUpdateHeightMap = true;
        this.material.removeElevationLayer();
    }

    setElevationTexture(
        layer: ElevationLayer,
        elevation: {
            texture: Texture;
            pitch: OffsetScale;
            min?: number;
            max?: number;
            renderTarget: WebGLRenderTarget;
        },
        isFinal = false,
    ) {
        if (this.disposed) {
            return;
        }

        this._elevationLayerInfo = {
            layer,
            offsetScale: elevation.pitch,
            renderTarget: elevation.renderTarget,
        };

        this.material.setElevationTexture(layer, elevation, isFinal);

        this.setBBoxZ(elevation.min, elevation.max);

        if (this._enableCPUTerrain) {
            this._shouldUpdateHeightMap = true;
        }

        this._onElevationChanged(this);
    }

    private createHeightMap(renderTarget: WebGLRenderTarget, offsetScale: OffsetScale) {
        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._instance.renderer,
            outputWidth,
            outputHeight,
            precision,
            offset,
        });

        const heightMap = new HeightMap(
            buffer,
            outputWidth,
            outputHeight,
            offsetScale,
            RGBAFormat,
            UnsignedByteType,
            precision,
            offset,
        );
        this._heightMap = intoUniqueOwner(heightMap, this);
    }

    private inheritHeightMap(heightMap: UniqueOwner<HeightMap, this>) {
        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() {
        this.geometry.resetHeights();
        this.setBBoxZ(0, 0);

        this._onElevationChanged(this);
    }

    private applyHeightMap() {
        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);
        }

        this._onElevationChanged(this);
    }

    setBBoxZ(min: number | undefined, max: number | undefined) {
        // 0 is an acceptable value
        if (min == null || max == null) {
            return;
        }
        this._minmax = { min, max };

        this.updateVolume(min, max);
    }

    traverseTiles(callback: (descendant: TileMesh) => void) {
        this.traverse(obj => {
            if (isTileMesh(obj)) {
                callback(obj);
            }
        });
    }

    /**
     * Removes the child tiles and returns the detached tiles.
     */
    detachChildren(): TileMesh[] {
        const childTiles = this.children.filter(c => isTileMesh(c)) as TileMesh[];
        childTiles.forEach(c => c.dispose());
        this.remove(...childTiles);
        return childTiles;
    }

    private updateVolume(min: number, max: number) {
        const v = this._volume;
        if (Math.floor(min) !== Math.floor(v.zMin) || Math.floor(max) !== Math.floor(v.zMax)) {
            this._volume.zMin = min;
            this._volume.zMax = max;
        }
    }

    get minmax() {
        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;
    }

    getExtent() {
        return this.extent;
    }

    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;
    }

    /**
     * Gets whether this mesh is currently performing processing.
     *
     * @returns `true` if the mesh is currently performing processing, `false` otherwise.
     */
    get loading() {
        return this.material.loading;
    }

    /**
     * Gets the progress percentage (normalized in [0, 1] range) of the processing.
     *
     * @returns The progress percentage.
     */
    get progress() {
        return this.material.progress;
    }

    /**
     * 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
     */
    findCommonAncestor(tile: TileMesh): TileMesh | null {
        if (tile == null) {
            return null;
        }
        if (tile.level === this.level) {
            if (tile.id === this.id) {
                return tile;
            }
            if (tile.level !== 0) {
                return (this.parent as TileMesh).findCommonAncestor(tile.parent as TileMesh);
            }
            return null;
        }
        if (tile.level < this.level) {
            return (this.parent as TileMesh).findCommonAncestor(tile);
        }
        return this.findCommonAncestor(tile.parent as TileMesh);
    }

    isAncestorOf(node: TileMesh) {
        return node.findCommonAncestor(this) === this;
    }

    dispose() {
        if (this.disposed) {
            return;
        }
        this.disposed = true;
        this.dispatchEvent({ type: 'dispose' });
        this.material.dispose();
        if (this._enableCPUTerrain) {
            // When colliders are enabled, geometries are created for each tile,
            // and thus must be disposed when the mesh is disposed.
            this.geometry.dispose();
        }
    }
}

export function isTileMesh(o: unknown): o is TileMesh {
    return (o as TileMesh).isTileMesh;
}

export default TileMesh;
