Cesium terrain

Reader support for the two Cesium terrain tile formats (.terrain files) used for 3D terrain visualization in web mapping: quantized-mesh-1.0 (adaptive triangle mesh, compressed via quantization and delta encoding) and heightmap-1.0 (regular elevation grid). This module is read-only — it parses tiles, answers height queries across tile sets, and converts terrain data to raster DEMs; it does not write .terrain files. Ships in the IRI.Maptor.Core.Spatial package.

Supported capabilities

Capability Supported Implemented in
Read quantized-mesh-1.0 Yes QuantizedMeshReader.Read (path/stream/reader)
Read heightmap-1.0 Yes HeightmapReader.Read, IsHeightmapFormat
Auto-detect format Yes TerrainReader.ReadAuto, DetectFormat
Height queries (single tile / tile set) Yes TerrainReader.GetHeightAt/GetHeightAtPixel, TerrainHeightQuery
Terrain → raster DEM (RasterGeoTiff) Yes HeightmapRasterConverter.ToRasterGeoTiff, QuantizedMeshRasterConverter.ToRasterGeoTiff
Raster DEM → in-memory terrain data Yes HeightmapRasterConverter.FromRasterGeoTiff, QuantizedMeshRasterConverter.FromRasterGeoTiff
Write .terrain files (either format) No

Usage

Auto-detection and reading

using IRI.Maptor.Core.Spatial.IO.CesiumTerrain;

var (format, data) = TerrainReader.ReadAuto("path/to/tile/15/12345/67890.terrain");

if (format == TerrainFormat.QuantizedMesh)
{
    var meshData = (QuantizedMeshData)data;
    Console.WriteLine($"Mesh: {meshData.VertexCount} vertices");
}
else if (format == TerrainFormat.Heightmap)
{
    var heightmapData = (HeightmapData)data;
    Console.WriteLine($"Grid: {heightmapData.GridSize}x{heightmapData.GridSize}");
}

Height queries

// By file path and normalized tile coordinates (0-1)
float height = TerrainReader.GetHeightAt("tile.terrain", u: 0.5, v: 0.5);

// By z/x/y tile coordinates and pixel position
float pixelHeight = TerrainReader.GetHeightAtPixel(
    terrainBasePath: @"C:\terrain",
    zoom: 13, tileX: 4096, tileY: 2048,
    pixelX: 128, pixelY: 128);           // pixel in a 256x256 tile

TerrainHeightQuery handles multi-tile queries against a tile directory: GetHeightsForBoundary returns a Matrix of elevations for a WGS84 bounding box at a zoom level (loading and merging all intersecting tiles), GetHeightsForBoundaryWithSize/GetHeightsForDisplay resample to exact output dimensions, and GetElevationProfile samples elevations along a list of lon/lat waypoints.

var (heights, actualBounds) = TerrainHeightQuery.GetHeightsForBoundary(
    boundingBox, zoomLevel: 6, terrainBasePath: @"C:\terrain");

Reading quantized-mesh tiles

var terrainData = QuantizedMeshReader.Read("path/to/tile/15/12345/67890.terrain");

Console.WriteLine($"Height range: {terrainData.Header.MinimumHeight}m to {terrainData.Header.MaximumHeight}m");
Console.WriteLine($"Vertices: {terrainData.VertexCount}, Triangles: {terrainData.TriangleCount}");

// Vertex data (dequantized on access)
for (int i = 0; i < terrainData.VertexCount; i++)
{
    double u = terrainData.GetNormalizedU(i);      // [0, 1]
    double v = terrainData.GetNormalizedV(i);      // [0, 1]
    double height = terrainData.GetHeight(i);      // meters
}

// Triangle indices
for (int i = 0; i < terrainData.Indices.Length; i += 3)
{
    uint v0 = terrainData.Indices[i];
    uint v1 = terrainData.Indices[i + 1];
    uint v2 = terrainData.Indices[i + 2];
}

Edge vertex indices (WestIndices, SouthIndices, EastIndices, NorthIndices) are exposed for tile stitching.

Reading heightmap tiles

var heightmapData = HeightmapReader.Read("tile.terrain");

Console.WriteLine($"Grid size: {heightmapData.GridSize}x{heightmapData.GridSize}");
Console.WriteLine($"Height range: {heightmapData.MinHeight}m to {heightmapData.MaxHeight}m");

float heightAt = heightmapData.GetHeight(row: 128, col: 128);
float interpolated = heightmapData.GetInterpolatedHeight(u: 0.5, v: 0.5);

Tile coordinates

var tileCoord = new TerrainTileCoordinate(level: 15, x: 12345, y: 67890);

string fileName = tileCoord.GetFileName();               // "15/12345/67890.terrain"
var (west, south, east, north) = tileCoord.GetBoundingBox(); // WGS84 degrees

var parent = tileCoord.GetParent();
var children = tileCoord.GetChildren();

var coord = TerrainTileCoordinate.FromPath("data/terrain/15/12345/67890.terrain");
var fromGeo = TerrainTileCoordinate.FromGeographic(longitude: 51.4, latitude: 35.7, zoom: 12);

To convert vertex positions to geographic coordinates, combine the tile's bounding box with the normalized vertex coordinates: longitude = west + u * (east - west), latitude = south + v * (north - south).

