terrain/ProceduralTerrain.tstypescript
import { createEvents } from "../Events";
import { EmptyTerrain } from "./EmptyTerrain";
import type { IEmptyTerrainParams, ITerrainCacheInfo } from "./EmptyTerrain";
import { TERRAIN_EVENTS } from "./GlobusTerrain";
import type { TerrainEvents } from "./GlobusTerrain";
import {
    createHeightsLevels,
    createHeightsLevelsSource,
    createLevelHeights,
    getGridCells,
    getGroundHeight,
    getSubdivisionLevels,
    TERRAIN_GRID_SIZE_BY_ZOOM
} from "./terrainUtils";
import type { IHeightsLevelsSource } from "./terrainUtils";
import { Extent } from "../Extent";
import { Layer } from "../layer/Layer";
import { LonLat } from "../LonLat";
import { isPowerOfTwo } from "../math";
import { Vec3 } from "../math/Vec3";
import type { Segment } from "../segment/Segment";
import { LRUCache } from "../utils/LRUCache";
import { getRasterWorker, RasterWorker } from "../utils/RasterWorker";
import type { TypedArray } from "../utils/shared";

const _point = new Vec3();

export type ProceduralHeights = number[] | TypedArray | null;

export type GetHeightsFunc = (segment: Segment) => ProceduralHeights | Promise<ProceduralHeights>;

export interface IProceduralTerrainCommon extends IEmptyTerrainParams {
    plainGridSize?: number;
    noDataValues?: number[];
    nativeCacheSize?: number;
}

export interface IProceduralTerrainParams extends IProceduralTerrainCommon {
    getHeights: GetHeightsFunc;
}

type TileHeights = {
    heights: ProceduralHeights;
    extent: Extent;
};

/**
 * Terrain with the heights made by a function, without a server.
 * @class
 * @extends {EmptyTerrain}
 * @param {string} [name="ProceduralTerrain"] - Terrain provider name.
 * @param {IProceduralTerrainParams} options - Provider options:
 * @param {GetHeightsFunc} options.getHeights - Returns the segment heights, or a promise of them, rows from north
 * to south, vertices evenly spaced in segment.getExtent(), or null when the segment has no terrain.
 * (plainGridSize + 1)² heights are taken as they are. Larger (2^n + 1)² vertices, or 2^n x 2^n pixels, are reduced
 * to them keeping peaks and pits. Segments of the height requests are not in the quad tree, their node is null.
 * The function runs in the main thread. Returned heights are reduced in the main thread too and applied right away,
 * promised heights are reduced in a worker when the promise is resolved.
 * @param {number} [options.plainGridSize=32] - Elevation grid size.
 * @param {number} [options.maxNativeZoom=maxZoom] - Last zoom of the height requests. Deeper segments are the
 * subdivision levels of the maxNativeZoom segment heights, as RgbTerrain ones, its pixels keep their heights there.
 * @param {number} [options.nativeCacheSize=50] - Number of maxNativeZoom segments which pixels are kept to make
 * their levels when they are needed. The least recently used are dropped and requested again.
 * @param {Array.<number>} [options.gridSizeByZoom] - Segment grid sizes by zoom, the same as RgbTerrain ones by default.
 * @param {Array.<number>} [options.noDataValues] - Heights of no data.
 *
 * @fires load
 * @fires loadend
 *
 * @example
 * // Heights made right away are reduced in the main thread
 * new ProceduralTerrain("Flat", {
 *     getHeights: (segment) => new Float32Array(129 * 129).fill(100)
 * });
 *
 * // Promised heights are reduced in a worker
 * new ProceduralTerrain("Loaded", {
 *     getHeights: (segment) => loadTilePixels(segment) // Promise<Float32Array>
 * });
 */
class ProceduralTerrain extends EmptyTerrain {
    public events: TerrainEvents;

    protected _loading: number;

    protected _getHeights: GetHeightsFunc;

