@bitruvius/geoid
v0.3.2
Published
Bitruvius SDK geoid: compact EGM96 undulation grid and lookup, converting WGS84 ellipsoidal heights to orthometric (MSL) so global datasets seat on terrain without a hand-tuned geoidN
Readme
@bitruvius/geoid
EGM96 geoid undulation, sampled per point, so heights from different sources land on the same ground.
Internal building block. This package exists so that
@bitruvius/sdk-maplibreand the Bitruvius codecs can resolve their dependencies on npm. It has no standalone product story. Unless you are deliberately building against it, install the SDK instead.
Why it exists
Most 3D datasets store WGS84 ellipsoidal height (h). Terrain and DEMs are published against mean
sea level, as orthometric height (H). The two differ by the geoid undulation N, where
h = H + N.
N is neither small nor constant. In the grid shipped here it runs from +84.6 m over New Guinea to
-107.0 m in the Indian Ocean south of India. The traditional fix was a hand-tuned geoidN constant:
correct for the one city it was measured in, then copied into the next project, where it sank the data
by tens of metres. A dataset that can be flown anywhere needs N looked up at the place it is
actually being drawn.
Three things pushed that lookup out of the SDK and into a package of its own.
It ships a data asset, not only code. The grid is a 508 KB binary. It is published as a subpath
export (@bitruvius/geoid/egm96-0.5deg.bin) so bundlers emit it as a fetchable asset and hosts can
serve it from their own CDN, rather than every consumer carrying it inside the main SDK bundle whether
or not a layer ever needs absolute vertical placement.
The grid has to be a singleton. Loading is module state. One in-flight fetch is shared by every
layer on the page, a failed attempt is remembered so a map with dozens of layers retries once rather
than once per layer, and setEgm96GeoidUrl() redirects the source once for everybody. Two vendored
copies of this logic would mean two fetches, two grids, and two answers to the same question.
It knows nothing about rendering or formats. No DOM, no WebGL, no MapLibre, no tile format, and no
dependencies at all: just arithmetic over an Int16Array. That is what lets the decode path (in a
worker) and the placement path (on the main thread) call the same function.
What is in it
Six functions and one grid file.
import { ensureEgm96Geoid, geoidHeight } from '@bitruvius/geoid';
await ensureEgm96Geoid(); // once per session, coalesced, never throws
const N = geoidHeight(40.75, -73.99); // about -32 m near New York
const orthometric = ellipsoidalHeight - N; // H = h - N| API | Behaviour |
| --- | --- |
| ensureEgm96Geoid(url?) | Resolves the grid shipped beside this module and loads it on demand. Idempotent, coalesces concurrent callers, and resolves false instead of rejecting, so a render loop degrades rather than breaks. |
| loadEgm96Geoid(url) | Explicit one-time fetch and install from a URL you supply. Throws on a bad response or a grid of the wrong shape. |
| setGeoidGrid(data) | Synchronous install from bytes you already hold: a bundler import, fs.readFile, or a prefetch. |
| setEgm96GeoidUrl(url) | Points the automatic loader at an explicit URL, for hosts whose bundler or CSP cannot resolve the built-in one. |
| geoidHeight(lat, lng) | The lookup. Synchronous, bilinear, longitude wrapping, latitude clamped at the poles. Returns 0 (ellipsoid treated as MSL) when no grid is installed. |
| isGeoidLoaded() | Whether a grid is currently installed. |
The grid is 720 x 361 at 0.5 degree spacing, int16 hundredths of a metre, row-major, built by
scripts/build-grid.mjs from the public-domain NGA EGM96 15-minute grid (the GeographicLib PGM
distribution). Downsampling costs at most about 0.18 m against that source, and bilinear interpolation
holds worldwide error near 0.3 m. Against a guessed constant, that is a rounding error.
How it fits
@bitruvius/sdk-maplibre is the consumer. Its vertical-datum resolver reads the vertical reference a
service declares (I3S heightModelInfo, 3D Tiles ESRI_crs.vcsWkid), and when a dataset's heights are
absolute it loads this grid, samples N at the dataset's anchor, and seats the ground at h - N. If
the grid cannot be fetched, the layer drapes onto terrain instead and warns that it did.
EGM96 specifically, because the DEM tiles a MapLibre map renders as terrain carry orthometric heights
referenced to mean sea level, and MapLibre draws those values as if they were heights above the
ellipsoid. Seating a dataset at h - N from this grid therefore lands it on the terrain as drawn. It
is also why an explicit geoidN override in the SDK is applied as a difference from this grid rather
than as an absolute shift: the layers resolve the dataset's own undulation first, so an override that
happens to be the conventional EGM96 constant for that city moves nothing instead of sinking the data
twice.
Should you install it directly
Usually not. Install the SDK and the grid loads itself, with no geoid vocabulary required of you.
Take the direct dependency in two cases:
- Your bundler or CSP cannot resolve the asset beside the module. Host the
.binyourself and callsetEgm96GeoidUrl(yourUrl)before you add layers. The SDK's drape warning names this call for exactly that reason. - You need the undulation outside the map: converting survey heights, auditing a dataset's vertical datum, or preparing data server-side.
npm i @bitruvius/geoidZero dependencies, ESM only, Node 18 or newer.
Trademarks
Esri, ArcGIS and I3S are trademarks of Environmental Systems Research Institute, Inc. Cesium and 3D Tiles are trademarks of Cesium GS, Inc. MapLibre is a trademark of the MapLibre organization. All other marks are the property of their respective owners.
These names are used solely to describe the data formats this software interoperates with. WebGL is a trademark of The Khronos Group Inc.
Bitruvius is not affiliated with, sponsored by, or endorsed by any of them, and no such relationship is implied.
License
Proprietary. The full terms ship as LICENSE inside this package, and are readable
before installing at cdn.bitruvius.com/legal/sdk-license-v1.txt.
© Bitruvius, Inc.
