Skip to main content

kevy_geo/
lib.rs

1//! `kevy-geo` — pure-Rust, zero-dependency primitives for Redis-style
2//! GEO commands. Provides geohash encoding (score-form, used as the ZSet
3//! score that backs every GEO key in kevy) and the standard 11-character
4//! base32 string geohash, plus the WGS84 great-circle distance kevy needs
5//! for `GEODIST` / `GEOSEARCH BYRADIUS`. Implementation deliberately
6//! matches Redis bit-for-bit so kevy GEO keys are wire-interchangeable
7//! with valkey clients.
8//!
9//! What it is NOT:
10//! - A full geo library — no projection conversions, no datums other than
11//!   WGS84, no path/intersection geometry, no R-tree spatial index. The
12//!   Redis-style GEO API is intentionally narrow; this crate matches that
13//!   narrowness rather than trying to be `proj` or `geo-types`.
14//! - A `no_std` crate — uses `f64::sqrt`/`sin`/`cos`/`atan2` from `std`.
15#![forbid(unsafe_code)]
16#![warn(missing_docs)]
17
18/// Inclusive latitude bound (degrees). Matches Redis's Web Mercator
19/// limit — the encoding cannot represent the poles because Web Mercator
20/// maps them to ±∞.
21pub const GEO_LAT_MIN: f64 = -85.05112878;
22/// Inclusive latitude bound (degrees).
23pub const GEO_LAT_MAX: f64 = 85.05112878;
24/// Inclusive longitude bound (degrees).
25pub const GEO_LON_MIN: f64 = -180.0;
26/// Inclusive longitude bound (degrees).
27pub const GEO_LON_MAX: f64 = 180.0;
28/// Mean great-circle Earth radius in metres, matching Redis's constant
29/// exactly (`6_372_797.560_856`). Used by [`haversine_meters`].
30pub const EARTH_RADIUS_METERS: f64 = 6_372_797.560_856;
31/// Bits per axis in the 52-bit interleaved score. Matches Redis.
32pub const GEO_STEP: u32 = 26;
33
34/// Encode `(longitude, latitude)` as the 52-bit interleaved geohash
35/// stored as a ZSet score. Returns `None` if either coordinate is out
36/// of WGS84 range. The score is a non-negative integer ≤ 2⁵² so its
37/// f64 representation is exact (within the 53-bit f64 mantissa).
38///
39/// Bit layout matches Redis: latitude bits at even positions
40/// (0, 2, … 50), longitude bits at odd positions (1, 3, … 51).
41pub fn encode_score(lon: f64, lat: f64) -> Option<f64> {
42    if !(lon.is_finite() && lat.is_finite()) {
43        return None;
44    }
45    if !(GEO_LAT_MIN..=GEO_LAT_MAX).contains(&lat) {
46        return None;
47    }
48    if !(GEO_LON_MIN..=GEO_LON_MAX).contains(&lon) {
49        return None;
50    }
51    let bits = encode_bits_wgs84(lon, lat);
52    Some(bits as f64)
53}
54
55/// Inverse of [`encode_score`]: decode a ZSet score back to the
56/// `(longitude, latitude)` *centre* of its geohash cell. Out-of-range
57/// scores saturate to the WGS84 bounds rather than producing garbage.
58pub fn decode_score(score: f64) -> (f64, f64) {
59    let bits = score_to_bits(score);
60    let (ilat, ilon) = deinterleave52(bits);
61    cell_centre(ilon, ilat, GEO_LON_MIN, GEO_LON_MAX, GEO_LAT_MIN, GEO_LAT_MAX)
62}
63
64/// Great-circle distance in metres between two `(longitude, latitude)`
65/// points on the WGS84 sphere (mean radius — matches Redis). Returns
66/// `0.0` if the inputs are equal.
67pub fn haversine_meters(lon1: f64, lat1: f64, lon2: f64, lat2: f64) -> f64 {
68    let phi1 = lat1.to_radians();
69    let phi2 = lat2.to_radians();
70    let dphi = (lat2 - lat1).to_radians();
71    let dlam = (lon2 - lon1).to_radians();
72    let a = (dphi * 0.5).sin().powi(2)
73        + phi1.cos() * phi2.cos() * (dlam * 0.5).sin().powi(2);
74    let c = 2.0 * a.sqrt().clamp(0.0, 1.0).asin();
75    EARTH_RADIUS_METERS * c
76}
77
78/// Encode `(lon, lat)` as the 11-character base32 geohash string Redis
79/// returns from `GEOHASH`. Uses the **standard** lat range [-90, 90]
80/// (NOT the WGS84 ±85.05 range used for the score). The high 55 bits
81/// of a step-26 standard-range encoding are emitted in 5-bit groups;
82/// the low 3 bits of the 11th char are always zero (52 ÷ 5 = 10 r 2).
83pub fn encode_base32_geohash(lon: f64, lat: f64) -> [u8; 11] {
84    const ALPHABET: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz";
85    let bits = encode_bits_full_range(lon, lat);
86    let mut out = [0u8; 11];
87    for (i, slot) in out.iter_mut().enumerate() {
88        let shift = 52i32 - (i as i32 + 1) * 5;
89        // Redis emits the 52 score bits across the first 10 chars (50 bits)
90        // and pads the 11th char with zero rather than spilling the 2 low
91        // real bits into it — match that so GEOHASH strings are byte-equal.
92        let idx = if shift >= 0 {
93            ((bits >> shift) & 0x1f) as usize
94        } else {
95            0
96        };
97        *slot = ALPHABET[idx];
98    }
99    out
100}
101
102/// Bit-wise interleave: bits of `lat_u32` at even positions, bits of
103/// `lon_u32` at odd positions, producing the 52-bit score layout. Only
104/// the low 26 bits of each input contribute.
105fn interleave52(lat: u32, lon: u32) -> u64 {
106    spread26(lat as u64) | (spread26(lon as u64) << 1)
107}
108
109/// Inverse of [`interleave52`]: extract `(lat_u32, lon_u32)` (26 bits
110/// each) from a 52-bit interleaved value.
111fn deinterleave52(bits: u64) -> (u32, u32) {
112    let lat = pack26(bits) as u32;
113    let lon = pack26(bits >> 1) as u32;
114    (lat, lon)
115}
116
117/// Spread the low 26 bits of `x` into the even bit positions of a
118/// 52-bit result (Bit Twiddling Hacks: interleave-by-magic-numbers).
119fn spread26(mut x: u64) -> u64 {
120    x &= 0x3ff_ffff; // 26 bits
121    x = (x | (x << 16)) & 0x0000_0000_FFFF_0000_FFFF;
122    x = (x | (x << 8)) & 0x0000_00FF_00FF_00FF_00FF;
123    x = (x | (x << 4)) & 0x000F_0F0F_0F0F_0F0F;
124    x = (x | (x << 2)) & 0x3333_3333_3333_3333;
125    x = (x | (x << 1)) & 0x5555_5555_5555_5555;
126    x
127}
128
129/// Inverse of [`spread26`]: collapse the even-positioned bits of a
130/// 52-bit value back into a contiguous 26-bit integer.
131fn pack26(mut x: u64) -> u64 {
132    x &= 0x5555_5555_5555_5555;
133    x = (x | (x >> 1)) & 0x3333_3333_3333_3333;
134    x = (x | (x >> 2)) & 0x000F_0F0F_0F0F_0F0F;
135    x = (x | (x >> 4)) & 0x0000_00FF_00FF_00FF_00FF;
136    x = (x | (x >> 8)) & 0x0000_0000_FFFF_0000_FFFF;
137    x = (x | (x >> 16)) & 0x3ff_ffff;
138    x
139}
140
141fn encode_bits_wgs84(lon: f64, lat: f64) -> u64 {
142    encode_bits(lon, lat, GEO_LON_MIN, GEO_LON_MAX, GEO_LAT_MIN, GEO_LAT_MAX)
143}
144
145fn encode_bits_full_range(lon: f64, lat: f64) -> u64 {
146    encode_bits(lon, lat, GEO_LON_MIN, GEO_LON_MAX, -90.0, 90.0)
147}
148
149fn encode_bits(
150    lon: f64,
151    lat: f64,
152    lon_min: f64,
153    lon_max: f64,
154    lat_min: f64,
155    lat_max: f64,
156) -> u64 {
157    let cells = (1u64 << GEO_STEP) as f64;
158    let lat_off = ((lat - lat_min) / (lat_max - lat_min)) * cells;
159    let lon_off = ((lon - lon_min) / (lon_max - lon_min)) * cells;
160    let lat_u = (lat_off as u32).min((1 << GEO_STEP) - 1);
161    let lon_u = (lon_off as u32).min((1 << GEO_STEP) - 1);
162    interleave52(lat_u, lon_u)
163}
164
165fn cell_centre(
166    ilon: u32,
167    ilat: u32,
168    lon_min: f64,
169    lon_max: f64,
170    lat_min: f64,
171    lat_max: f64,
172) -> (f64, f64) {
173    // Mirror Redis's geohashDecode float-op order EXACTLY: decode each axis
174    // to its cell [min,max] separately, then average — `lon_min + (i/cells)
175    // *span` for min and `(i+1)/cells*span` for max. The mathematically
176    // equivalent `(i+0.5)/cells*span` rounds differently in the last ULP,
177    // which made GEOPOS diverge from valkey/redis in the final digits.
178    let cells = (1u64 << GEO_STEP) as f64;
179    let lon_span = lon_max - lon_min;
180    let lat_span = lat_max - lat_min;
181    let lon_lo = lon_min + (ilon as f64 / cells) * lon_span;
182    let lon_hi = lon_min + ((ilon as f64 + 1.0) / cells) * lon_span;
183    let lat_lo = lat_min + (ilat as f64 / cells) * lat_span;
184    let lat_hi = lat_min + ((ilat as f64 + 1.0) / cells) * lat_span;
185    ((lon_lo + lon_hi) / 2.0, (lat_lo + lat_hi) / 2.0)
186}
187
188/// Convert an f64 score back to its 52-bit interleaved integer. Saturates
189/// negative / NaN / >2⁵² values to the valid range so that `decode_score`
190/// on a garbage score still produces a defined `(lon, lat)` pair rather
191/// than UB or a wild f64 cast.
192fn score_to_bits(score: f64) -> u64 {
193    if !score.is_finite() || score < 0.0 {
194        return 0;
195    }
196    let max = (1u64 << (GEO_STEP * 2)) - 1;
197    let n = score as u64;
198    n.min(max)
199}
200
201// ───────────── neighbor score ranges ─────────────
202
203/// Compute up to 9 ZSet-score ranges (closed-inclusive `(min, max)` as
204/// f64-encoded 52-bit integers) that cover **at least** the disc of
205/// `radius_m` metres around `(lon, lat)`. Each range maps a step-`k`
206/// geohash cell to its contiguous score interval in the step-26 layout.
207///
208/// Returns the ranges sorted by `min`, with adjacent ranges merged so
209/// the caller can iterate them as `ZRANGEBYSCORE` queries without
210/// redundant work. The set over-approximates the circle by at most one
211/// cell width — callers MUST filter by exact distance afterwards.
212///
213/// Returns a single all-key range `(0, 2⁵² − 1)` when the radius is
214/// large enough to span the globe or the centre is invalid.
215pub fn neighbor_score_ranges(lon: f64, lat: f64, radius_m: f64) -> Vec<(f64, f64)> {
216    if !lon.is_finite() || !lat.is_finite() || radius_m <= 0.0 {
217        return vec![(0.0, (1u64 << 52) as f64 - 1.0)];
218    }
219    let step = estimate_step(radius_m);
220    if step <= 1 {
221        return vec![(0.0, (1u64 << 52) as f64 - 1.0)];
222    }
223    let (clat, clon) = encode_uniform_step(lon, lat, step);
224    let mut raw: Vec<(u64, u64)> = Vec::with_capacity(9);
225    let cells = 1i32 << step;
226    let shift = (GEO_STEP - step) * 2;
227    let inner_mask = (1u64 << shift) - 1;
228    for dlat in -1i32..=1 {
229        for dlon in -1i32..=1 {
230            let ilat = clat as i32 + dlat;
231            if !(0..cells).contains(&ilat) {
232                continue;
233            }
234            let ilon = (clon as i32 + dlon).rem_euclid(cells);
235            let prefix = interleave52(ilat as u32, ilon as u32);
236            let min = prefix << shift;
237            let max = min | inner_mask;
238            raw.push((min, max));
239        }
240    }
241    raw.sort_unstable();
242    merge_ranges(raw)
243}
244
245fn estimate_step(radius_m: f64) -> u32 {
246    const MERCATOR_MAX: f64 = 20_037_726.37;
247    if radius_m <= 0.0 {
248        return GEO_STEP;
249    }
250    let mut step = 1u32;
251    let mut r = radius_m;
252    while r < MERCATOR_MAX {
253        r *= 2.0;
254        step += 1;
255    }
256    step.saturating_sub(2).clamp(1, GEO_STEP)
257}
258
259fn encode_uniform_step(lon: f64, lat: f64, step: u32) -> (u32, u32) {
260    let cells = (1u64 << step) as f64;
261    let lat_clamped = lat.clamp(GEO_LAT_MIN, GEO_LAT_MAX);
262    let lon_clamped = lon.clamp(GEO_LON_MIN, GEO_LON_MAX);
263    let lat_off =
264        ((lat_clamped - GEO_LAT_MIN) / (GEO_LAT_MAX - GEO_LAT_MIN) * cells) as u32;
265    let lon_off =
266        ((lon_clamped - GEO_LON_MIN) / (GEO_LON_MAX - GEO_LON_MIN) * cells) as u32;
267    let max = (1u32 << step) - 1;
268    (lat_off.min(max), lon_off.min(max))
269}
270
271/// Sort + coalesce adjacent / overlapping integer ranges, then convert
272/// to the `(f64, f64)` form callers feed into `ZRANGEBYSCORE`. The 52-bit
273/// integer ↔ f64 mapping is exact within the f64 mantissa.
274fn merge_ranges(sorted: Vec<(u64, u64)>) -> Vec<(f64, f64)> {
275    let mut out: Vec<(u64, u64)> = Vec::with_capacity(sorted.len());
276    for (min, max) in sorted {
277        match out.last_mut() {
278            Some(prev) if prev.1.saturating_add(1) >= min => {
279                prev.1 = prev.1.max(max);
280            }
281            _ => out.push((min, max)),
282        }
283    }
284    out.into_iter().map(|(a, b)| (a as f64, b as f64)).collect()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    // Reference values produced by valkey 9.1 + redis 7.4 (both agree) for
292    // the canonical Sicily fixture. Regression guards for the two geo
293    // encoding bugs the cross-engine differential (`bench/compat3.sh`)
294    // caught: GEOHASH 11th-char padding and GEOPOS cell-midpoint rounding.
295    const PALERMO: (f64, f64) = (13.361389, 38.115556);
296    const CATANIA: (f64, f64) = (15.087269, 37.502669);
297
298    #[test]
299    fn geohash_string_matches_redis() {
300        // 11th char must be '0' (zero-padded), not the spilled low bits.
301        assert_eq!(&encode_base32_geohash(PALERMO.0, PALERMO.1), b"sqc8b49rny0");
302        assert_eq!(&encode_base32_geohash(CATANIA.0, CATANIA.1), b"sqdtr74hyu0");
303    }
304
305    #[test]
306    fn geopos_roundtrip_matches_redis_to_the_last_digit() {
307        // decode(encode(..)) must reproduce valkey/redis GEOPOS byte-for-byte
308        // (17 sig digits), which requires the exact cell-midpoint float order.
309        for (lon, lat, want_lon, want_lat) in [
310            (
311                PALERMO.0,
312                PALERMO.1,
313                "13.36138933897018433",
314                "38.11555639549629859",
315            ),
316            (
317                CATANIA.0,
318                CATANIA.1,
319                "15.08726745843887329",
320                "37.50266842333162032",
321            ),
322        ] {
323            let score = encode_score(lon, lat).expect("in range");
324            let (dlon, dlat) = decode_score(score);
325            assert_eq!(format!("{dlon:.17}"), want_lon, "lon for ({lon},{lat})");
326            assert_eq!(format!("{dlat:.17}"), want_lat, "lat for ({lon},{lat})");
327        }
328    }
329}