235 lines
10 KiB
TypeScript
235 lines
10 KiB
TypeScript
import * as Cesium from "cesium";
|
|
import type {Map_Tile_Metadata} from "./Map_Resources.tsx";
|
|
import {bilinear_height, fill_height_gaps_from_nearest, fill_height_gaps_from_parent} from "./Terrarium_Height.ts";
|
|
type Cached_Height_Tile = {
|
|
promise: Promise<Float32Array | undefined>
|
|
}
|
|
type Decoded_Height_Tile = {
|
|
heights: Float32Array
|
|
invalid_count: number
|
|
}
|
|
type Terrarium_Terrain_Provider_Options = {
|
|
maximum_level?: number
|
|
max_cached_height_tiles?: number
|
|
}
|
|
type Pending_Worker_Request = {
|
|
resolve: (value: Decoded_Height_Tile | null | undefined) => void
|
|
reject: (reason?: any) => void
|
|
}
|
|
type Worker_Response = {
|
|
id: number
|
|
buffer?: ArrayBuffer
|
|
invalid_count?: number
|
|
missing?: boolean
|
|
aborted?: boolean
|
|
error?: string
|
|
}
|
|
export class Terrarium_Terrain_Provider {
|
|
provider: Cesium.TerrainProvider
|
|
private resource: Map_Tile_Metadata
|
|
private height_cache: Map<string, Cached_Height_Tile> = new Map()
|
|
private missing_tiles: Set<string> = new Set()
|
|
private worker: Worker
|
|
private pending_worker_requests: Map<number, Pending_Worker_Request> = new Map()
|
|
private next_worker_request_id = 1
|
|
private maximum_level: number
|
|
private max_cached_height_tiles: number
|
|
private tiling_scheme: Cesium.WebMercatorTilingScheme
|
|
private destroyed = false
|
|
constructor(resource: Map_Tile_Metadata, options: Terrarium_Terrain_Provider_Options = {}) {
|
|
if (resource.projection !== "web_mercator") {
|
|
throw new Error(`Unsupported terrain projection: ${resource.projection}`)
|
|
}
|
|
if (resource.encoding !== "terrarium") {
|
|
throw new Error(`Unsupported terrain encoding: ${resource.encoding}`)
|
|
}
|
|
this.resource = resource
|
|
this.maximum_level = Math.min(options.maximum_level ?? resource.maximum_level, resource.maximum_level)
|
|
this.max_cached_height_tiles = options.max_cached_height_tiles || 128
|
|
this.tiling_scheme = new Cesium.WebMercatorTilingScheme()
|
|
this.worker = new Worker(new URL("./Terrarium_Terrain_Worker.ts", import.meta.url), {type: "module"})
|
|
this.worker.onmessage = (event: MessageEvent<Worker_Response>) => this.handle_worker_response(event.data)
|
|
this.worker.onerror = (event) => this.reject_all_worker_requests(new Error(event.message))
|
|
const provider = new Cesium.CustomHeightmapTerrainProvider({
|
|
width: resource.tile_size,
|
|
height: resource.tile_size,
|
|
tilingScheme: this.tiling_scheme,
|
|
callback: () => new Float32Array(resource.tile_size * resource.tile_size)
|
|
})
|
|
;(provider as any).requestTileGeometry = (x: number, y: number, level: number, request?: Cesium.Request) => {
|
|
return this.request_tile_geometry(x, y, level, request)
|
|
}
|
|
;(provider as any).getTileDataAvailable = (x: number, y: number, level: number) => {
|
|
return this.get_tile_data_available(x, y, level)
|
|
}
|
|
this.provider = provider as unknown as Cesium.TerrainProvider
|
|
}
|
|
destroy() {
|
|
this.destroyed = true
|
|
this.resolve_all_worker_requests()
|
|
this.worker.terminate()
|
|
this.height_cache.clear()
|
|
this.missing_tiles.clear()
|
|
}
|
|
async sample_height(cartographic: Cesium.Cartographic, level?: number): Promise<number | undefined> {
|
|
if (this.destroyed) return undefined
|
|
for (let sample_level = this.sample_level(level); sample_level >= this.resource.minimum_level; --sample_level) {
|
|
const tile = this.tiling_scheme.positionToTileXY(cartographic, sample_level)
|
|
const heights = await this.load_height_tile(tile.x, tile.y, sample_level)
|
|
if (!heights || this.destroyed) continue
|
|
const rectangle = this.tiling_scheme.tileXYToRectangle(tile.x, tile.y, sample_level)
|
|
const x = Cesium.Math.clamp((cartographic.longitude - rectangle.west) / (rectangle.east - rectangle.west), 0, 1) * (this.resource.tile_size - 1)
|
|
const y = Cesium.Math.clamp((rectangle.north - cartographic.latitude) / (rectangle.north - rectangle.south), 0, 1) * (this.resource.tile_size - 1)
|
|
return bilinear_height(heights, this.resource.tile_size, x, y)
|
|
}
|
|
return undefined
|
|
}
|
|
private sample_level(level?: number): number {
|
|
const requested = level ?? this.maximum_level
|
|
return Math.max(this.resource.minimum_level, Math.min(requested, this.maximum_level))
|
|
}
|
|
private request_tile_geometry(x: number, y: number, level: number, request?: Cesium.Request): Promise<Cesium.TerrainData | undefined> | undefined {
|
|
if (this.destroyed || !this.is_level_available(level) || this.missing_tiles.has(this.tile_key(x, y, level))) {
|
|
return undefined
|
|
}
|
|
return this.load_height_tile(x, y, level, request).then(heights => {
|
|
if (!heights || this.destroyed) {
|
|
return undefined
|
|
}
|
|
return new Cesium.HeightmapTerrainData({
|
|
buffer: new Float32Array(heights),
|
|
width: this.resource.tile_size,
|
|
height: this.resource.tile_size
|
|
})
|
|
})
|
|
}
|
|
private load_height_tile(x: number, y: number, level: number, request?: Cesium.Request): Promise<Float32Array | undefined> {
|
|
if (!this.is_level_available(level)) {
|
|
return Promise.resolve(undefined)
|
|
}
|
|
const key = this.tile_key(x, y, level)
|
|
if (this.missing_tiles.has(key)) {
|
|
return Promise.resolve(undefined)
|
|
}
|
|
const cached = this.height_cache.get(key)
|
|
if (cached) {
|
|
return cached.promise
|
|
}
|
|
const promise = this.load_and_complete_height_tile(key, x, y, level, request).catch(error => {
|
|
this.height_cache.delete(key)
|
|
throw error
|
|
})
|
|
this.height_cache.set(key, {promise})
|
|
this.trim_cache()
|
|
return promise
|
|
}
|
|
private async load_and_complete_height_tile(key: string, x: number, y: number, level: number, request?: Cesium.Request): Promise<Float32Array | undefined> {
|
|
const decoded = await this.decode_height_tile_in_worker(key, x, y, level, request)
|
|
if (decoded === null) {
|
|
this.mark_tile_missing(key)
|
|
return undefined
|
|
}
|
|
if (!decoded || this.destroyed) {
|
|
return undefined
|
|
}
|
|
if (decoded.invalid_count === 0) {
|
|
return decoded.heights
|
|
}
|
|
if (level > this.resource.minimum_level) {
|
|
const parent = await this.load_height_tile(Math.floor(x / 2), Math.floor(y / 2), level - 1)
|
|
if (parent) {
|
|
fill_height_gaps_from_parent(decoded.heights, parent, this.resource.tile_size, x, y)
|
|
}
|
|
}
|
|
if (!fill_height_gaps_from_nearest(decoded.heights, this.resource.tile_size)) {
|
|
this.mark_tile_missing(key)
|
|
return undefined
|
|
}
|
|
return decoded.heights
|
|
}
|
|
private trim_cache() {
|
|
while (this.height_cache.size > this.max_cached_height_tiles) {
|
|
const first = this.height_cache.keys().next().value
|
|
if (first === undefined) {
|
|
break
|
|
}
|
|
this.height_cache.delete(first)
|
|
}
|
|
}
|
|
private mark_tile_missing(key: string) {
|
|
this.missing_tiles.add(key)
|
|
while (this.missing_tiles.size > this.max_cached_height_tiles * 4) {
|
|
const first = this.missing_tiles.values().next().value
|
|
if (first === undefined) {
|
|
break
|
|
}
|
|
this.missing_tiles.delete(first)
|
|
}
|
|
}
|
|
private decode_height_tile_in_worker(key: string, x: number, y: number, level: number, request?: Cesium.Request): Promise<Decoded_Height_Tile | null | undefined> {
|
|
const id = this.next_worker_request_id++
|
|
return new Promise((resolve, reject) => {
|
|
this.pending_worker_requests.set(id, {resolve, reject})
|
|
if (request) {
|
|
request.cancelFunction = () => {
|
|
const pending = this.pending_worker_requests.get(id)
|
|
if (!pending) return
|
|
this.pending_worker_requests.delete(id)
|
|
this.height_cache.delete(key)
|
|
this.worker.postMessage({type: "cancel", id})
|
|
pending.resolve(undefined)
|
|
}
|
|
}
|
|
this.worker.postMessage({type: "decode", id, url: this.tile_url(x, y, level), tile_size: this.resource.tile_size})
|
|
})
|
|
}
|
|
private handle_worker_response(response: Worker_Response) {
|
|
const pending = this.pending_worker_requests.get(response.id)
|
|
if (!pending) return
|
|
this.pending_worker_requests.delete(response.id)
|
|
if (response.error) {
|
|
pending.reject(new Error(response.error))
|
|
return
|
|
}
|
|
if (response.missing) {
|
|
pending.resolve(null)
|
|
return
|
|
}
|
|
if (response.aborted || !response.buffer) {
|
|
pending.resolve(undefined)
|
|
return
|
|
}
|
|
pending.resolve({heights: new Float32Array(response.buffer), invalid_count: response.invalid_count || 0})
|
|
}
|
|
private resolve_all_worker_requests() {
|
|
for (const pending of this.pending_worker_requests.values()) {
|
|
pending.resolve(undefined)
|
|
}
|
|
this.pending_worker_requests.clear()
|
|
}
|
|
private reject_all_worker_requests(error: Error) {
|
|
for (const pending of this.pending_worker_requests.values()) {
|
|
pending.reject(error)
|
|
}
|
|
this.pending_worker_requests.clear()
|
|
}
|
|
private get_tile_data_available(x: number, y: number, level: number): boolean | undefined {
|
|
if (!this.is_level_available(level)) return false
|
|
if (this.missing_tiles.has(this.tile_key(x, y, level))) return false
|
|
return undefined
|
|
}
|
|
private is_level_available(level: number): boolean {
|
|
return level >= this.resource.minimum_level && level <= this.maximum_level
|
|
}
|
|
private tile_key(x: number, y: number, level: number): string {
|
|
return `${level}/${x}/${y}`
|
|
}
|
|
private tile_url(x: number, y: number, level: number): string {
|
|
const tile_y = this.resource.y_axis === "tms" ? Math.pow(2, level) - 1 - y : y
|
|
return this.resource.url
|
|
.replace("{z}", String(level))
|
|
.replace("{x}", String(x))
|
|
.replace("{y}", String(tile_y))
|
|
}
|
|
}
|