Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3D Geographic Search (ECEF)

Laurus offers two geographic field types that target different problems:

Field typeBacking structureCoordinate systemUse it when …
Geo2D BKD-TreeWGS84 latitude / longitude (degrees)All your data lives at the surface and altitude does not matter.
Geo3d3D BKD-TreeECEF Cartesian (x, y, z) (metres)Altitude is a first-class dimension — drones, satellites, indoor positioning, multi-floor buildings.

Both share the same BKD-Tree primitive; Geo3d simply adds a third dimension and a different query vocabulary.

Why ECEF?

(latitude, longitude, altitude) triples are convenient for humans but make Euclidean distance unusable: a degree of longitude is ~111 km at the equator and 0 km at the poles, and “altitude” is curved with the Earth’s surface.

ECEF (Earth-Centered Earth-Fixed) flattens this into a Cartesian frame:

  • The origin is the Earth’s centre of mass.
  • The +X axis points through the equator at 0° longitude.
  • The +Y axis points through the equator at 90° E longitude.
  • The +Z axis points through the geographic North Pole.
  • All three axes are in metres.

In this frame the straight-line distance between two points is just the Euclidean norm — no spherical trigonometry, no pole singularity, no longitude-wrap. That property is what makes Geo3d queries usable from a 3D BKD-Tree without bespoke spatial code.

Defining a Geo3d Field

#![allow(unused)]
fn main() {
use laurus::Schema;
use laurus::lexical::core::field::Geo3dOption;

let schema = Schema::builder()
    .add_geo3d_field("position", Geo3dOption::default())
    .build();
}

Geo3dOption exposes the same indexed / stored switches as the other lexical field options. Indexed values populate the field’s 3D BKD-Tree; stored values are returned by get_documents.

The TOML form (used by laurus-cli):

[fields.position.Geo3d]
indexed = true
stored = true

Indexing 3D Points

Use DocumentBuilder::add_geo_ecef to add a point in raw Cartesian metres:

#![allow(unused)]
fn main() {
use laurus::Document;

// A point at lat=35.6586°, lon=139.7454°, height=250 m
// (already converted to ECEF — see "Coordinate Conversion" below).
let doc = Document::builder()
    .add_geo_ecef("position", -3_955_182.0, 3_350_553.0, 3_700_276.0)
    .build();

engine.put_document("tokyo-tower", doc).await?;
engine.commit().await?;
}

If the value already exists as a GeoEcefPoint, build it through the unified DataValue API instead:

#![allow(unused)]
fn main() {
use laurus::{DataValue, GeoEcefPoint};

let p = GeoEcefPoint::new(-3_955_182.0, 3_350_553.0, 3_700_276.0);
let doc = Document::builder()
    .add_field("position", DataValue::GeoEcef(p))
    .build();
}

Coordinate Conversion (WGS84 ↔ ECEF)

Most input arrives as (latitude°, longitude°, height_m). Laurus exposes the canonical pair of conversions in laurus::util::ecef:

#![allow(unused)]
fn main() {
use laurus::util::ecef::{wgs84_to_ecef, ecef_to_wgs84};

// Forward: lat/lon/height → ECEF
let p = wgs84_to_ecef(35.6586, 139.7454, 250.0);

// Reverse: ECEF → lat/lon/height
let (lat, lon, height) = ecef_to_wgs84(&p);
}
FunctionDirectionAlgorithm
wgs84_to_ecef(lat°, lon°, h_m)geographic → CartesianClosed-form prime-vertical formula.
ecef_to_wgs84(&GeoEcefPoint)Cartesian → geographicBowring 1985 closed-form seed + 3 Newton-Raphson iterations.

The reverse conversion round-trips with sub-µm precision on every axis from sub-surface up to far beyond LEO altitudes. Near the poles the implementation switches from the standard h = p / cos(lat) - N formula (which diverges at cos(lat) → 0) to the meridian form h = z / sin(lat) - N(1 - e²).

The WGS84 ellipsoid constants used internally are also exposed as pub consts (WGS84_A, WGS84_F, WGS84_B, WGS84_E2, WGS84_E_PRIME_SQ) so external code can reference the exact values laurus uses.

Query Types

Three query types target Geo3d fields. All three are IntersectVisitor implementations on top of the shared 3D BKD-Tree.

Sphere (radius) — Geo3dDistanceQuery

Find every document whose stored point lies within radius_m metres of a centre point. Score is 1 - distance / radius, clamped to [0, 1].

#![allow(unused)]
fn main() {
use laurus::lexical::query::geo3d::Geo3dDistanceQuery;
use laurus::GeoEcefPoint;

let centre = GeoEcefPoint::new(-3_955_182.0, 3_350_553.0, 3_700_276.0);
let query = Geo3dDistanceQuery::new("position", centre, 5_000.0); // 5 km radius
}

