# 3D Terrain Pipeline

> Turn drone footage into Terrain-RGB PMTiles and Gaussian-splat OGC 3D Tiles rendered by MapLibre.

## Overview

geolith compiles raw drone capture into web-streamable 3D terrain through two subcommands:

| Track | Command | Input | Output |
| --- | --- | --- | --- |
| Terrain-RGB | `geolith terrain` | GeoTIFF DEM | `raster-dem` PMTiles for MapLibre `setTerrain` + hillshade |
| Gaussian splats | `geolith splat-tiles` | Trained 3DGS PLY | OGC 3D Tiles 1.1 (`KHR_gaussian_splatting` + `KHR_gaussian_splatting_compression_spz_2`) |

## Capture Recipe (upstream of geolith)

```text
drone video (5–10 min 4K, orbit or lawnmower grid)
  → frame extraction @ 2–5 fps (~200–500 frames)
  → OpenDroneMap: --dsm --dtm --orthophoto
  → GeoTIFF DEM (+ orthophoto)
```

For splats, continue from the same frames:

```text
frames → COLMAP (`colmap mapper --global`)
  → gsplat / Nerfstudio `splatfacto` (Apache-2.0)
  → PLY splat export
```

## Track A — GeoTIFF DEM → Terrain-RGB PMTiles

```bash
# Drone survey (UTM GeoTIFF from OpenDroneMap / Pix4D)
geolith terrain \
  --dem ./data/belvedere_dsm.tif \
  --output ./data/belvedere-terrain.pmtiles \
  --min-zoom 8 --max-zoom 16 \
  --encoding mapbox \
  --name belvedere-glacier

# Regional demo from a public DEM (Copernicus GLO-30, EPSG:4326) —
# e.g. the Garhwal Himalaya (Kedarnath / Badrinath / Nanda Devi):
curl -LO https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N30_00_E079_00_DEM/Copernicus_DSM_COG_10_N30_00_E079_00_DEM.tif
geolith terrain \
  --dem Copernicus_DSM_COG_10_N30_00_E079_00_DEM.tif \
  --output ./data/garhwal-himalaya.pmtiles \
  --min-zoom 6 --max-zoom 12 \
  --name garhwal-himalaya
```

A live archive built exactly this way serves at
`https://samples.geolith.app/terrain/garhwal-himalaya.pmtiles` (257 tiles,
z6-z12, the 79-80°E / 30-31°N Copernicus tile).

| Flag | Default | Purpose |
| --- | --- | --- |
| `--dem` | required | Input GeoTIFF DEM (Float32/Int16, tiled or stripped, LZW/Deflate) |
| `--output` | required | Output PMTiles path |
| `--encoding` | `mapbox` | `mapbox` (Terrain-RGB) or `terrarium` |
| `--min-zoom` / `--max-zoom` | 0 / 14 | Zoom range |
| `--tile-size` | 512 | Tile edge in pixels |
| `--dem-crs` | auto | EPSG override when the GeoTIFF lacks geokeys |
| `--name` | `terrain` | Layer name in archive metadata |

The CRS is read from the GeoTIFF `GeoKeyDirectory`. WGS84 (4326), Web Mercator (3857), and UTM zones on WGS84/ETRS89/RDN2008/NAD83 datums resolve automatically; anything else needs `--dem-crs`.

Tiles with no DEM coverage are skipped, nodata pixels encode as sea level, and elevations bilinearly resample from the source raster.

### Rendering

```js
map.addSource('terrain', {
  type: 'raster-dem',
  url: 'pmtiles://https://samples.geolith.app/terrain/garhwal-himalaya.pmtiles',
  encoding: 'mapbox',
  tileSize: 512,
});
map.setTerrain({ source: 'terrain', exaggeration: 1.2 });
map.addLayer({ id: 'hills', type: 'hillshade', source: 'terrain' });
```

Serving works through tileserver-rs or any PMTiles host — the archive declares `tile_type` PNG with no tile compression, per the PMTiles v3 spec.

## Track B — 3DGS PLY → OGC 3D Tiles

```bash
geolith splat-tiles \
  --ply ./data/scene.ply \
  --output ./data/tileset \
  --longitude 7.9797 --latitude 45.9756 --altitude 2250 \
  --max-splats-per-tile 65536
```

| Flag | Default | Purpose |
| --- | --- | --- |
| `--ply` | required | Trained 3DGS PLY (INRIA / gsplat / splatfacto layout) |
| `--output` | required | Output directory (`tileset.json` + `tiles/*.glb` + `scene.spz`) |
| `--longitude` / `--latitude` | required | Geographic anchor of the scene |
| `--altitude` | 0 | Anchor height above the ellipsoid in metres |
| `--max-splats-per-tile` | 100000 | Octree leaf capacity |
| `--max-depth` | 6 | Octree depth limit |
| `--root-geometric-error` | 64 | Root tile geometric error |
| `--antialiased` | false | Mark splats trained with antialiasing |
| `--emit-scene-spz` | true | Also write a standalone `scene.spz` |
| `--crop-percentile` | 0 | Trim this % from both ends of every axis, removing the hallucinated far-field shell of inward-facing captures |
| `--scale` | 1 | Uniform scene scale factor (positions and splat footprints together) |

Pick assets that suit a map: outward-facing captures of a subject at true metric
scale render crisply from map viewpoints, while indoor inward-facing scans (the
MipNeRF-360 datasets) show their hallucinated shell from outside. For a bad
world scale, `--scale` normalises it; for a noisy far field, `--crop-percentile`
trims it.

The output shape matches the reference `3DGS-PLY-3DTiles-Converter`: an octree of GLB tiles with `refine: REPLACE`, each carrying a gzip-compressed SPZ stream under `KHR_gaussian_splatting_compression_spz_2`, georeferenced by an east-north-up ECEF `transform` on the root tile. Parents hold decimated previews of their subtree so coarse zooms stay light.

### Rendering

`tileset.json` + GLB tiles are static files — serve from tileserver-rs, R2, or any CDN. Render in MapLibre through `@geoql/v-maplibre`:

```vue
<!-- @geoql/v-maplibre/splat — single-file splat -->
<VLayerSplat url="https://samples.geolith.app/splat/cactus/scene.spz"
  :longitude="7.9797" :latitude="45.9756" :altitude="2250" :lod="true" />

<!-- @geoql/v-maplibre/3d-tiles — streamed tileset with LOD -->
<VLayer3DTiles url="https://samples.geolith.app/splat/cactus/tileset.json" :error-target="6" />
```

## Sample Data

The [Belvedere Glacier open dataset](https://zenodo.org/records/19265915) provides real multi-year UAV DSMs and orthophotos for Track A; any INRIA-format splat export exercises Track B.
