@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
233 lines (193 loc) • 7.06 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import type { FloatType, RedFormat, RGFormat, TypedArray, UnsignedShortType } from 'three';
import { MathUtils, RGBAFormat, UnsignedByteType, Vector2 } from 'three';
import type ElevationRange from './ElevationRange';
import type OffsetScale from './OffsetScale';
import type Rect from './Rect';
import TextureGenerator from '../utils/TextureGenerator';
const RGBA_OFFSET = 20000;
const temp = {
input: new Vector2(),
output: new Vector2(),
ij: new Vector2(),
topLeft: new Vector2(),
bottomRight: new Vector2(),
};
export type HeightMapPixelFormat = typeof RGBAFormat | typeof RGFormat | typeof RedFormat;
export type HeightMapTextureDataType =
| typeof UnsignedByteType
| typeof UnsignedShortType
| typeof FloatType;
/**
* Utility class to sample an elevation raster.
*/
export class HeightMap {
/**
* The heightmap data.
*/
public readonly buffer: TypedArray;
/**
* The width, in pixels, of the heightmap buffer.
*/
public readonly width: number;
/**
* The height, in pixels, of the heightmap buffer.
*/
public readonly height: number;
/**
* The transformation to apply to UV coordinates before sampling the buffer.
*/
public readonly offsetScale: OffsetScale;
/**
* The distance between each elevation value in the buffer.
* e.g If the buffer is an RGBA buffer, stride is 4.
*/
public readonly stride: number;
/**
* The format of the underlying buffer pixels.
*/
public readonly format: HeightMapPixelFormat;
/**
* The data type of the underlying buffer pixels.
*/
public readonly type: HeightMapTextureDataType;
/**
* The vertical precision of the height values to apply during decoding.
*/
public readonly precision: number;
/**
* The offset to apply to height values during decoding.
*/
public readonly offset: number;
/**
* The vertical scaling to apply in order to get values in meter.
*/
public readonly verticalScaling: number;
public constructor(
buffer: TypedArray,
width: number,
height: number,
offsetScale: OffsetScale,
format: HeightMapPixelFormat,
type: HeightMapTextureDataType,
precision?: number,
offset?: number,
verticalScaling?: number,
) {
const stride = TextureGenerator.getChannelCount(format);
if (buffer.length < width * height * stride) {
throw new Error('buffer is too small');
}
this.buffer = buffer;
this.width = width;
this.height = height;
this.offsetScale = offsetScale;
this.stride = stride;
this.format = format;
this.type = type;
this.precision = precision ?? 0.1;
this.offset = offset ?? RGBA_OFFSET;
this.verticalScaling = verticalScaling ?? 1;
}
private readRGBA(index: number, ignoreNoData: boolean): number | null {
const { buffer, stride } = this;
const r = buffer[index * stride + 0];
const g = buffer[index * stride + 1];
const b = buffer[index * stride + 2];
const alpha = buffer[index * stride + 3];
if (!ignoreNoData && alpha === 0) {
return null;
}
return (r + g * 256.0 + b * 256.0 * 256.0) * this.precision - this.offset;
}
private readRG(index: number, ignoreNoData: boolean): number | null {
const { buffer, stride } = this;
const alpha = buffer[index * stride + 1];
if (!ignoreNoData && alpha === 0) {
return null;
}
const value = buffer[index * stride + 0];
return value;
}
public clone(): HeightMap {
return new HeightMap(
this.buffer,
this.width,
this.height,
this.offsetScale.clone(),
this.format,
this.type,
this.precision,
this.offset,
this.verticalScaling,
);
}
/**
* Returns the elevation of the pixel that contains the UV coordinate.
* No interpolation is performed.
* @param u - The normalized U coordinate (along the horizontal axis).
* @param v - The normalized V coordinate (along the vertical axis).
* @param ignoreTransparentPixels - If `true`, then transparent pixels are returned. Otherwise
* values that match transparent pixels return `null`. Default is `false`.
*/
public getValue(u: number, v: number, ignoreTransparentPixels = false): number | null {
const ij = this.getPixelCoordinates(u, v, temp.ij);
return this.getValueRaw(ij.x, ij.y, ignoreTransparentPixels);
}
/**
* Computes the min/max elevation from the given normalized region.
* @param uvRect - The normalized region to process.
* @returns The min/max, if any, otherwise `null`.
*/
public getMinMax(uvRect: Rect): ElevationRange | null {
const left = uvRect.left;
const top = uvRect.top;
const bottom = uvRect.bottom;
const right = uvRect.right;
let min = +Infinity;
let max = -Infinity;
const topLeft = this.getPixelCoordinates(left, top, temp.topLeft);
const bottomRight = this.getPixelCoordinates(right, bottom, temp.bottomRight);
for (let i = topLeft.x; i <= bottomRight.x; i++) {
for (let j = bottomRight.y; j <= topLeft.y; j++) {
const z = this.getValueRaw(i, j, true);
if (z != null) {
min = Math.min(z, min);
max = Math.max(z, max);
}
}
}
if (isFinite(min) && isFinite(max)) {
return { min, max };
}
return null;
}
private getPixelCoordinates(u: number, v: number, target: Vector2): Vector2 {
const { width, height, offsetScale } = this;
temp.input.set(u, v);
const transformed = offsetScale.transform(temp.input, temp.output);
const uu = MathUtils.clamp(transformed.x, 0, 1);
const vv = MathUtils.clamp(transformed.y, 0, 1);
const i = MathUtils.clamp(Math.round(uu * width - 1), 0, width);
const j = MathUtils.clamp(Math.round(vv * height - 1), 0, height);
return target.set(i, j);
}
private getValueRaw(i: number, j: number, ignoreTransparentPixels = false): number | null {
const index = i + j * this.width;
let rawValue: number | null = null;
if (this.format === RGBAFormat && this.type === UnsignedByteType) {
rawValue = this.readRGBA(index, ignoreTransparentPixels);
} else {
rawValue = this.readRG(index, ignoreTransparentPixels);
}
if (rawValue !== null) {
rawValue *= this.verticalScaling;
}
return rawValue;
}
}
export default HeightMap;