The visitor uses AABB::min_distance_sq_to_point for the Outside test and AABB::max_distance_sq_to_point for the Inside test, so both cell-vs-sphere checks run in squared-distance space and avoid sqrt.

Bounding Box — Geo3dBoundingBoxQuery

Find every document whose stored point lies inside an axis-aligned 3D box defined by (min_x, min_y, min_z) and (max_x, max_y, max_z).

#![allow(unused)]
fn main() {
use laurus::lexical::query::geo3d::Geo3dBoundingBoxQuery;
use laurus::GeoEcefPoint;

let min = GeoEcefPoint::new(-4_000_000.0, 3_300_000.0, 3_650_000.0);
let max = GeoEcefPoint::new(-3_900_000.0, 3_400_000.0, 3_750_000.0);
let query = Geo3dBoundingBoxQuery::new("position", min, max)?;
}

The constructor returns Result because every min[i] must be ≤ max[i]; mismatched bounds are rejected at construction time rather than producing silently-empty results.

Caveat. The query box is axis-aligned in ECEF Cartesian space. A box built from two (lat, lon, height) corners by converting each corner separately is not the same shape as the spherical-shell region a user might intuitively expect. For region queries on the Earth’s surface, Geo3dDistanceQuery (a true 3D sphere) usually matches user intuition better.

k-Nearest Neighbours — Geo3dNearestQuery

Find the k nearest documents to a query point. The query starts from a small probe sphere (default 1 km) and doubles the radius until either:

  1. it has collected at least k distinct candidates,
  2. doubling makes no progress,
  3. the radius reaches max_radius_m (default 1.0e10 m, i.e. 10⁷ km).
#![allow(unused)]
fn main() {
use laurus::lexical::query::geo3d::Geo3dNearestQuery;
use laurus::GeoEcefPoint;

let centre = GeoEcefPoint::new(-3_955_182.0, 3_350_553.0, 3_700_276.0);
let query = Geo3dNearestQuery::new("position", centre, 10) // 10 nearest
    .with_initial_radius(500.0)         // start at 500 m
    .with_max_radius(1_000_000.0);      // cap at 1 000 km
}

A smaller-than-k result simply means the index does not contain k points within max_radius_m. Score is normalised so that the closest hit gets 1.0 and the farthest hit in the returned set gets 0.0.

The k-NN visitor never returns CellRelation::Inside from compare — only Outside or Crosses — so every candidate is delivered to visit with its exact coordinates, ensuring k-NN ordering uses the true distance.

Query DSL

Geo3d queries are also exposed in the unified Query DSL (see Query DSL → Geo3d Functions):

position:geo3d_distance(-3955182, 3350553, 3700276, 5000)
position:geo3d_bbox(-4000000, 3300000, 3650000, -3900000, 3400000, 3750000)
position:geo3d_nearest(-3955182, 3350553, 3700276, 10)
FormArguments
geo3d_distance(x, y, z, radius_m)centre (x, y, z) and search radius in metres
geo3d_bbox(min_x, min_y, min_z, max_x, max_y, max_z)two opposite corners of the AABB
geo3d_nearest(x, y, z, k)centre (x, y, z) and the integer k

All numeric arguments are signed floats; the k of geo3d_nearest is an unsigned integer.

Wire Formats

gRPC

Protocol Buffers expose 3D geo as a dedicated Value variant alongside a Geo3dPoint message and a Geo3dOption schema option:

message Geo3dPoint {
  double x = 1;
  double y = 2;
  double z = 3;
}

message Value {
  oneof kind {
    // ...
    Geo3dPoint geo3d_value = 12;
  }
}

message Geo3dOption {
  bool indexed = 1;
  bool stored = 2;
}

See common.proto and index.proto for the full definitions.

HTTP gateway / MCP

JSON documents accept Geo3d values as a { "x": …, "y": …, "z": … } object:

{
  "position": { "x": -3955182.0, "y": 3350553.0, "z": 3700276.0 }
}

The HTTP gateway converts this to DataValue::GeoEcef before forwarding to the engine; the MCP server uses the same convention.

When not to Use Geo3d

  • Pure 2D map queries (radius from a coordinate on the surface, simple bounding boxes on a tile map) — keep using Geo. The 2D BKD is one dimension lighter and the WGS84 input matches user intuition.
  • Approximate semantic similarity of a learned location embedding — that is a vector field (Hnsw, Flat, Ivf), not a Geo3d field.

See Also

  • BKD-Tree — the underlying multi-dimensional index that also backs Integer, Float, DateTime, and 2D Geo fields.
  • Schema & FieldsGeo3d alongside the rest of the field type table.
  • Query DSL — full geo3d DSL grammar.
  • Lexical Search — programmatic entry points for every lexical query type.