bhumi-sdk2.0
v2.3.0
Published
Bhumi 3D map engine for three.js - terrain, your own DEM and street maps, drawing, and in-browser terrain analysis (slope, aspect, hillshade, contour, line of sight, dome)
Maintainers
Readme
bhumi-sdk2.0
3D maps and terrain analysis for three.js. Stream any street map and any elevation (DEM) tiles onto a 3D terrain, draw points, lines and polygons on it, and run slope, aspect, hillshade, contour, line-of-sight, dome and viewshed analysis, all in the browser, with no backend.
- Your own data: any XYZ / TMS street map or satellite imagery, any DEM in Mapbox Terrain-RGB, Terrarium or Bhumi encoding
- 3D terrain with tile level of detail, vertical exaggeration and overzoom past the deepest level your server has
- Drawing: points, lines and polygons that follow the terrain, with live length / area, editing and GeoJSON in / out
- Coordinates bar: latitude, longitude and altitude under the cursor, camera heading, pitch and a scale bar
- Terrain analysis: slope, aspect, hillshade, elevation and contours as map layers, limited to a polygon if you like, with polygon statistics
- Visibility: line of sight, dome (sky coverage) and ground viewshed, with Earth curvature and refraction
- TypeScript types included; ES module and UMD / CommonJS builds
Contents
- Install
- Quick start
- Street maps and DEMs
- Drawing points, lines and polygons
- Coordinates bar and picking
- Terrain analysis
- Line of sight
- Dome and viewshed
- Accuracy
- Requirements and limitations
Install
npm install bhumi-sdk2.0 threethree 0.156 or newer is a peer dependency.
Quick start
import { Scene, PerspectiveCamera, WebGLRenderer, AmbientLight } from 'three';
import { MapControls } from 'three/examples/jsm/controls/MapControls.js';
import { createMapView, TileProvider, DEMProvider, MapView, CoordinatesBar, UnitsUtils } from 'bhumi-sdk2.0';
const scene = new Scene();
scene.add(new AmbientLight(0xffffff, 1));
const camera = new PerspectiveCamera(60, innerWidth / innerHeight, 1, 1e12);
const renderer = new WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const controls = new MapControls(camera, renderer.domElement);
controls.zoomToCursor = true; // scroll zooms towards the point under the cursor
// Street map and elevation tiles (AWS Terrain Tiles are public, Terrarium encoded)
const streets = new TileProvider({ url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', subdomains: [], maxNativeZoom: 19 });
const dem = new DEMProvider({ url: 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png', subdomains: [], encoding: 'terrarium', maxNativeZoom: 15 });
const map = createMapView({ mode: MapView.HEIGHT, imagery: streets, dem });
scene.add(map);
// Look at Kedarnath from 40 km away
const p = UnitsUtils.datumsToSpherical(30.7346, 79.0669);
controls.target.set(p.x, 0, -p.y);
camera.position.set(p.x, 40000, -p.y + 32000);
new CoordinatesBar({ view: map, camera, domElement: renderer.domElement, controls });
renderer.setAnimationLoop(() => {
controls.update();
renderer.render(scene, camera);
});World units are Web Mercator metres: UnitsUtils.datumsToSpherical(lat, lon) gives x (east) and y (north); place things at (x, height, -y).
Street maps and DEMs
TileProvider (street map / imagery)
new TileProvider({
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png',
subdomains: ['a', 'b', 'c', 'd'],
maxZoom: 20,
});
// Mapbox styles (your own access token)
new TileProvider({ url: `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}?access_token=${token}`, subdomains: [], maxNativeZoom: 22 });| Option | Default | Description |
| --- | --- | --- |
| url | required | Template with {z}, {x}, {y}; {-y} for the TMS row; {s} for a subdomain |
| subdomains | ['a', 'b', 'c'] | Values rotated into {s} (an array or a string such as 'abc'); only used when the URL has {s} |
| tms | false | Rows counted from the south; or (zoom) => boolean for servers that mix TMS and XYZ |
| minZoom / maxZoom | 0 / 20 | Levels the map subdivides between |
| maxNativeZoom | maxZoom | Deepest level the server has; deeper tiles are cut from it and enlarged |
| crossOrigin | 'anonymous' | null leaves it unset |
DEMProvider (elevation)
new DEMProvider({
url: 'https://dem.example.com/{z}/{x}/{y}.png',
encoding: 'terrarium',
maxNativeZoom: 14,
exaggeration: 1.5,
});Takes the same url, subdomains, tms, minZoom and crossOrigin options, plus:
| Option | Default | Description |
| --- | --- | --- |
| encoding | 'mapbox' | 'mapbox' (Terrain-RGB), 'terrarium', 'bhumi', or your own (r, g, b) => metres |
| maxNativeZoom | 15 | Deepest DEM level; deeper tiles are interpolated (bilinear) |
| exaggeration | 1 | Vertical scale of the 3D terrain (analyses always use true heights) |
| noData | none | Source value meaning "no data"; read as 0 m |
| mode | MapView.HEIGHT | Map mode it feeds; createMapView sets it for you |
A DEM tile that fails to load (outside coverage, 404) is drawn flat at 0 m, so the map keeps refining. dem.sampleElevations([{ latitude, longitude }]) reads true heights in metres at any position.
createMapView
createMapView({ mode, imagery, dem, lod, mapboxToken }): MapViewBuilds the map and matches the DEM's tile format to the mode. Without mapboxToken the engine's Mapbox fallback for failed imagery tiles is switched off.
| Mode | Use |
| --- | --- |
| MapView.PLANAR | Flat map (default without a DEM) |
| MapView.HEIGHT | 3D terrain (default with a DEM, recommended) |
| MapView.SPHERICAL | Globe |
| MapView.HEIGHT_SHADER, MapView.MARTINI | Not usable with three ≥ 0.152 (see limitations) |
Drawing points, lines and polygons
import { DrawTool } from 'bhumi-sdk2.0';
const draw = new DrawTool({ view: map, camera, domElement: renderer.domElement, scene, controls });
draw.start('polygon'); // 'point' | 'line' | 'polygon'; stays active until draw.stop()
draw.on('create', (feature) => {
// GeoJSON Feature with [longitude, latitude, height above sea level]
console.log(feature.properties.area_m2, feature.properties.perimeter_m);
});| Action | How | | --- | --- | | Add a vertex | Click (a drag pans the map as usual) | | Finish | Double-click or Enter; a polygon also closes when you click its first corner | | Undo last vertex | Backspace or Ctrl+Z | | Cancel / leave the tool | Esc / Esc again | | Edit | When not drawing: click a shape, drag its vertices, Delete removes it |
Lines keep their pixel width at every zoom, vertices are round antialiased markers, and shapes follow the terrain between vertices. While drawing, a dashed guide follows the cursor and a label shows the length or area.
| Member | Description |
| --- | --- |
| start(mode), stop(), finish(), undo() | Drawing control |
| select(id), getSelected(), remove(id), clear() | Shapes |
| toGeoJSON(), addGeoJSON(data) | Point, LineString, Polygon and Multi* in and out; lines carry length_m, polygons area_m2 and perimeter_m |
| setStyle({ color, selectedColor, width, fillOpacity, vertexSize, pointSize }) | Look |
| on(event, fn) | drawstart, drawend, create, update, delete, select |
| setView(map), dispose() | Lifecycle |
Options: style, measure (live label, default true), editable (default true), drape (follow the terrain, default true). Helpers distance, pathLength, ringArea, formatLength and formatArea are exported.
Coordinates bar and picking
import { CoordinatesBar, MapPicker } from 'bhumi-sdk2.0';
new CoordinatesBar({ view: map, camera, domElement: renderer.domElement, controls });
const picker = new MapPicker(map, camera, renderer.domElement);
picker.pickGeo(event.clientX, event.clientY); // { latitude, longitude, altitude } or nullThe bar shows latitude, longitude (click to switch decimal degrees / DMS), altitude above sea level under the cursor, camera altitude, heading, pitch, a scale bar and a copy button; C over the map copies lat, lon, altitude. Options: container, dms, copyKey (null turns it off), logo (image URL).
MapPicker also has pick (world position), toGeo, toWorld and heightAt. Altitudes are above sea level with the terrain exaggeration taken out.
Terrain analysis
Computed in the browser from a DEMProvider and drawn over your street map.
import { TerrainAnalysisProvider, createMapView } from 'bhumi-sdk2.0';
const analysis = new TerrainAnalysisProvider({ dem, base: streets, analysis: 'slope', opacity: 0.6 });
const map = createMapView({ imagery: analysis, dem });
analysis.legend(); // [{ color, label }] for a legend
analysis.setAnalysis('hillshade'); map.clear(); // switch later and redraw| analysis | Method | Shown as |
| --- | --- | --- |
| slope | Horn's 3 × 3 gradient | Classes 0–5, 5–15, 15–25, 25–35, 35–45, 45–60, 60–90° |
| aspect | Direction the slope faces (ESRI convention) | 8 compass colours; flat (< 2°) grey |
| hillshade | ESRI formula, sun azimuth 315°, altitude 45° | Shading multiplied over the imagery |
| elevation | Height above sea level | Colour ramp 0 → 5,000 m |
| contour | Marching squares | Lines; interval follows zoom and relief (5, 10, 50, 100, 500 or 1,000 m), every 5th thicker |
| Option | Default | Description |
| --- | --- | --- |
| dem | required | Elevation source |
| base | none | Street map drawn underneath |
| analysis | 'hillshade' | One of the above |
| opacity | 0.6 | Colour opacity / hillshade strength |
| contours | false | Contours on top of any other analysis |
| contourInterval | auto | Fixed interval in metres |
| azimuth, altitude | 315, 45 | Hillshade sun position |
| zFactor | 1 | Vertical exaggeration for slope / hillshade maths |
| smooth | 2 for Bhumi DEMs, else 0 | 3 × 3 smoothing passes, to hide the height steps of coarse DEMs |
| clip | null | Only analyse inside these polygons |
Inside a polygon
import { polygonTerrainStats } from 'bhumi-sdk2.0';
draw.on('create', async (feature) => {
if (feature.geometry.type !== 'Polygon') return;
// Show the analysis only inside the drawn polygons
const rings = draw.toGeoJSON().features.filter((f) => f.geometry.type === 'Polygon').map((f) => f.geometry.coordinates[0]);
analysis.setClip(rings); // [] = nowhere, null = whole map
map.clear();
// Terrain statistics of this polygon
const stats = await polygonTerrainStats(dem, feature.geometry.coordinates[0]);
});| polygonTerrainStats field | Meaning |
| --- | --- |
| area, surfaceArea | Map area and area along the terrain, m² |
| elevation.min, .max, .mean | Metres above sea level |
| slope.mean, .max | Degrees |
| slope.classes, aspect.classes | { label, color, percent } per class, same classes as the legend |
| samples, zoom | DEM samples used and the zoom they were read at |
Line of sight
import { LineOfSight } from 'bhumi-sdk2.0';
const los = new LineOfSight({ view: map, camera, domElement: renderer.domElement, scene, dem, observerHeight: 1.7, targetHeight: 0 });
los.start(); // click the observer, then the target
los.on('result', (r) => console.log(r.visible, r.clearance, r.obstruction));
// or without clicks
await los.analyze({ latitude: 30.73, longitude: 79.06 }, { latitude: 30.75, longitude: 79.10 });The line is drawn green up to the first obstruction and red after it. The result has visible, distance, observer, target, obstruction (position and distance), clearance (smallest gap to the terrain, negative when blocked) and the full profile for a chart. Options: observerHeight (1.7 m), targetHeight (0 m), curvature (true), refraction (0.13), visibleColor, blockedColor.
Dome and viewshed
import { DomeAnalysis, computeViewshed } from 'bhumi-sdk2.0';
const dome = new DomeAnalysis({ view: map, camera, domElement: renderer.domElement, scene, dem, radius: 3000, observerHeight: 10 });
dome.start(); // click a point
dome.on('result', (r) => console.log(r.visiblePercent, r.viewshed?.visiblePercent));
// Ground viewshed only, as data
const v = await computeViewshed(dem, { latitude: 30.73, longitude: 79.06 }, { radius: 3000, observerHeight: 10 });- Dome: every direction of a hemisphere (5° azimuth steps, 18 elevation bands) is tested out to
radius; open directions green, directions the terrain blocks red.visiblePercentis by solid angle;cellsholds each direction. - Viewshed: every ground cell inside the radius is tested from the observer and draped on the terrain, green seen and red hidden;
viewshed.visiblePercentis the share of ground that can be seen.computeViewshedreturns the grid (visibility,elevations,cellToLatLon).
Options: radius (2000 m), observerHeight (10 m), targetHeight (0 m), minElevation (0°, negative looks down), azimuthSteps, elevationSteps, curvature, refraction, viewshed (true), visibleColor, blockedColor, opacity, viewshedOpacity.
Accuracy
Each analysis was checked against an independent calculation or a case with a known answer:
| Check | Reference | Result | | --- | --- | --- | | Slope on a real Himalaya DEM tile | ESRI Horn formula in numpy, WGS84 geodesic pixel size | Max difference 0.025° | | Aspect, hillshade on the same tile | ESRI formulas in numpy | Identical; max 0.11 of 255 | | Contours | Height at every drawn point | Max error 0.0000 m over 18,659 segments | | Line of sight, 10 m to 10 m over sea level | Horizon formula √(2Rh₁) + √(2Rh₂) | 24.23 km vs 24.20 km | | Dome on flat ground | Ground-hit angle, solid angle | No direction wrong; 70.5 % vs 70.0 % | | Viewshed on flat ground / hilly terrain | Horizon distance / brute-force line of sight to every cell | 12.17 vs 12.10 km; 98.3 % of cells agree | | Polygon stats on a 20° plane | Geometry | Mean slope 20.000°, surface / map area = 1 / cos 20° | | Lengths and areas | WGS84 geodesic (pyproj) | +0.04 % and +0.11 % (spherical Earth model) |
Results can only be as good as the DEM: a ~30 m pixel averages summits, so peaks read a little low.
Using it without a bundler
dist/bhumi-sdk.umd.cjs works with require() and as a <script>: load three's global build first (it defines THREE); the SDK is then available as the global BhumiSDK.
Requirements and limitations
- A browser with WebGL. Tile servers must send CORS headers, because tiles become WebGL textures.
three≥ 0.156.MapView.HEIGHT_SHADERandMapView.MARTINIrender black with three ≥ 0.152 (their shader readsvUv, which three.js removed); useMapView.HEIGHT.- Keep the default
LODRaycaston flat (PLANAR) maps;LODFrustumdoes not refine them. - Shapes and analysis overlays are drawn on top of the terrain, so they stay visible behind hills.
- Lengths and areas use a spherical Earth (within about 0.1 % of WGS84).
License
MIT © 2026 HeliBhumi. See LICENSE.
The map engine is a modified version of geo-three (MIT); the package also bundles code from martini (ISC), three.js and global-mercator (MIT). Their notices are in THIRD_PARTY_NOTICES.md.