    protected _heightsCache: Record<string, TileHeights>;

    protected _pendingHeights: Record<string, Promise<TileHeights>>;

    /**
     * Pixels of the maxNativeZoom segments with subdivision levels. A dropped segment drops its heights too,
     * so a deeper segment requests the pixels again.
     */
    protected _levelsSources: LRUCache<string, IHeightsLevelsSource>;

    protected _loadendFrame: number;

    constructor(name: string | null, options: IProceduralTerrainParams) {
        super({
            equalizeVertices: true,
            ...options,
            gridSizeByZoom: options.gridSizeByZoom || TERRAIN_GRID_SIZE_BY_ZOOM.slice(),
            name: name || "ProceduralTerrain"
        });

        this.events = createEvents(TERRAIN_EVENTS, this);
        this.isEmpty = false;
        this.equalizeNormals = true;
        this.plainGridSize = options.plainGridSize || 32;
        this.noDataValues = options.noDataValues || [];
        this._loading = 0;
        this._getHeights = options.getHeights;
        this._heightsCache = {};
        this._pendingHeights = {};
        this._levelsSources = new LRUCache(options.nativeCacheSize || 50, (tileIndex) => {
            delete this._heightsCache[tileIndex];
        });
        this._loadendFrame = 0;
    }

    public override clearCache() {
        this._heightsCache = {};
        this._levelsSources.clear();
    }

    public override getCacheInfo(): ITerrainCacheInfo {
        return { tiles: Object.keys(this._heightsCache).length, native: this._levelsSources.size };
    }

    public override abortLoading() {
        cancelAnimationFrame(this._loadendFrame);
        this._loadendFrame = 0;
    }

    public override loadTerrain(segment: Segment) {
        if (!this._planet!.terrainLock.isFree()) {
            segment.terrainIsLoading = false;
            return;
        }

        segment.terrainReady = false;
        segment.terrainIsLoading = true;

        let tile = this._getSegmentHeights(segment);

        if (tile instanceof Promise) {
            tile.then((t) => this._applyHeights(segment, t));
        } else {
            this._applyHeights(segment, tile);
        }

        if (!this._loadendFrame && this.events.loadend!.handlers.length) {
            this._loadendFrame = requestAnimationFrame(this._checkLoadend);
        }
    }

    protected _applyHeights(segment: Segment, tile: TileHeights) {
        if (!this._planet || !segment.plainReady || !segment.terrainIsLoading) {
            return;
        }

        if (this.events.load!.handlers.length) {
            this.events.dispatch(this.events.load!, { elevations: tile.heights, segment: segment });
        }

        segment.applyTerrain(tile.heights);
    }

    protected _checkLoadend = () => {
        if (!this._planet || (this._loading === 0 && this._planet.quadTreeStrategy._terrainCompletedActivated)) {
            this._loadendFrame = 0;
            this.events.dispatch(this.events.loadend!, this);
        } else {
            this._loadendFrame = requestAnimationFrame(this._checkLoadend);
        }
    };

    /**
     * Returns the height under the point from the zoom tile heights, they are made for the request if there are none.
     * Calls back right away and returns true when the heights are ready.
     */
    public override getHeightAsync(lonLat: LonLat, callback: (h: number) => void, zoom?: number): boolean {
        let z = zoom != undefined ? zoom : this.maxZoom,
            tile = this._getPointTileHeights(lonLat, z, _point),
            x = _point.x,
            y = _point.y;

        const getHeight = (t: TileHeights | null) => (t && t.heights ? getGroundHeight(x, y, t.extent, t.heights) : 0);

        if (tile instanceof Promise) {
            tile.then((t) => {
                if (!t.heights && z > this.maxNativeZoom) {
                    this.getHeightAsync(lonLat, callback, z - 1);
                } else {
                    callback(getHeight(t));
                }
            });
            return false;
        }

        if (!(tile && tile.heights) && z > this.maxNativeZoom) {
            return this.getHeightAsync(lonLat, callback, z - 1);
        }

        callback(getHeight(tile));
        return true;
    }