Quantized-mesh extensions

var terrainData = QuantizedMeshReader.Read("tile.terrain");

if (terrainData.Extensions != null)
{
    if (terrainData.Extensions.HasWaterMask)
    {
        byte[] waterMask = terrainData.Extensions.WaterMask;   // 0 = land, 255 = water
    }

    if (terrainData.Extensions.HasVertexNormals)
    {
        byte[] normals = terrainData.Extensions.VertexNormals; // oct-encoded, 2 bytes/vertex
        var (nx, ny, nz) = QuantizedMeshReader.DecodeOctNormal(normals[0], normals[1]);
    }

    if (terrainData.Extensions.HasMetadata)
    {
        string metadata = terrainData.Extensions.Metadata;     // JSON string
    }
}

Converting terrain to raster DEM

Both formats convert to RasterGeoTiff (see ../GeoTiff):

// Quantized-mesh -> raster (barycentric interpolation over the mesh)
var terrainData = QuantizedMeshReader.Read("15/12345/67890.terrain");
var tileCoord = new TerrainTileCoordinate(15, 12345, 67890);
var raster = QuantizedMeshRasterConverter.ToRasterGeoTiff(
    terrainData, tileCoord, outputWidth: 512, outputHeight: 512);

Console.WriteLine($"Bounds: {raster.GeodeticWgs84BoundingBox}");
double elevation = raster.Data[256, 256];

// Heightmap -> raster
var heightmapData = HeightmapReader.Read("tile.terrain");
var raster2 = HeightmapRasterConverter.ToRasterGeoTiff(heightmapData, new TerrainTileCoordinate(5, 39, 20));

Converting raster DEM to in-memory terrain data

The reverse converters build in-memory HeightmapData/QuantizedMeshData objects from a raster DEM. Because there is no .terrain writer, the result can be queried or converted back but cannot be saved as a terrain tile.

var raster = TiffReader.ReadGeoTiff32bitDEM("dem.tif");   // IRI.Maptor.Core.Spatial.IO

var heightmap = HeightmapRasterConverter.FromRasterGeoTiff(raster, targetGridSize: 257);
var resampled = HeightmapRasterConverter.Resample(heightmap, targetGridSize: 65);

var mesh = QuantizedMeshRasterConverter.FromRasterGeoTiff(raster, new TerrainTileCoordinate(15, 12345, 67890));

Quantized-mesh binary layout

Header (88 bytes)
Vertex count            uint32
U coordinates           zigzag-encoded deltas
V coordinates           zigzag-encoded deltas
Height values           zigzag-encoded deltas
Triangle count          uint32
Indices                 high-water-mark encoding
West/South/East/North   edge vertex indices (for tile stitching)
Extensions              optional (normals, water mask, metadata)

Header fields (all little-endian): tile center in ECEF (CenterX/Y/Z, 3 doubles), MinimumHeight/MaximumHeight (2 floats), bounding sphere center and radius (4 doubles), and horizon occlusion point (3 doubles).

U, V, and height values are quantized to 16-bit integers in [0, 32767]; actual = min + (quantized / 32767.0) * (max - min). Heightmap-1.0 files are a plain row-major grid of elevation samples; the grid size is detected from the file length.

Tile pyramid

Terrain tiles live in a level/x/y pyramid addressed as {level}/{x}/{y}.terrain, with each level doubling the resolution of the previous one. TerrainTileCoordinate implements the coordinate arithmetic: file naming, parent/children navigation, geographic bounds, and lookup by geographic position (see the tiling-scheme caveat under Limitations).

Format comparison

Feature heightmap-1.0 quantized-mesh-1.0
Structure Regular grid Triangle mesh
Sampling Uniform Adaptive
File size Fixed (grid-based) Variable
Height lookup Direct grid access Barycentric interpolation

Conversion methodology

  • Mesh → raster: for each output pixel, find the containing triangle, compute barycentric weights, and interpolate the height from the triangle's vertices.
  • Raster → mesh: each valid raster cell becomes a vertex; each grid cell is split into two triangles; coordinates and heights are quantized to 16-bit values; edge vertices are extracted for stitching and ECEF header values computed.
  • Raster → heightmap: bilinear resampling to a 2^n + 1 grid.

Limitations

  • Writing is not implemented for either format: there is no serializer that produces .terrain files, so FromRasterGeoTiff results exist in memory only.
  • QuantizedMeshRasterConverter.FromRasterGeoTiff accepts a simplificationTolerance parameter, but mesh simplification is not implemented — the full grid triangulation is always produced.
  • Heightmap grid sizes must be 2^n + 1 (65, 129, 257, 513, ...); other sizes are rejected.
  • TerrainTileCoordinate assumes a simplified geographic tiling with a single 2level x 2level grid per level — not the standard Cesium layout with two root tiles (2^(n+1) x 2n at level n). FromGeographic maps latitude over 180/2level degrees per tile while GetBoundingBox uses 360/2^level on both axes, so providers using the standard scheme need their own coordinate mapping.
  • Quantized-mesh water mask, vertex normals, and metadata extensions are parsed but not interpreted beyond raw access.
  • No tile caching or streaming; each query loads tiles from disk.

References


Back to IRI.Maptor.Core.Spatial