Coordinate Systems
Defining a CRS
Everywhere a CRS is expected (--t-srs, --a-srs, terrio crs, the APIs), terrio accepts:
- EPSG codes:
EPSG:32613,32613,urn:ogc:def:crs:EPSG::32613,http://www.opengis.net/def/crs/EPSG/0/32613, including geographic 3D CRSs (EPSG:4979) and compound CRSs (EPSG:5498); - a horizontal and a vertical CRS, as GDAL builds them:
EPSG:4326+5773(WGS 84 with EGM96 heights); OGC:CRS84andWGS84;- ESRI codes, the geographic and projected CRSs of PROJ's ESRI authority:
ESRI:54009(World Mollweide),esri:102003,urn:ogc:def:crs:ESRI::102003. The few whose projection terrio does not compute (Goode homolosine of the oceans, Peirce quincuncial and others) are refused; - WKT1 and WKT2 (2015 and 2019) definitions, including ESRI
.prjWKT and WKT2BOUNDCRSs (a CRS with its transformation to WGS 84, as+towgs84); an unknown WKT2 projection method or parameter is an error rather than ignored; - PROJJSON v0.7 definitions in the supported object subset;
- PROJ strings:
+proj=utm +zone=13 +datum=WGS84, with+nadgrids=and+geoidgrids=for datum shift and geoid grids; - the path of a file holding one of these.
Of PROJ's 117 projections, terrio computes 89 as PROJ does (within 1 mm, on a sphere and on WGS 84,
as PROJ strings with typical parameters and as pyproj's WKT1 and WKT2 of them:
crates/terrio-crs/tests/proj_strings.rs) and refuses the 28 others (airy, bacon, goode,
vandg2 and others) rather than computing something else. The known difference is
Krovak off Bessel's ellipsoid, where PROJ's inverse is not the inverse of its forward.
Mollweide and Wagner IV/V use Terrio's own ports of PROJ's formulas, including
PROJ's finite Mollweide pole approximation. The orthographic inverse intersects
the viewing ray with the ellipsoid directly, including local scale and azimuth.
For Winkel Tripel, an omitted lat_1 means 0, as in PROJ's CRS model and GDAL;
PROJ's bare operation instead defaults to acos(2/pi) (about 50.46°). Supply
lat_1 explicitly when comparing these paths. See the
CRS benchmark regressions for
reference versions, tolerances and boundary differences.
Rotated-pole grids (regional climate models: CORDEX, COSMO-CLM) are read as PROJ strings
(+proj=ob_tran +o_proj=longlat with +o_lat_p, +o_lon_p and +lon_0), as the WKT2 derived
geographic CRSs PROJ and GDAL write (the netCDF CF, GRIB and PROJ ob_tran pole rotations) and as
CF rotated_latitude_longitude grid mappings. Their coordinates are in degrees, but they are not
geographic CRSs: is_geographic() is false, and is_rotated_pole() is true. The pole may
also be given as PROJ's other forms (see
General oblique transformation) in a PROJ string; other WKT2
deriving conversions are errors.
Coordinates are always in GIS order: x (easting, longitude) first, then y (northing, latitude), whatever the axis order the authority defines.
Standalone Rust crate
Applications that only need coordinate systems can depend on terrio-crs rather
than Terrio's raster/vector library:
[dependencies]
terrio-crs = "0.2"
The published crate works offline by
default. Enable wbprojection-fallback when Terrio's wider projection coverage
is required; the feature matrix below describes the supported configurations.
use terrio_crs::{SpatialRef, Transformer};
let source = SpatialRef::parse("EPSG:4326")?;
let target = SpatialRef::parse("EPSG:32613")?;
let transform = Transformer::new(&source, &target)?;
let (easting, northing) = transform.transform(-105.0, 40.0)?;
# Ok::<(), terrio_crs::Error>(())
| Configuration | Behavior |
|---|---|
| Default | EPSG/ESRI tables, WKT and PROJ strings, 40 native methods from the 117-method test corpus, datum operations, epochs, and local or registered grids; offline |
wbprojection-fallback |
46 additional projection methods and legacy WKT/identification fallback; enabled by Terrio |
grid-transport (native) |
An application supplies network::GridTransport with seekable readers; the engine retains lazy TIFF block decoding |
Terrio's remote feature |
Supplies Terrio's existing HTTP range reader, request controls and cache to the shared engine |
| WebAssembly | Register grid bytes with register_grid; the host handles network access |
The transformation implementation and embedded tables live in
crates/terrio-crs. terrio::crs retains its existing structs, public fields,
methods, terrio::Error results, and raster/vector conversion methods. Owned
SpatialRef values convert between the two crates with From/Into.
The standalone crate returns its own terrio_crs::Error.
Grid registration, search paths, network configuration and decoded-grid caches
are shared by both interfaces in a process. Setting a custom standalone transport
before using Terrio preserves that transport. Terrio installs its default HTTP
transport lazily on the first CRS construction, transformation, grid registration
or configuration call through terrio::crs; linking Terrio or calling only
reset_grid_cache() does not install it. Direct terrio_crs callers should
configure their own transport, or first call a Terrio CRS entry point such as
terrio::crs::set_network_enabled(None). Network access remains controlled
by the settings described below. Direct callers retrieve diagnostics with
terrio_crs::take_warnings(), while Terrio continues to use
terrio::warnings::take(). A custom transformer warning handler is also available.
The standalone default omits wbprojection; Terrio enables the fallback to retain
its existing coverage. Bundled tables use portable ruzstd decompression on all
targets. The standalone default has no C dependency: Zstandard TIFF grids are read only with the
libzstd feature, which uses the zstd C library and which Terrio does not enable. The PROJ CDN's
grids are Deflate-compressed (all 11 sampled, among them those this page and the tests name), so this
concerns grids recompressed with Zstandard.
The dependency and build measurements record both standalone
configurations and full Terrio before/after extraction. Extraction and validation are tracked in
#268. Capability additions are tracked separately in #269–#272.
Publication and release workflow
The initial terrio-crs 0.1.0 release was published from a verified checkout with
cargo publish -p terrio-crs --locked. Its crates.io trusted publisher is configured
for repository opengeos/terrio, workflow release.yml, environment crates-io.
Subsequent versions use the release workflow. Its bootstrap
check runs before registry authentication or uploads and fails with setup guidance
if the crate is unavailable. Already published versions are skipped.
The workflow publishes terrio-crs, then terrio, then terrio-cli using each
package's own version. Cargo waits for index visibility between publications; a
polling timeout requires checking registry state before attempting another upload.
See Cargo's publication behavior.
The standalone benchmark validation records the direct native/fallback results against both pinned upstream suites.
Canonical EPSG references use one parser in both standalone configurations.
Versioned URNs/URLs resolve the code separately from the authority version
(urn:ogc:def:crs:EPSG:6.6:4326 resolves EPSG:4326). The fallback retains its
legacy free-form authority extraction only after canonical parsing fails;
inputs such as EPSG:4326junk are refused by the native default and retain the
legacy acceptance with the fallback. Inputs beginning with + remain PROJ
strings in every configuration.
Identification
When a dataset stores WKT without an EPSG code, as shapefiles do, terrio identifies the code: an
EPSG CRS on the WKT's datum, known by its AUTHORITY or its name (official, alias, or ESRI's:
D_European_1950 is ED50, D_ETRS_1989 is ETRS89), with the same conversion and units. When
several codes match, it prefers the one named as the WKT's CRS, then (for a projected CRS) the one
with its axis order, then the base datum over its later realizations (NAD83 over NAD83(HARN) and
NAD83(2011)) unless the WKT names one, then the lowest code. CRSs that terrio's EPSG table lacks or
whose projection it cannot compare are looked up in wbprojection's registry, on the same datum,
with the last two preferences. This is what GDAL reports: the New York City boroughs shapefile is
EPSG:2263 in both, and ESRI's GCS_European_1950 is EPSG:4230.
A datum terrio does not know by name and without TOWGS84 (Unknown based on Bessel 1841
ellipsoid, as GDAL and PROJ write a PROJ string's +ellps=bessel) gets no EPSG code and no datum
shift, as in GDAL: the CRS is used as it is.
A CRS of PROJ's ESRI authority is reported under it, as GDAL reports it: a GeoTIFF GDAL writes in
ESRI:54009 is ESRI:54009 (from the ESRI code the WKT declares or, for GDAL's ESRI PE strings,
the ESRI CRS of the same name and definition), never an EPSG code.
GeoTIFFs with user-defined projected CRSs, common in NASA products, are read from their GeoKeys:
UTM zones become their EPSG codes, and other projections (Albers, Lambert conformal conic,
transverse Mercator, polar stereographic and more) become PROJ definitions. A user-defined
geographic CRS on an EPSG datum (GeogGeodeticDatumGeoKey) keeps that datum, as GDAL reads it: the
GeoKeys of PDAL's autzen_trim.las, on NAD83(HARN), give NAD83(HARN) / Oregon GIC Lambert (ft)
(EPSG:2994), not the same projection on NAD83, and a UTM zone on such a datum is that datum's.
The same GeoKeys are read in LAS files.
PROJ string interpretation changes
PROJ strings supplied as CRSs follow PROJ's CRS import rules. For polar
+proj=stere with +lat_0=90 or -90, an explicit +lat_ts selects the
hemisphere: negative selects south, and zero or positive selects north. For
example, +lat_0=-90 +lat_ts=90 now describes the north pole. This differs from
PROJ's bare stereographic operation, which takes the hemisphere from lat_0.
Use a negative lat_ts for a southern standard parallel. Terrio's CRS API does
not interpret these strings as raw projection operations.
For +proj=lcc +lat_1=X with neither lat_0 nor lat_2, CRS import supplies
zero for both omitted parameters. Earlier Terrio versions used X for both,
matching a bare projection operation instead. Supply +lat_0=X +lat_2=X
explicitly to retain that tangent-cone definition. Coordinates from previously
ambiguous strings can change substantially under these corrected CRS rules.
Transformations
terrio crs EPSG:4326 --to EPSG:32613 -105.5 39.5
echo "-105.5 39.5 1600" | terrio crs EPSG:4979 --to EPSG:6318+5703 # with heights
echo "135 -25 0" | terrio crs EPSG:7912 --to EPSG:7843 --s_coord_epoch 2010.5
Between two CRSs on different datums, terrio picks the datum transformation GDAL (PROJ) picks for
each point: the most accurate of those GDAL lists for the pair whose area of use holds the point;
when PROJ's database has no transformation between the two datums, a chain of two of the
transformations it registers (each possibly a concatenation of several) through intermediate CRSs
and datums, found and sorted as PROJ's proj_create_operations does (through an intermediate CRS
first, between the geographic and geocentric CRSs of the datums, then through an intermediate datum,
whatever its CRSs; without the transformations replaced by another it finds); when none applies, no
shift (PROJ's "ballpark" transformation, of 0 m accuracy between CRSs that differ only by their
prime meridian). NAD27 to ITRF92, for instance, goes through NAD83(2011) (PROJ's concatenation of
the NADCON5 grids), ITRF2008 and ITRF2014. NAD83 and WGS 84 are treated as equivalent, as GDAL's
default operations do. Bounds are transformed with densified edges, so curved edges in the target
CRS are covered. scripts/gen_epsg_points.py writes GDAL's results for every EPSG CRS, which
crates/terrio-crs/tests/epsg_accuracy.rs checks terrio against (see the
conformance report).
Transformer::operation_at(x, y, z) returns the transformations transform_3d runs for a point,
as PROJ's proj_trans_get_last_used_operation: their names, summed accuracy, grids and whether one
is a ballpark, or None between CRSs of one datum. Conversions (projections, units) are not
named, and geoid models are named without PROJ's EPSG index (WGS 84 to EGM96 height).
let t = terrio_crs::Transformer::new(&SpatialRef::parse("EPSG:27700")?, &SpatialRef::parse("EPSG:4326")?)?;
let op = t.operation_at(530000.0, 180000.0, 0.0)?; // "OSGB36 to WGS 84 (9)", 1 m, OSTN15
Datum shift grids
Many datum transformations interpolate shifts in a grid: NADCON and NADCON5 in the United States, NTv2 grids in Canada, Australia, Germany, Great Britain (OSTN15) and elsewhere, France's geocentric translations. terrio reads the grids PROJ uses, in pure Rust:
- PROJ's GeoTIFF grids (
.tif), as published on the PROJ CDN (PROJ-data), with their subgrids, offsets in arc-seconds or degrees, scale and offset metadata, and every layout PROJ reads (tiled or stripped, chunky or planar, deflate, LZW or ZSTD compressed); - NTv2 (
.gsb) files with their subgrids, and GTX (.gtx) geoid grids; - the old names of PROJ-data grids (
conusforus_noaa_conus.tif).
They are applied as PROJ applies them: bilinear interpolation (biquadratic for NADCON5 grids, as
their metadata asks), PROJ's iterated inverse, its handling of grid edges and of the
antimeridian, and hgridshift, vgridshift, gridshift, xyzgridshift and deformation steps.
A transformation that needs a grid is used only when the grid can be found, so that terrio gives GDAL's result for the same installed grids: with the grids, the transformations GDAL lists when they are available; without them, those it lists when they are not. When the point falls outside the grid, the next best transformation is used, as PROJ does. Grids are looked up, in order:
- in grids registered from memory (
terrio::crs::register_grid,registerGridin JavaScript, which has no file system); - in the directories of
TERRIO_GRID_DIR(a path list), thenPROJ_DATAandPROJ_LIB(so grids installed for PROJ, such as conda'sproj-data, are found), then PROJ's user directory (~/.local/share/proj, whereprojsyncwrites) and terrio's download cache; - when network access is on (
TERRIO_NETWORK=ON, orPROJ_NETWORK=ONwhen the first is not set), on the PROJ CDN (TERRIO_CDN_ENDPOINT, defaulthttps://cdn.proj.org): the grid is read in place with HTTP range requests, as PROJ reads it, fetching only its header and the blocks around the points transformed (four EGM2008 heights fetch 0.85 MB of the 80 MBus_nga_egm08_25.tif). WithTERRIO_GRID_DOWNLOAD=YES, it is downloaded whole into the cache instead (TERRIO_GRID_CACHE, default~/.cache/terrio/grids), where later runs find it.
The search directories and network access can also be set from code
(terrio::crs::set_grid_search_paths, set_network_enabled, and the same functions in Python),
as PROJ's proj_context_set_search_paths and proj_context_set_enable_network.
TERRIO_NETWORK=ON terrio crs EPSG:4267 --to EPSG:4269 -100 40 # reads the NADCON5 grid by range
TERRIO_GRID_DIR=~/grids terrio crs EPSG:27700 --to EPSG:4258 434422 233624 # OSTN15
Heights and vertical datums
Between CRSs that both have heights (geographic 3D CRSs such as EPSG:4979, and compound CRSs of
a horizontal and a vertical CRS such as EPSG:4326+5773 or EPSG:7405), heights go through the
transformation as in GDAL: geoid models (EGM96, EGM2008, GEOID18, OSGM15... the vgridshift
transformations from a geographic 3D CRS to a vertical CRS) turn heights into ellipsoidal heights
and back, a horizontal transformation first when the geoid model is on another datum (OSTN15
before OSGM15 for British National Grid with ODN heights), and 3D datum transformations change
ellipsoidal heights. Vertical units (US survey feet) and depths are converted. A vertical CRS
without geoid models of its own uses those of the vertical CRSs of its datum, as PROJ does:
NAVD88 height (ftUS) (EPSG:6360), NAVD88 height (ft) (EPSG:8228) and NAVD88 depth go
through NAVD88 height's GEOID18. When no geoid model applies, heights are kept, as PROJ's
ballpark vertical transformation does. Between 2D CRSs, heights are kept as GDAL keeps them (or
transformed, for the pipelines PROJ runs on heights).
With TransformOptions { promote_to_3d: true, .. } (Rust), the heights of 2D CRSs are ellipsoidal
heights that datum transformations change, as GDAL gives them after PromoteTo3D: EPSG:28992
(85000, 447000, 0) is 43.576 m above the WGS 84 ellipsoid in EPSG:4979, where it stays 0 by default. Vector layer
conversions pass existing Z values through the promoted transformer and preserve
2D input geometries as 2D, matching OGR geometry transformation.
Transformer::transform_3d in Rust, transform_points(xs, ys, src, dst, zs) in Python,
transformPoints(xs, ys, src, dst, zs) in JavaScript and x y z lines on terrio crs's
standard input transform heights.
Between compound CRSs on two vertical CRSs that the EPSG dataset connects, heights go through the
transformations PROJ lists between them, as PROJ prefers them to geoid models: vertical offsets,
offsets and slopes (vertoffset), VERTCON's grids from NGVD29 to NAVD88 and other grids, directly
or chained through an intermediate vertical CRS (DHHN92 to SNN76 through EVRF2000), in the CRSs'
axis directions (depths included) and units. For each point, the most accurate transformation
whose area holds it is used, PROJ's way: one interpolating in another datum's geographic CRS
counts the accuracy of the horizontal transformation to it (both ways), and is evaluated there.
Where none applies, heights are kept. Vertical CRSs of one datum (NAVD88 height in metres and in
US survey feet) only convert units.
TERRIO_NETWORK=ON terrio crs EPSG:4267+7968 --to EPSG:4267+5703 -105.27 40.01 1700 # VERTCON: 1701.0026
terrio crs EPSG:4258+5783 --to EPSG:4258+5785 13.4 52.5 100 # DHHN92 to SNN76: 99.856
These are listed in crates/terrio-crs/data/epsg_vertical.tsv.zst (scripts/gen_vertical_table.py)
and compared with PROJ by scripts/compare_vertical_with_proj.py: of 732 points over the 604
pairs, 727 have PROJ's heights to the micrometre; the other 5 (EVRF2000 Austria, where PROJ chains
the horizontal transformations of some concatenations in its own way) differ by 0.3 mm to 1 cm.
Dynamic datums and coordinate epochs
ITRF realizations, WGS 84's realizations, ETRF and other dynamic reference frames move with the
tectonic plates, and the transformations between them and to static datums (GDA2020, ETRS89,
NAD83(CSRS)) have rates of change: the result depends on the coordinates' epoch. As in GDAL, the
coordinate epoch is a property of the coordinates (-s_coord_epoch and -t_coord_epoch of
gdaltransform, TransformOptions in Rust, s_coord_epoch and t_coord_epoch in Python and
JavaScript, --s_coord_epoch and --t_coord_epoch on terrio crs):
- from a dynamic CRS to a static one, the source epoch is used;
- from a static CRS to a dynamic one, the target epoch;
- between two dynamic CRSs, the source epoch when both are given;
- between two epochs of one datum that has a point motion operation (the Canada velocity grids
v6, v7 and v8 of NAD83(CSRS), the NKG_RF17vel land uplift model of the Nordic ETRF2014
realization), the coordinates move with its velocity grid over the time between the epochs,
as PROJ moves them (
projinfo -s EPSG:8254@2010 -t EPSG:8254@2020): geographic 2D and 3D CRSs, not projected ones (which PROJ does not move); - otherwise the transformations' parameters at their reference epoch are used, as PROJ does without an epoch.
Transformer::transform_4d takes the time of each point instead (x y z t lines on
terrio crs). Time-dependent Helmert transformations (15 parameters) and velocity grids
(deformation) are supported.
# NAD83(CSRS)v7 coordinates of 2010 at epoch 2020 (Canada velocity grid v7, from the PROJ CDN)
echo "-75.7 45.4 100" | TERRIO_NETWORK=ON terrio crs EPSG:8254 --to EPSG:8254 --s_coord_epoch 2010 --t_coord_epoch 2020
On 1,100 points of NAD83(CSRS) v6 and v7 (2D and 3D, both directions) and NKG_RF17vel, the
coordinates are PROJ's to 4 nanometres. (GDAL 3.12's osr applies the forward motion when the
target epoch is the earlier one; PROJ's own pipeline, which terrio follows, moves them back.)
use terrio::crs::{SpatialRef, TransformOptions, Transformer};
let opts = TransformOptions { source_coordinate_epoch: Some(2010.0), ..Default::default() };
let t = Transformer::with_options(&SpatialRef::parse("EPSG:7912")?, &SpatialRef::parse("EPSG:7843")?, &opts)?;
let (lon, lat, h) = t.transform_3d(135.0, -25.0, 0.0)?;
How close to GDAL
With the same grids, terrio's results are GDAL's to the micrometre (scripts/compare_crs_grids_with_gdal.py
compares random points through every grid of scripts/proj_grids.txt, legacy NTv2 and GTX files,
geoid models, compound CRSs and epochs: 250 cases, 50,000 points, the largest difference 0.02 mm).
For every EPSG CRS, GDAL's reference points computed with those grids
(gen_epsg_points.py --grids, 50 points of each kind per CRS spread over its area of use,
checked by crates/terrio-crs/tests/epsg_accuracy_grids.rs) are matched within 1 cm forward and
inverse; from CRS to CRS, every point of 6,291 of the 6,295 pairs is within 1 cm and of 6,294
within 1 m: the other is a deprecated CRS whose own datum PROJ uses (#153).
The chains through intermediate datums are checked by scripts/compare_chains_with_proj.py, on
every pair of EPSG geographic 2D CRSs on two datums no transformation links directly whose areas of
use overlap (31,127 pairs, 5 points each), and on a projected CRS of each datum (1,204 pairs):
without grids, every point of every geographic pair is GDAL's within 1 cm (31,126 pairs within
1 mm), as are 1,202 projected pairs; with the grids of scripts/proj_grids.txt, 31,126 geographic
pairs and 1,200 projected ones are within 1 cm, and every geographic pair within 1 m. The others
go through transformations PROJ's database
registers between projected CRSs (IGN Astro 1960 / UTM zone 28N to Mauritania 1999 / UTM zone 28N,
S-JTSK / Krovak East North to S-JTSK/05 / Modified Krovak East North), which terrio does not
apply.
Attribution
EPSG definitions come from the EPSG Geodetic Parameter Dataset, through the
wbprojection crate and PROJ. The grid algorithms follow PROJ (MIT licence);
the grids themselves come from PROJ-data, under their producers' licences.
Geocentric Cartesian CRSs
The shared pure-Rust engine accepts geocentric Cartesian endpoints such as
EPSG:4978 (WGS 84 X/Y/Z), +proj=geocent, WKT1 GEOCCS, and WKT2
GEODCRS/GEODETICCRS with a three-axis Cartesian CS. Authority-free WKT is
supported. The bundled table adds 269 EPSG geocentric definitions from PROJ 9.8.1 /
GDAL 3.12.2; the update script reuses each datum's existing geographic operation
lists. This works in the standalone crate without optional features, in Terrio,
and in WebAssembly. These CRSs report is_geocentric(), not is_geographic().
Use all three ordinates. Cartesian X points toward longitude 0, latitude 0,
Y toward longitude 90, latitude 0, and Z toward the north pole. Linear units apply
to all three coordinates; PROJ +units, +to_meter, prime meridians and axis
options are supported. Geocentric PROJ axes must contain one east/west and one
north/south direction in the first two positions, followed by up/down. WKT2
requires three canonical geocentric X/Y/Z axes with consistent linear units;
other layouts are refused. Geographic inputs remain longitude, latitude,
ellipsoidal height in traditional GIS order. Geocentric conversions compose with projected
CRSs, datum shifts and existing coordinate-epoch options.
let src = terrio_crs::SpatialRef::from_epsg(4979)?;
let dst = terrio_crs::SpatialRef::from_epsg(4978)?;
let xyz = terrio_crs::Transformer::new(&src, &dst)?.transform_3d(147.0, -42.0, 100.0)?;
// Approximately (-3981230.646139, 2585441.411873, -4245670.749162) metres.
terrio crs EPSG:4979 --to EPSG:4978 --dim 3 147 -42 100
# Standard input also accepts XYZ or XYZ plus observation epoch, as before.
--dim 3 (or --dim 4 with epoch) groups positional ordinates and makes the same
operation available to the MCP crs tool, whose arguments are CLI arguments.
x, y, z = terrio.transform_points([147], [-42], "EPSG:4979", "EPSG:4978", zs=[100])
const [x, y, z] = terrio.transformPoints([147], [-42], "EPSG:4979", "EPSG:4978", [100]);
Vector reprojection involving a geocentric endpoint retains all three output ordinates, treating missing input Z as zero; measures remain unchanged. The 2D point API likewise uses input Z=0, but returns only X/Y. Use the 3D API to retain the result's Z; a two-coordinate raster or bounding box does not supply a 3D height model.
Native, CLI/MCP, Python and WASM regressions check all three ordinates against
the pinned rust-proj-capability PROJ 9.9.0 fixture and a frozen GDAL 3.12.2 /
PROJ 9.8.1 suite. Cartesian/projected coordinates and heights allow 1 mm;
geographic angles allow 1e-8 degrees. This is numerical agreement, not bit
identity. The inverse uses PROJ's normalized Bowring convention: at the ellipsoid
centre longitude is zero, latitude is +90 and height is minus the polar radius;
interior points can have no unique geographic inverse. Nonfinite coordinates,
invalid linear units/ellipsoids and out-of-range geographic latitude are errors.
One reference discrepancy is explicit: PROJ 9.8.1's CRS factory skips a declared
TOWGS84 on a custom geocentric BoundCRS in the tested Airy case. Terrio honors
that declared shift. Those two composition cases are compared with the declared
Helmert operation run as an explicit GDAL pipeline, recorded in
geocentric_reference.json, rather than certifying a lost datum transformation.
PROJJSON definitions
The shared parser accepts serialized PROJJSON v0.7
objects and files containing them. SpatialRef::from_projjson is also available
in both Rust crates. Python and JavaScript CRS arguments accept JSON strings;
serialize a language object with json.dumps or JSON.stringify.
| Object | Supported subset |
|---|---|
GeographicCRS, GeodeticCRS |
Two-dimensional ellipsoidal coordinates on a static geodetic reference frame or datum ensemble; longitude/latitude in GIS order |
ProjectedCRS |
A supported geographic base and WKT2 projection conversion, two Cartesian axes, including north-first and west/south orientations |
BoundCRS |
A geographic/projected source, a Greenwich WGS 84 geographic target, and a complete three- or seven-parameter Helmert transformation, identified by EPSG codes or standard names |
Geographic axes must span east and north; projected axes may use west and south.
Both axes need the same explicit unit. Standard unit strings include metre,
degree, radian, grad, unity, arc-second and parts per million.
Other units use a LinearUnit, AngularUnit or ScaleUnit object with a finite,
positive conversion_factor, such as the US survey foot in PROJ's exports.
Ellipsoid axis/radius quantities and prime-meridian longitude quantities are
converted using their declared units. Projection and Helmert parameters require
explicit units of the appropriate kind, using the shared WKT2 parameter
catalogue. Duplicate parameter identities and contradictory known names/IDs
are refused. Zero Helmert parameters retain the
conversion between different ellipsoids.
terrio crs OGC:CRS84 --to british-grid.projjson -1.5 52.5
import json
x, y = terrio.transform_points(lons, lats, "EPSG:4326", json.dumps(definition))
const [x, y] = terrio.transformPoints(lons, lats, "EPSG:4326", JSON.stringify(definition));
The supplied conversion parameters are retained even when the root object has
an authority ID. IDs identify component methods, parameters and datums; they do
not replace the complete definition with a registry lookup. The resulting CRS
stores WKT2 and does not assign a root EPSG/ESRI code merely from an ID. The
exported WKT omits root CRS identifiers so that reopening it also preserves
caller-supplied conversion parameters; component identifiers remain. Descriptive
usage fields (scope, area, bbox, usages, remarks) are accepted without
affecting coordinates.
This is a reader for the documented subset, rather than a general JSON Schema validator; Writing PROJJSON describes the writer. Unsupported semantic members and object types are errors: dynamic/derived/compound/vertical CRSs, Cartesian geocentric or 3D PROJJSON, polar axis meridians, grid/deformation operations and nested bound sources are not supported. Geocentric and 3D CRSs remain available through EPSG, PROJ and WKT definitions. This prevents silent loss of datum, axis, unit or operation information.
Writing PROJJSON
SpatialRef::to_projjson writes PROJJSON v0.7 for geographic 2D and 3D, geocentric, projected,
vertical, compound and bound (+towgs84) CRSs: terrio crs CRS --projjson (and MCP), Python
rio.CRS.to_projjson() (and to_dict(projjson=True)), JavaScript crsToProjjson(crs). PROJJSON
is read wherever a CRS is (above). The projjson-export suite of scripts/conformance.py
compares it with live PROJ's PROJJSON of 52 CRSs by meaning (identifiers, datums, ellipsoids,
prime meridians, axes, methods and parameters by EPSG code) and has GDAL transform points with
them and with the originals, through each CRS's geographic CRS (a vertical CRS paired with
WGS 84; no grids), and does the same for every IAU_2015 CRS. Planetary CRSs are written as PROJ writes them: west-positive longitudes as west
axes, planetocentric latitudes (+geoc, a spherical coordinate system) as a GeodeticCRS with a
spherical coordinate system, IAU_2015 CRSs with their IAU/2015 identifier. Differences from
PROJ's output:
- no
scope,area,bboxorusages; - the WGS 84 and ETRS89 ensembles are written as their datum, without members (so GDAL's
IsSamewith the EPSG CRS is false; coordinates are the same); - parameters are in degrees and metres, whatever the CRS's units (EPSG:27572's in grads);
- projection conversions are named
unknown, except UTM zones (UTM zone 32N, EPSG:16032); - polar CSs have east and north axes rather than axes along meridians;
- static datums have no
anchor_epoch.
Refused rather than written wrongly: dynamic CRSs (their frame epochs; by EPSG code or a WKT 2
DYNAMIC node), projections without an
EPSG method (+proj=robin), engineering CRSs, projections on an auxiliary sphere of an
ellipsoid (+R_C, +R_A; 19 IAU_2015 CRSs), planetocentric latitudes with heights, a
+towgs84 of other than 3 or 7 values, a prime meridian it cannot read (+pm=foo), non-finite
values, a Krovak projection with another +alpha than its default (PROJ ignores its +lat_1 and
+lat_ts, so they are not written), and any parameter of the PROJ definition the PROJJSON would
not carry (named in the error), unless it has the value used when it is absent (+lat_0=0,
+k=1...). A CRS made from an EPSG code is written unbound (its datum shift is EPSG's); a
+towgs84 or TOWGS84 the definition gives makes a BoundCRS, to WGS 84's geographic 2D or 3D
CRS, or to its geocentric CRS for a geocentric CRS, as PROJ writes them.
IAU planetary coordinate systems
The shared pure-Rust engine includes the IAU_2015 registry from PROJ 9.8.1,
with source Git blob 900564ccf2e1f77fb708ddc2c8544843b29bc24a. Its 2,201 definitions
comprise 115 geographic CRSs (including spheres), 12 ellipsoidal planetocentric
geographic CRSs and 2,074 projected CRSs. The source is PROJ's
data/sql/iau.sql, based
on the IAU Working Group's 2015 report.
The embedded table is about 50 KB compressed; GDAL is used for validation and is
not a runtime dependency. See NOTICE and scripts/gen_iau_table.py.
Supported references include:
IAU_2015:49900orIAU:49900(the latter defaults to this pinned registry);IAU:2015:49900;urn:ogc:def:crs:IAU:2015:49900;urn:ogc:def:crs:IAU_2015::49900;https://www.opengis.net/def/crs/IAU/2015/49900.
Unknown codes and other registry years are errors; IAU:0:49900 is not a
latest-version alias. Both Rust crates expose SpatialRef::from_iau(code). Authority
reporting uses ("IAU_2015", code) and does not assign an EPSG code. Python
rio.CRS.to_authority() and JavaScript describeCrs(...).authority retain this
versioned identity. Stored WKT2 preserves the IAU version and the geographic
coordinate-system type. Python rio.CRS.to_wkt() returns this stored WKT when
called without an explicit format-conversion request.
terrio crs IAU_2015:49900 --to IAU_2015:49910 137.4 -4.6
x, y = terrio.transform_points([137.4], [-4.6], "IAU_2015:49900", "IAU_2015:49910")
const [x, y] = terrio.transformPoints([137.4], [-4.6], "IAU_2015:49900", "IAU_2015:49910");
Coordinates follow GDAL's traditional GIS order, including its planetary axis
conventions. For example, Mars 49900 uses east-positive longitude then
latitude. Mars 49901 uses latitude then west-positive longitude; its
projected 49911 uses westing then northing. Planetocentric 49902 uses
longitude then planetocentric latitude. The engine converts between
planetocentric and ellipsoidal geodetic latitude, so these two latitude types
are not interchangeable on an ellipsoid. Spherical and ellipsoidal projected
coordinates retain their declared units, offsets and axes. Mercator (Spherical)
on an ellipsoid uses the conformal-sphere radius (+R_C). PROJ 9.8.1
initializes this radius before reading lat_0, so it remains the equatorial
radius even when that latitude parameter is nonzero; Terrio follows this
reference behavior. Geographic and geocentric +R_C endpoints also use this
auxiliary sphere for runtime conversion. Their WKT retains the original
ellipsoid and a PROJ4 extension preserves the radius modifier on reopening.
CRSs on different known celestial bodies cannot be transformed into each other.
The PROJ-compatible Earth-radius heuristic treats an undeclared figure as Earth
when its semi-major axis is within 0.7% of 6,375,000 m. This is a radius
heuristic, not proof of body identity: use an explicit IAU body identifier for a
non-Earth figure, even if it has an Earth-like radius. Explicit body identities
take precedence over the heuristic. When a body identity remains unknown, semi-major axes must agree within 0.7% of the target's
axis. This is a compatibility change: custom CRS pairs with different small or
planetary radii that previously passed through as an unshifted datum now fail
when their axes exceed that allowance (for example, unit spheres of radii 1 and
2). Use IAU authority references to retain explicit non-Earth body identities. Earth datum operations
are rejected for planetary coordinates. There is no implicit Earth-frame bridge
and no PROJ_IGNORE_CELESTIAL_BODY bypass. Planetary datum shifts and time-varying
body orientation models are outside this registry's static 2D scope.
Validation compares all 2,074 projected CRS pairs (4,148 points) against frozen
and live GDAL 3.12.2 / PROJ 9.8.1, with component allowances of 1 mm projected
and 1e-8 degrees inverse, plus separate unit/offset cases. The original Mars
benchmark retains its distinct frozen PROJ 9.9.0 oracle. Nonzero differences are
reported as within tolerance. Projection domain and numerical limits still
apply: the reference itself refuses five inverse samples and has a worst
3.8343-degree round-trip residual for the highly flattened Eros transverse
Mercator definition IAU_2015:200043361. Agreement with these reference outputs
carries those limits; it is not a guarantee of accurate inverse recovery for
arbitrary extreme flattening. Reference refusals are recorded separately, and
native inverse refusal or accurate recovery of the original point is accepted.
The PROJJSON reader's existing object subset remains unchanged; use authority
strings or WKT2 for spherical-CS planetocentric definitions.
IAU datum identities are distinct from EPSG datums. Conversions within one registered
IAU datum (including planetographic/planetocentric latitude conversions) remain
available with ALLOW_BALLPARK=NO. Changes between different IAU datums, or from a
registered datum to an unidentified custom planetary datum, require the default
ballpark policy; strict mode rejects them. Editing a registered WKT datum does not
retain the original datum identity merely because the outer CRS identifier remains.
IAU authority reporting checks the stored definition against the registered CRS. Editing parameters while retaining a root IAU identifier does not preserve that registry identity or make the edited CRS equivalent to the original.
Perspective projections
+proj=nsper (near-sided perspective) and +proj=tpers (tilted perspective)
use the native pure-Rust engine. Supply a positive viewpoint height +h in
metres and the view centre +lat_0 / +lon_0. Tilted perspective additionally
accepts +tilt and +azi in degrees, both zero by default. The shared +units,
+to_meter, +x_0 and +y_0 options apply; height and false offsets remain in
metres even when the output is in kilometres or feet.
These are PROJ's spherical perspective formulas. With an ellipsoid such as WGS 84, its semi-major axis supplies the sphere radius, matching PROJ 9.8.1; this does not implement an ellipsoidal 3D camera model. All four viewing aspects (north/south pole, equatorial and oblique) are supported. Coordinates beyond the visible horizon and inverse coordinates outside the projection domain raise a coordinate error. The CLI's streaming input reports each failed point and continues; Python and JavaScript coordinate arrays raise if any point fails.
PROJ strings and their WKT1 PROJ4 extensions retain the parameters. WKT2
PROJ tpers and PROJ's Vertical Perspective conversion are accepted; the latter
requires zero topocentric-origin height. Nonzero topocentric-origin height is
refused explicitly. This restriction preserves the supported spherical model
instead of ignoring the extra parameter.
The original seed-tpers and edge-tpers-back fixtures retain their separate
PROJ 9.9.0 oracle. The additional GDAL 3.12.2 / PROJ 9.8.1 corpus covers 20
configurations and 2,500 forward samples, both sides of the horizon, inverse
domain boundaries, metres/kilometres/US survey feet, offsets and non-default
origins/tilt/azimuth. Component allowances are 1 mm projected and 1e-8 degrees
inverse, with longitude weighted by cos(latitude) at the poles. Other projection
families in #272 remain tracked there.
Interrupted Goode homolosine
+proj=igh is computed as PROJ 9.8.1 does (igh.cpp): twelve zones, spherical
sinusoidal between the equator and 40 degrees 44' 11.8" and Mollweide poleward of it, each
with its own central meridian, the Mollweide zones shifted north or south to meet
the sinusoidal ones. It is a spherical projection: with an ellipsoid, the
semi-major axis is the radius, as in PROJ. +lon_0, +units, +x_0 and +y_0
apply. The inverse refuses points in the interruptions, beyond the poles' northings
and past 180 degrees from the central meridian, as PROJ's does, including PROJ's southern
bound, which also refuses the south pole's own northing.
GDAL's WKT1 Interrupted_Goode_Homolosine, ESRI's Goode_Homolosine with
Option 1, and WKT2 Interrupted Goode Homolosine are read, and terrio writes
the first, as GDAL does. ESRI's Goode_Homolosine without Option 1 and WKT2
Goode Homolosine are PROJ's uninterrupted goode (or, with Option 2,
igh_o), which terrio refuses; WKT2 Goode Homolosine was previously read as
igh. PROJJSON export refuses igh, as for other projections without an EPSG
method.
The seed-igh fixture keeps its PROJ 9.9.0 oracle. A PROJ 9.8.1 corpus covers
8 configurations and 5,120 samples (see Testing). Allowances are 1 mm projected
and 1e-8 degrees inverse.
General oblique transformation
+proj=ob_tran with an +o_proj other than longlat is computed as PROJ 9.8.1 does
(ob_tran.cpp): the longitude, relative to +lon_0, and latitude are rotated to a new pole,
then projected by +o_proj's own formulas, with its own parameters (+lat_ts, +lat_1,
+zone...) and the CRS's figure, which may be an ellipsoid. As in PROJ, the rotated projection's
own central meridian and false easting and northing do not apply: +lon_0, +units, +x_0 and
+y_0 apply to the whole. The new pole is +o_lat_p and +o_lon_p; or a centre +o_lon_c,
+o_lat_c and azimuth +o_alpha; or two points of the new equator +o_lon_1, +o_lat_1,
+o_lon_2 and +o_lat_2, refused where PROJ refuses them. A pole latitude within 1e-10
radians of 0 is PROJ's transverse case. These forms apply to rotated-pole grids
(+o_proj=longlat) too.
PROJ strings, GDAL's WKT1 (which carries the PROJ string) and PROJ's WKT2 method
PROJ ob_tran o_proj=... are read; terrio writes the WKT1 form. PROJJSON export refuses
ob_tran. Where a projection's own refusals are stricter than PROJ's, they apply rotated too:
PROJ's spherical sinusoidal inverse returns a latitude past a pole, which its rotation turns into
a point, and terrio's refuses it; terrio's Mercator refuses the poles. Where terrio is more
lenient, that applies rotated too: its utm accepts a sphere, which PROJ refuses.
The seed-ob_tran-moll fixture keeps its PROJ 9.9.0 oracle. A PROJ 9.8.1 corpus covers
15 configurations and 4,680 samples (see Testing). Allowances are 1 mm projected and 1e-8
degrees inverse.
Quadrilateralized spherical cube
+proj=qsc is computed as PROJ 9.8.1 does (qsc.cpp): one face of a cube, chosen by the
projection centre (+lat_0 of at least 67.5 degrees north or south for the top or bottom face,
otherwise +lon_0 within 45 degrees of 0, 90, 180 or -90 for the front, right, back or left
face). PROJ's face centres are those values exactly; for other centres, +lon_0 stays the
central meridian. On an ellipsoid, geodetic latitudes are converted to
geocentric latitudes on a sphere of the semi-major axis, as in PROJ. The whole globe projects
onto the plane of the face, with no refusals; +units, +x_0 and +y_0 apply. The inverse at
the point opposite the face centre is sensitive to rounding: a change of 1e-15 in the northing
moves its latitude by about 1e-6 degrees, in PROJ as in terrio, and so may differ between
platforms.
GDAL's and ESRI's WKT1 Quadrilateralized_Spherical_Cube and WKT2 Quadrilateralized Spherical
Cube are read, and terrio writes the WKT1 form, as GDAL does. PROJJSON export refuses qsc, as
for other projections without an EPSG method.
The method-qsc fixture keeps its PROJ 9.9.0 oracle. A PROJ 9.8.1 corpus covers 16
configurations and 4,288 samples (see Testing). Allowances are 1 mm projected and 1e-8 degrees
inverse. At the point opposite the face centre the inverse is allowed 1e-5 degrees, and must
project back within 1 mm.
HEALPix and rHEALPix
+proj=healpix and +proj=rhealpix are computed as PROJ 9.8.1 does (healpix.cpp): an
equal-area cylindrical projection between the latitudes of asin(2/3) and Collignon projections
in the four polar caps on each side, which healpix leaves as triangles and rhealpix combines
into one square at each pole. +rot_xy rotates the healpix image, in degrees; +north_square
and +south_square (0 to 3, the default 0) pick the equatorial square below which rhealpix
places each polar square, and other values are refused, as in PROJ. On an ellipsoid the
latitude is the authalic latitude and the image is scaled by the authalic radius, as in PROJ
9.8.1 (PROJ 9.4 differs by 0.18 m on WGS 84 at the south pole). +lon_0, +units,
+x_0 and +y_0 apply. The inverse refuses points outside the image, as PROJ's does. As in
PROJ, whose point-in-polygon test leaves out the polygon's closing edge, the rhealpix inverse
accepts points left of the image between the polar squares, such as (-4, 0) on the unit sphere.
GDAL's WKT1 (custom_proj4, which carries the PROJ string), ESRI's PROJ_healpix and
PROJ_rhealpix, and PROJ's WKT2 methods PROJ healpix and PROJ rhealpix are read; terrio
writes the WKT1 form, as GDAL does. PROJJSON with these methods is not read, and PROJJSON
export refuses them, as for other projections without an EPSG method.
On the edge of the image (the poles, the antimeridian of the central meridian and the edges of
the polar caps) the last bit of a projected coordinate moves a point in or out of the image.
There a US survey foot in the PROJ string's +units can decide it: PROJ 9.8.1 takes that unit
in these methods' own +units as 0.304800609601219 m, and terrio, as PROJ's unitconvert for
other methods, as 1200/3937 m.
The method-healpix and method-rhealpix fixtures keep their PROJ 9.9.0 oracles. A PROJ
9.8.1 corpus covers 22 configurations and 6,600 samples (see Testing). Allowances are 1 mm
projected and 1e-8 degrees inverse.
Icosahedral Snyder Equal Area
+proj=isea is computed as PROJ 9.8.1 does (isea.cpp): Snyder's equal-area projection of the
sphere onto the 20 faces of an icosahedron, unfolded in the plane. +orient=isea (the default)
places the poles on edge midpoints and makes the equator symmetric; +orient=pole places a
vertex at each pole. As in PROJ, +lon_0 and +lat_0 set the icosahedron's orientation, and
+lon_0 is also the central meridian; +azi rotates it. +units, +x_0 and +y_0 apply.
+mode=plane is the default; +mode=di, dd and hex, which give grid addresses rather than
coordinates, are refused. Invalid +orient, +mode, +resolution and +aperture values are
refused, as in PROJ.
As in PROJ 9.8.1, the inverse is defined only for the default grid (+resolution=4,
+aperture=3, no +azi) whose origin, after any +lon_0 and +lat_0, is exactly the default
orientation's (which +lon_0=11.25 keeps) or the pole orientation's (which
+lat_0=90 +lon_0=0 gives), and refuses points outside the unfolded icosahedron; elsewhere it
is refused. On an ellipsoid the forward takes the geodetic latitude on a sphere of the
semi-major axis, while the inverse uses the authalic radius without the authalic latitude, so
the inverse does not return the projected point: on WGS 84, (147, -42) projects and unprojects
to (147.166, -41.969), in PROJ as in terrio.
GDAL's WKT1 (custom_proj4, which carries the PROJ string), ESRI's PROJ_isea (with
PROJ_isea_orient=pole and other options in the method's name) and PROJ's WKT2 method
PROJ isea (PROJ isea orient=pole) are read; terrio writes the WKT1 form, as GDAL does.
PROJJSON export refuses isea, as for other projections without an EPSG method.
Other PROJ versions differ: PROJ 9.4 has no inverse and a forward up to 2 mm different,
and PROJ 9.9 replaced the method with a new implementation (another default origin, the
authalic latitude on an ellipsoid, other parameters). The method-isea fixture, from PROJ
9.9.0, is therefore not used. A PROJ 9.8.1 corpus covers 14 configurations and 7,784 samples
(see Testing). Allowances are 1 mm projected and 1e-8 degrees inverse.