    protected _getPointTileHeights(
        lonLat: LonLat,
        zoom: number,
        outPoint: Vec3
    ): TileHeights | Promise<TileHeights> | null {
        let node = this._planet!.quadTreeStrategy.getNodeByLonLat(lonLat, zoom);

        if (!node) {
            return null;
        }

        let s = node.segment,
            p = s.projectNative(lonLat);

        outPoint.set(p.lon, p.lat, 0);

        if (s.tileZoom === zoom) {
            return this._getSegmentHeights(s);
        }

        let d = 1 << (zoom - s.tileZoom),
            e = s.getExtent(),
            width = e.getWidth() / d,
            height = e.getHeight() / d,
            j = Math.min(Math.max(Math.floor((p.lon - e.southWest.lon) / width), 0), d - 1),
            i = Math.min(Math.max(Math.floor((e.northEast.lat - p.lat) / height), 0), d - 1);

        let tileIndex = Layer.getTileIndex(s.tileX * d + j, s.tileY * d + i, zoom, s.tileGroup),
            cache = this._heightsCache[tileIndex] || this._pendingHeights[tileIndex];

        if (cache) {
            return cache;
        }

        // The segment is out of the quad tree, it has no node
        let segment = new node.SegmentPrototype(
            null!,
            node.quadTreeStrategy,
            zoom,
            new Extent(
                new LonLat(e.southWest.lon + j * width, e.northEast.lat - (i + 1) * height),
                new LonLat(e.southWest.lon + (j + 1) * width, e.northEast.lat - i * height)
            )
        );

        segment._setExtentLonLat();

        return this._getSegmentHeights(segment);
    }

    /**
     * Returns the segment heights, or the promise of them while they are made. Every tile heights are requested once,
     * failed requests are made again next time.
     */
    protected _getSegmentHeights(segment: Segment): TileHeights | Promise<TileHeights> {
        let tileIndex = segment.tileIndex,
            cache = this._heightsCache[tileIndex] || this._pendingHeights[tileIndex];

        if (cache) {
            return cache;
        }

        if (segment.tileZoom > this.maxNativeZoom) {
            return this._getLevelHeights(segment);
        }

        let extent = segment.getExtent(),
            heights = this._getHeights(segment);

        if (!(heights instanceof Promise)) {
            return this._setHeights(segment, extent, this._toGridHeights(segment, heights, null));
        }

        this._loading++;

        // Loaded heights make their grid in the worker, it takes the frame time of the main thread
        return (this._pendingHeights[tileIndex] = heights
            .then((h) => this._toGridHeights(segment, h, getRasterWorker()))
            .then(
                (h) => this._setHeights(segment, extent, h),
                (err) => {
                    console.error(err);
                    return { heights: null, extent: extent };
                }
            )
            .then((tile) => {
                delete this._pendingHeights[tileIndex];
                this._loading--;
                return tile;
            }));
    }

    /**
     * Returns the subdivision level heights of the maxNativeZoom segment, made of its pixels when they are needed.
     * Levels end at the vertex grid, deeper segments have no heights and take the terrain of the level above.
     */
    protected _getLevelHeights(segment: Segment): TileHeights | Promise<TileHeights> {
        let native = this._getNativeSegment(segment),
            nativeHeights = this._getSegmentHeights(native),
            getLevel = () => this._heightsCache[segment.tileIndex] || this._createLevelHeights(segment, native);

        return nativeHeights instanceof Promise ? nativeHeights.then(getLevel) : getLevel();
    }

