Files
eacp_webapp/src/Map/Terrarium_Terrain_Provider.tsx
T
2026-08-05 11:59:44 +08:00

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