    protected _createLevelHeights(segment: Segment, native: Segment): TileHeights {
        let source = this._levelsSources.get(native.tileIndex),
            level = segment.tileZoom - native.tileZoom,
            extent = segment.getExtent();

        if (!source || level > source.levels) {
            return {
                heights: null,
                extent: extent
            };
        }

        let d = 1 << level;

        return (this._heightsCache[segment.tileIndex] = {
            heights: createLevelHeights(
                source,
                level,
                segment.tileY - native.tileY * d,
                segment.tileX - native.tileX * d
            ),
            extent: extent
        });
    }

    /**
     * Returns the maxNativeZoom segment of the deeper segment, out of the quad tree for the height requests.
     */
    protected _getNativeSegment(segment: Segment): Segment {
        let n = segment.node;

        if (n) {
            while (n.segment.tileZoom > this.maxNativeZoom) {
                n = n.parentNode!;
            }
            return n.segment;
        }

        let d = 1 << (segment.tileZoom - this.maxNativeZoom),
            e = segment.getExtent(),
            width = e.getWidth(),
            height = e.getHeight(),
            west = e.southWest.lon - (segment.tileX % d) * width,
            north = e.northEast.lat + (segment.tileY % d) * height;

        let native = new (segment.constructor as typeof Segment)(
            null!,
            segment.quadTreeStrategy,
            this.maxNativeZoom,
            new Extent(new LonLat(west, north - d * height), new LonLat(west + d * width, north))
        );

        native._setExtentLonLat();

        return native;
    }

    protected _setHeights(segment: Segment, extent: Extent, heights: ProceduralHeights): TileHeights {
        return (this._heightsCache[segment.tileIndex] = {
            heights: heights,
            extent: extent
        });
    }

    /**
     * Returns the grid heights of the segment, or the promise of them the worker makes. The maxNativeZoom segment
     * with subdivision levels keeps its pixels to make them.
     */
    protected _toGridHeights(segment: Segment, heights: ProceduralHeights, worker: null): ProceduralHeights;
    protected _toGridHeights(
        segment: Segment,
        heights: ProceduralHeights,
        worker: RasterWorker | null
    ): ProceduralHeights | Promise<ProceduralHeights>;
    protected _toGridHeights(
        segment: Segment,
        heights: ProceduralHeights,
        worker: RasterWorker | null
    ): ProceduralHeights | Promise<ProceduralHeights> {
        if (!heights) {
            return null;
        }

        let size = Math.round(Math.sqrt(heights.length)),
            cells = getGridCells(size);

        if (size * size === heights.length && size === this.plainGridSize + 1) {
            return heights;
        }

        if (size * size !== heights.length || !isPowerOfTwo(cells) || cells < this.plainGridSize) {
            console.warn(
                `ProceduralTerrain: ${heights.length} heights of the segment ${segment.tileIndex}, ` +
                    `(plainGridSize + 1)², larger (2^n + 1)² or 2^n x 2^n are expected`
            );
            return null;
        }

        let decodeRow = (row: number, out: Float32Array) => {
            for (let j = 0, k = row * size; j < size; j++, k++) {
                out[j] = heights[k];
            }
        };

        let levels =
            segment.tileZoom === this.maxNativeZoom
                ? getSubdivisionLevels(size, this.plainGridSize, segment.tileZoom, this.maxZoom, this.maxNativeZoom)
                : 0;

        let noDataHeight = this.noDataValues.length ? this.noDataValues[0] : NaN;

        if (worker) {
            if (levels) {
                this._levelsSources.set(
                    segment.tileIndex,
                    createHeightsLevelsSource(size, this.plainGridSize, levels, decodeRow, noDataHeight)
                );
            }

            return worker.heights(new Float32Array(heights), size, this.plainGridSize, levels, noDataHeight);
        }

        if (!levels) {
            return createHeightsLevels(size, this.plainGridSize, 0, decodeRow, noDataHeight);
        }

        let source = createHeightsLevelsSource(size, this.plainGridSize, levels, decodeRow, noDataHeight);

        this._levelsSources.set(segment.tileIndex, source);

        return createLevelHeights(source, 0, 0, 0);
    }
}

export { ProceduralTerrain };