From 5e470f2eb44ae2aedeaf2187939be9c2f656ff39 Mon Sep 17 00:00:00 2001 From: Anatolii Tsyplenkov Date: Wed, 30 Apr 2025 11:36:48 +1200 Subject: [PATCH 1/7] chore: added geo Point and MultiPoint support --- Cargo.lock | 198 +++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/cells.rs | 4 +- src/geo.rs | 26 ++++++ src/lib.rs | 1 + src/test/data.rs | 115 +++++++++++++++++++++++++ src/test/geo.rs | 36 ++++++++ src/test/hashing.rs | 36 -------- src/test/mod.rs | 3 +- src/test/tiles.rs | 16 ++-- src/tiles.rs | 22 ++--- src/utils.rs | 70 ++++++++-------- 12 files changed, 433 insertions(+), 95 deletions(-) create mode 100644 src/geo.rs create mode 100644 src/test/data.rs create mode 100644 src/test/geo.rs delete mode 100644 src/test/hashing.rs diff --git a/Cargo.lock b/Cargo.lock index 1a99b4d..f70c71e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anes" version = "0.1.6" @@ -57,6 +63,12 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cast" version = "0.3.0" @@ -153,7 +165,7 @@ dependencies = [ "clap", "criterion-plot", "is-terminal", - "itertools", + "itertools 0.10.5", "num-traits", "once_cell", "oorandom", @@ -174,7 +186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools", + "itertools 0.10.5", ] [[package]] @@ -208,18 +220,64 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools 0.11.0", + "num-traits", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "float_eq" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28a80e3145d8ad11ba0995949bbcf48b9df2be62772b3d351ef017dff6ecb853" +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "geo" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4416397671d8997e9a3e7ad99714f4f00a22e9eaa9b966a5985d2194fc9e02e1" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "robust", + "rstar", + "spade", +] + [[package]] name = "geo-types" version = "0.7.16" @@ -228,9 +286,20 @@ checksum = "62ddb1950450d67efee2bbc5e429c68d052a822de3aad010d28b351fbb705224" dependencies = [ "approx", "num-traits", + "rayon", + "rstar", "serde", ] +[[package]] +name = "geographiclib-rs" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e5ed84f8089c70234b0a8e0aedb6dc733671612ddc0d37c6066052f9781960" +dependencies = [ + "libm", +] + [[package]] name = "geohash" version = "0.13.1" @@ -281,12 +350,86 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "hermit-abi" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" +[[package]] +name = "i_float" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85df3a416829bb955fdc2416c7b73680c8dcea8d731f2c7aa23e1042fe1b8343" +dependencies = [ + "serde", +] + +[[package]] +name = "i_key_sort" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "347c253b4748a1a28baf94c9ce133b6b166f08573157e05afe718812bc599fcd" + +[[package]] +name = "i_overlay" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0542dfef184afdd42174a03dcc0625b6147fb73e1b974b1a08a2a42ac35cee49" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", + "rayon", +] + +[[package]] +name = "i_shape" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a38f5a42678726718ff924f6d4a0e79b129776aeed298f71de4ceedbd091bce" +dependencies = [ + "i_float", + "serde", +] + +[[package]] +name = "i_tree" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "155181bc97d770181cf9477da51218a19ee92a8e5be642e796661aee2b601139" + [[package]] name = "is-terminal" version = "0.4.16" @@ -307,6 +450,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -412,6 +564,7 @@ version = "0.1.1" dependencies = [ "approx", "criterion", + "geo", "geohash", "h3o", ] @@ -474,6 +627,23 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "robust" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf4a6aa5f6d6888f39e980649f3ad6b666acdce1d78e95b8a2cb076e687ae30" + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless", + "num-traits", + "smallvec", +] + [[package]] name = "rustversion" version = "1.0.20" @@ -527,6 +697,30 @@ dependencies = [ "serde", ] +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "spade" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ece03ff43cd2a9b57ebf776ea5e78bd30b3b4185a619f041079f4109f385034" +dependencies = [ + "hashbrown", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "syn" version = "2.0.100" diff --git a/Cargo.toml b/Cargo.toml index 4676fdf..c0a83dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ keywords = ["quadbin", "quadkey", "spatial", "spatial-index"] categories = ["data-structures", "science::geo"] [dependencies] +geo = "0.30.0" [dev-dependencies] approx = "0.5.1" diff --git a/src/cells.rs b/src/cells.rs index 2a7cae7..c052cc1 100644 --- a/src/cells.rs +++ b/src/cells.rs @@ -2,7 +2,7 @@ use crate::Direction; use crate::constants::*; use crate::errors; use crate::errors::*; -use crate::tiles::*; +use crate::tiles::Tile; use crate::utils::*; use core::{fmt, num::NonZeroU64}; @@ -328,7 +328,7 @@ fn point_to_cell(lat: f64, lng: f64, res: u8) -> Result { let lng = clip_longitude(lng); let lat = clip_latitude(lat); - let tile = Tile::from_point(lat, lng, res); + let tile = Tile::from_point(lat, lng, res)?; tile.to_cell() } diff --git a/src/geo.rs b/src/geo.rs new file mode 100644 index 0000000..c724bbe --- /dev/null +++ b/src/geo.rs @@ -0,0 +1,26 @@ +use crate::Cell; +use crate::errors::*; +use geo::{MultiPoint, Point}; + +/// Support for [geo]. +impl Cell { + /// Get Quadbin cell index from [geo::Point]. + /// + /// Similar to [Cell::from_point], but requires a [geo::Point] for input. + pub fn from_geopoint(point: Point, res: u8) -> Result { + Cell::from_point(point.y(), point.x(), res) + } + + /// Get Quadbin cell index from [geo::MultiPoint] + /// + /// The output may contain duplicate indexes in case of overlapping + /// input geometries. + pub fn from_multipoint( + multipoint: MultiPoint, + res: u8, + ) -> impl Iterator> { + multipoint + .into_iter() + .map(move |point| Cell::from_geopoint(point, res)) + } +} diff --git a/src/lib.rs b/src/lib.rs index a367a87..8673800 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ // Quadbin cell itself mod cells; +mod geo; pub use crate::cells::Cell; // Direction struct diff --git a/src/test/data.rs b/src/test/data.rs new file mode 100644 index 0000000..9cf0f03 --- /dev/null +++ b/src/test/data.rs @@ -0,0 +1,115 @@ +// Adapted from +// https://github.com/georust/wkb/blob/main/src/test/data.rs + +use geo::{ + Geometry, GeometryCollection, LineString, MultiLineString, MultiPoint, MultiPolygon, Point, + Polygon, line_string, point, polygon, +}; + +pub(super) fn point_2d() -> Point { + point!( + x: -3.7038, y: 40.4168 + ) +} + +// pub(super) fn linestring_2d() -> LineString { +// line_string![ +// (x: -73.935242, y: 40.730610), // New York City +// (x: -118.243683, y: 34.052235) // Los Angeles +// ] +// } + +// pub(super) fn polygon_2d() -> Polygon { +// polygon![ +// (x: -122.419418, y: 37.774929), // San Francisco +// (x: -122.419418, y: 34.052235), // Los Angeles +// (x: -118.243683, y: 34.052235), // Los Angeles +// (x: -118.243683, y: 37.774929), // San Francisco +// ] +// } + +// pub(super) fn polygon_2d_with_interior() -> Polygon { +// polygon!( +// exterior: [ +// (x: -122.419418, y: 37.774929), // San Francisco +// (x: -122.419418, y: 34.052235), // Los Angeles +// (x: -118.243683, y: 34.052235), // Los Angeles +// (x: -118.243683, y: 37.774929), // San Francisco +// ], +// interiors: [ +// [ +// (x: -121.886330, y: 37.338207), // San Jose +// (x: -121.886330, y: 36.778259), // Central California +// (x: -119.417931, y: 36.778259), // Central California +// (x: -119.417931, y: 37.338207), // San Jose +// ], +// ], +// ) +// } + +pub(super) fn multi_point_2d() -> MultiPoint { + MultiPoint::new(vec![ + point!( + x: -3.7038, y: 40.4168 + ), + point!( + x: 33.75, y: -11.178401873711776 + ), + ]) +} + +// pub(super) fn multi_line_string_2d() -> MultiLineString { +// MultiLineString::new(vec![ +// line_string![ +// (x: -122.419418, y: 37.774929), // San Francisco +// (x: -122.419418, y: 34.052235), // Los Angeles +// (x: -118.243683, y: 34.052235), // Los Angeles +// (x: -118.243683, y: 37.774929), // San Francisco +// ], +// line_string![ +// (x: -121.886330, y: 37.338207), // San Jose +// (x: -121.886330, y: 36.778259), // Central California +// (x: -119.417931, y: 36.778259), // Central California +// (x: -119.417931, y: 37.338207), // San Jose +// ], +// ]) +// } + +// pub(super) fn multi_polygon_2d() -> MultiPolygon { +// MultiPolygon::new(vec![ +// polygon![ +// (x: -122.419418, y: 37.774929), // San Francisco +// (x: -122.419418, y: 34.052235), // Los Angeles +// (x: -118.243683, y: 34.052235), // Los Angeles +// (x: -118.243683, y: 37.774929), // San Francisco +// ], +// polygon!( +// exterior: [ +// (x: -122.419418, y: 37.774929), // San Francisco +// (x: -122.419418, y: 34.052235), // Los Angeles +// (x: -118.243683, y: 34.052235), // Los Angeles +// (x: -118.243683, y: 37.774929), // San Francisco +// ], +// interiors: [ +// [ +// (x: -121.886330, y: 37.338207), // San Jose +// (x: -121.886330, y: 36.778259), // Central California +// (x: -119.417931, y: 36.778259), // Central California +// (x: -119.417931, y: 37.338207), // San Jose +// ], +// ], +// ), +// ]) +// } + +// pub(super) fn geometry_collection_2d() -> GeometryCollection { +// GeometryCollection::new_from(vec![ +// Geometry::Point(point_2d()), +// Geometry::LineString(linestring_2d()), +// Geometry::Polygon(polygon_2d()), +// Geometry::Polygon(polygon_2d_with_interior()), +// Geometry::MultiPoint(multi_point_2d()), +// Geometry::MultiLineString(multi_line_string_2d()), +// Geometry::MultiPolygon(multi_polygon_2d()), +// ]) +// } diff --git a/src/test/geo.rs b/src/test/geo.rs new file mode 100644 index 0000000..6dd2a1c --- /dev/null +++ b/src/test/geo.rs @@ -0,0 +1,36 @@ +use super::data::*; +use crate::Cell; +use crate::errors::*; + +#[test] +fn test_quadbin_from_point() { + let orig = point_2d(); + let cell = Cell::from_geopoint(orig, 10).expect("cell index"); + assert_eq!(cell.get(), 5234261499580514303_u64) +} + +#[test] +fn test_quadbin_from_multipoint() { + let orig = multi_point_2d(); + let cells_iter = Cell::from_multipoint(orig, 4); + let truth = [5207251884775047167_u64, 5209574053332910079_u64]; + + for (i, cell_result) in cells_iter.enumerate() { + let cell = cell_result.expect("cell index"); + assert_eq!(cell.get(), truth[i]); + } +} + +#[test] +fn test_invalid_resolution() { + let orig = point_2d(); + let res = 27; + let cell = Cell::from_geopoint(orig, res); + assert_eq!( + cell.err(), + Some(QuadbinError::InvalidResolution(InvalidResolution::new( + res, + "Resolution should be between 0 and 26" + ))) + ); +} diff --git a/src/test/hashing.rs b/src/test/hashing.rs deleted file mode 100644 index 7db0050..0000000 --- a/src/test/hashing.rs +++ /dev/null @@ -1,36 +0,0 @@ -use crate::tiles::Tile; -use crate::utils::point_cover; - -#[test] -fn test_tile_hashing() { - let cases = [ - (Tile::new(0, 0, 0), 0_u64), - (Tile::new(1, 0, 1), 33_u64), - (Tile::new(0, 1, 1), 129_u64), - (Tile::new(0, 0, 2), 2_u64), - (Tile::new(13, 13, 13), 6816173_u64), - (Tile::new(46, 3584, 12), 939525580_u64), - (Tile::new(123, 321, 25), 689342254969_u64), - (Tile::new(8108, 14336, 14), 15032645006_u64), - ]; - - for (tile, hash) in cases.iter() { - // Tile to hash - assert_eq!(tile.to_hash(), *hash); - // Hash to tile - assert_eq!(Tile::from_hash(*hash), *tile); - } -} - -#[test] -fn test_point_hashing() { - let cases = [ - ((46.152, -52.222), 10_u8, 44978282_u64), - ((46.152, -52.222), 22_u8, 755128617831862_u64), - ((46.152, -52.222), 26_u8, 193312953173859226_u64), - ]; - - for (coords, res, hash) in cases.iter() { - assert_eq!(point_cover(coords.1, coords.0, *res), *hash); - } -} diff --git a/src/test/mod.rs b/src/test/mod.rs index c299d71..e34434d 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -1,5 +1,6 @@ mod cells; +mod data; mod directions; mod errors; -mod hashing; +mod geo; mod tiles; diff --git a/src/test/tiles.rs b/src/test/tiles.rs index adbb49e..62f554a 100644 --- a/src/test/tiles.rs +++ b/src/test/tiles.rs @@ -20,15 +20,15 @@ fn test_point_to_tile_fraction() { #[test] fn test_point_to_tile() { // X axis - assert_eq!(Tile::from_point(0.0, -180.0, 0), Tile::new(0, 0, 0)); - assert_eq!(Tile::from_point(85.0, -180.0, 2), Tile::new(0, 0, 2)); - assert_eq!(Tile::from_point(85.0, 180.0, 2), Tile::new(0, 0, 2)); - assert_eq!(Tile::from_point(85.0, -185.0, 2), Tile::new(3, 0, 2)); - assert_eq!(Tile::from_point(85.0, 185.0, 2), Tile::new(0, 0, 2)); + assert_eq!(Tile::from_point(0.0, -180.0, 0), Ok(Tile::new(0, 0, 0))); + assert_eq!(Tile::from_point(85.0, -180.0, 2), Ok(Tile::new(0, 0, 2))); + assert_eq!(Tile::from_point(85.0, 180.0, 2), Ok(Tile::new(0, 0, 2))); + assert_eq!(Tile::from_point(85.0, -185.0, 2), Ok(Tile::new(3, 0, 2))); + assert_eq!(Tile::from_point(85.0, 185.0, 2), Ok(Tile::new(0, 0, 2))); // Y-axis - assert_eq!(Tile::from_point(-95.0, -175.0, 2), Tile::new(0, 3, 2)); - assert_eq!(Tile::from_point(95.0, -175.0, 2), Tile::new(0, 0, 2)); + assert_eq!(Tile::from_point(-95.0, -175.0, 2), Ok(Tile::new(0, 3, 2))); + assert_eq!(Tile::from_point(95.0, -175.0, 2), Ok(Tile::new(0, 0, 2))); } // Estimate tile's area @@ -62,7 +62,7 @@ fn test_tile_conversion() { let lon = -45.0_f64; let lat = 45.0_f64; - let tile = Tile::from_point(lat, lon, 10); + let tile = Tile::from_point(lat, lon, 10).unwrap(); // Check Tile conversion assert_eq!(tile.x, 384_u32); diff --git a/src/tiles.rs b/src/tiles.rs index ca43845..a050739 100644 --- a/src/tiles.rs +++ b/src/tiles.rs @@ -25,7 +25,7 @@ impl Tile { } /// Compute the tile for a longitude and latitude in a specific resolution. - pub fn from_point(lat: f64, lng: f64, res: u8) -> Self { + pub fn from_point(lat: f64, lng: f64, res: u8) -> Result { point_to_tile(lat, lng, res) } @@ -55,15 +55,15 @@ impl Tile { tile_neighbor(self, direction) } - /// Compute a hash from the tile. - #[allow(dead_code)] - pub fn to_hash(self) -> u64 { - to_tile_hash(&self) - } + // /// Compute a hash from the tile. + // #[allow(dead_code)] + // pub fn to_hash(self) -> u64 { + // to_tile_hash(&self) + // } - /// Compute a tile from the hash. - #[allow(dead_code)] - pub fn from_hash(tile_hash: u64) -> Tile { - from_tile_hash(tile_hash) - } + // /// Compute a tile from the hash. + // #[allow(dead_code)] + // pub fn from_hash(tile_hash: u64) -> Tile { + // from_tile_hash(tile_hash) + // } } diff --git a/src/utils.rs b/src/utils.rs index 3eab058..07315e4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -49,11 +49,11 @@ pub(crate) fn point_to_tile_fraction( } /// Compute the tile for a longitude and latitude in a specific resolution. -pub(crate) fn point_to_tile(lat: f64, lng: f64, res: u8) -> Tile { - let (x, y, z) = point_to_tile_fraction(lat, lng, res).expect("resolution"); +pub(crate) fn point_to_tile(lat: f64, lng: f64, res: u8) -> Result { + let (x, y, z) = point_to_tile_fraction(lat, lng, res)?; let x: u32 = x.floor() as u32; let y: u32 = y.floor() as u32; - Tile::new(x, y, z) + Ok(Tile::new(x, y, z)) } /// Compute the latitude for a tile with an offset. @@ -189,35 +189,35 @@ pub(crate) fn tile_neighbor(tile: &Tile, direction: Direction) -> Option { Some(Tile::new(x, y, z)) } -/// Compute a hash from the tile. -pub(crate) fn to_tile_hash(tile: &Tile) -> u64 { - let x = tile.x as u64; - let y = tile.y as u64; - let z = tile.z as u64; - - let dim = 2 * (1 << z); - - ((dim * y + x) * 32) + z -} - -/// Compute a tile from the hash. -#[allow(dead_code)] -pub(crate) fn from_tile_hash(tile_hash: u64) -> Tile { - // TODO: - // Return None if hash is invalid - // Understand why do we need tile hashing - let z = tile_hash % 32_u64; - let dim = 2_u64 * (1_u64 << z); - let xy = (tile_hash - z) / 32; - let x = xy % dim; - let y = ((xy - x) / dim) % dim; - - Tile::new(x as u32, y as u32, z as u8) -} - -/// Return the tiles hashes that cover a point. -#[allow(dead_code)] -pub(crate) fn point_cover(lat: f64, lng: f64, res: u8) -> u64 { - let tile = Tile::from_point(lat, lng, res); - to_tile_hash(&tile) -} +// /// Compute a hash from the tile. +// pub(crate) fn to_tile_hash(tile: &Tile) -> u64 { +// let x = tile.x as u64; +// let y = tile.y as u64; +// let z = tile.z as u64; + +// let dim = 2 * (1 << z); + +// ((dim * y + x) * 32) + z +// } + +// /// Compute a tile from the hash. +// #[allow(dead_code)] +// pub(crate) fn from_tile_hash(tile_hash: u64) -> Tile { +// // TODO: +// // Return None if hash is invalid +// // Understand why do we need tile hashing +// let z = tile_hash % 32_u64; +// let dim = 2_u64 * (1_u64 << z); +// let xy = (tile_hash - z) / 32; +// let x = xy % dim; +// let y = ((xy - x) / dim) % dim; + +// Tile::new(x as u32, y as u32, z as u8) +// } + +// /// Return the tiles hashes that cover a point. +// #[allow(dead_code)] +// pub(crate) fn point_cover(lat: f64, lng: f64, res: u8) -> u64 { +// let tile = Tile::from_point(lat, lng, res); +// to_tile_hash(&tile) +// } From 550225c0fc5a0e25f630ddeb4a1630f669099d79 Mon Sep 17 00:00:00 2001 From: Anatolii Tsyplenkov Date: Wed, 30 Apr 2025 11:42:55 +1200 Subject: [PATCH 2/7] ci: enhance build workflow --- .github/workflows/rust-ci.yml | 36 +++++++++++++++++++++++++++++++++-- src/test/data.rs | 5 +---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 5899e9f..e61937b 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -39,7 +39,6 @@ jobs: - name: Check the lints run: cargo clippy --tests --verbose -- -D warnings - rustfmt: name: Formatting runs-on: ubuntu-latest @@ -64,4 +63,37 @@ jobs: with: args: -v *.md env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Check documentation errors + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --document-private-items --all-features --examples + + publish-dry-run: + name: Publish dry run + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - run: cargo publish --dry-run + + no-std-build: + name: Make sure the code is no-std compatible + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + run: rustup update stable && rustup target add thumbv7em-none-eabihf + - run: cargo build --target=thumbv7em-none-eabihf --no-default-features \ No newline at end of file diff --git a/src/test/data.rs b/src/test/data.rs index 9cf0f03..04221ad 100644 --- a/src/test/data.rs +++ b/src/test/data.rs @@ -1,10 +1,7 @@ // Adapted from // https://github.com/georust/wkb/blob/main/src/test/data.rs -use geo::{ - Geometry, GeometryCollection, LineString, MultiLineString, MultiPoint, MultiPolygon, Point, - Polygon, line_string, point, polygon, -}; +use geo::{MultiPoint, Point, point}; pub(super) fn point_2d() -> Point { point!( From acf23475610ec256c437131d3633982554310b20 Mon Sep 17 00:00:00 2001 From: Anatolii Tsyplenkov Date: Wed, 30 Apr 2025 11:52:46 +1200 Subject: [PATCH 3/7] ci: remove no_std check --- .github/workflows/rust-ci.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index e61937b..8c7b08b 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -86,14 +86,4 @@ jobs: uses: actions/checkout@v4 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - - run: cargo publish --dry-run - - no-std-build: - name: Make sure the code is no-std compatible - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Install Rust toolchain - run: rustup update stable && rustup target add thumbv7em-none-eabihf - - run: cargo build --target=thumbv7em-none-eabihf --no-default-features \ No newline at end of file + - run: cargo publish --dry-run \ No newline at end of file From 16a7eef8acc0ca6f96dbceb477fb7160ba0abe9e Mon Sep 17 00:00:00 2001 From: Anatolii Tsyplenkov Date: Wed, 30 Apr 2025 12:31:20 +1200 Subject: [PATCH 4/7] chore: Quadbin to Polygon conversion --- src/errors.rs | 2 +- src/geo.rs | 59 ++++++++++++++++++++++++++++++++++++++++++--- src/test/geo.rs | 21 ++++++++++++++++ src/test/hashing.rs | 36 +++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 src/test/hashing.rs diff --git a/src/errors.rs b/src/errors.rs index 29fd8c6..1e61ab2 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,6 +1,6 @@ use core::{error::Error, fmt}; -// Inherited from h3o +// Adapted from h3o // https://github.com/HydroniumLabs/h3o/blob/ad2bebf52eab218d66b0bf213b14a2802bf616f7/src/error/invalid_value.rs // Macro to declare type-specific InvalidValue error type. diff --git a/src/geo.rs b/src/geo.rs index c724bbe..73d7c36 100644 --- a/src/geo.rs +++ b/src/geo.rs @@ -1,20 +1,48 @@ use crate::Cell; use crate::errors::*; -use geo::{MultiPoint, Point}; +use geo::{LineString, MultiPoint, Point, Polygon}; -/// Support for [geo]. +/// Support for geospatial primitive types from [geo] crate. impl Cell { /// Get Quadbin cell index from [geo::Point]. /// /// Similar to [Cell::from_point], but requires a [geo::Point] for input. + /// + /// # Example + /// ``` + /// use qbin::Cell; + /// use geo::*; + /// + /// let point = point!(x: 174.77727344223067, y: -41.28303675124842); + /// + /// let cell = Cell::from_geopoint(point, 26).expect("cell index"); + /// assert_eq!(cell.get(), 5309133744805926483_u64) + /// ``` pub fn from_geopoint(point: Point, res: u8) -> Result { Cell::from_point(point.y(), point.x(), res) } - /// Get Quadbin cell index from [geo::MultiPoint] + /// Get Quadbin cell index from [geo::MultiPoint]. /// /// The output may contain duplicate indexes in case of overlapping /// input geometries. + /// + /// # Example + /// ``` + /// use qbin::Cell; + /// use geo::*; + /// + /// let points = MultiPoint::new(vec![ + /// point!( + /// x: -3.7038, y: 40.4168 + /// ), + /// point!( + /// x: 33.75, y: -11.178401873711776 + /// ), + /// ]); + /// + /// let cells = Cell::from_multipoint(points, 10).collect::>(); + /// ``` pub fn from_multipoint( multipoint: MultiPoint, res: u8, @@ -23,4 +51,29 @@ impl Cell { .into_iter() .map(move |point| Cell::from_geopoint(point, res)) } + + /// Converts Quadbin cell into [geo::Polygon] + /// + /// # Example + /// ``` + /// use qbin::Cell; + /// + /// // Create a Polygon out of the Cell's bounding box + /// let polygon = Cell::new(5309133744805926483).to_polygon(); + /// // Check if the polygon is of type Polygon and with no interior rings + /// assert_eq!(polygon.num_interior_rings(), 0); + /// ``` + pub fn to_polygon(&self) -> Polygon { + let bbox = self.to_bbox(); + Polygon::new( + LineString::from(vec![ + (bbox[0], bbox[1]), // bottom-left + (bbox[2], bbox[1]), // bottom-right + (bbox[2], bbox[3]), // top-right + (bbox[0], bbox[3]), // top-left + (bbox[0], bbox[1]), // back to bottom-left to close the loop + ]), + vec![], + ) + } } diff --git a/src/test/geo.rs b/src/test/geo.rs index 6dd2a1c..924847c 100644 --- a/src/test/geo.rs +++ b/src/test/geo.rs @@ -1,6 +1,7 @@ use super::data::*; use crate::Cell; use crate::errors::*; +use geo::{LineString, Polygon}; #[test] fn test_quadbin_from_point() { @@ -34,3 +35,23 @@ fn test_invalid_resolution() { ))) ); } + +#[test] +fn test_cell_to_polygon() { + let bbox = [22.5, -21.943045533438166, 45.0, 0.0]; + + let polygon = Polygon::new( + LineString::from(vec![ + (bbox[0], bbox[1]), // bottom-left + (bbox[2], bbox[1]), // bottom-right + (bbox[2], bbox[3]), // top-right + (bbox[0], bbox[3]), // top-left + (bbox[0], bbox[1]), // back to bottom-left to close the loop + ]), + vec![], + ); + + let qb_cell = Cell::new(5209574053332910079); + + assert_eq!(qb_cell.to_polygon(), polygon) +} diff --git a/src/test/hashing.rs b/src/test/hashing.rs new file mode 100644 index 0000000..7db0050 --- /dev/null +++ b/src/test/hashing.rs @@ -0,0 +1,36 @@ +use crate::tiles::Tile; +use crate::utils::point_cover; + +#[test] +fn test_tile_hashing() { + let cases = [ + (Tile::new(0, 0, 0), 0_u64), + (Tile::new(1, 0, 1), 33_u64), + (Tile::new(0, 1, 1), 129_u64), + (Tile::new(0, 0, 2), 2_u64), + (Tile::new(13, 13, 13), 6816173_u64), + (Tile::new(46, 3584, 12), 939525580_u64), + (Tile::new(123, 321, 25), 689342254969_u64), + (Tile::new(8108, 14336, 14), 15032645006_u64), + ]; + + for (tile, hash) in cases.iter() { + // Tile to hash + assert_eq!(tile.to_hash(), *hash); + // Hash to tile + assert_eq!(Tile::from_hash(*hash), *tile); + } +} + +#[test] +fn test_point_hashing() { + let cases = [ + ((46.152, -52.222), 10_u8, 44978282_u64), + ((46.152, -52.222), 22_u8, 755128617831862_u64), + ((46.152, -52.222), 26_u8, 193312953173859226_u64), + ]; + + for (coords, res, hash) in cases.iter() { + assert_eq!(point_cover(coords.1, coords.0, *res), *hash); + } +} From d5c6451ad3cdc9af0c2b32355a601729ec117701 Mon Sep 17 00:00:00 2001 From: Anatolii Tsyplenkov Date: Wed, 30 Apr 2025 12:37:40 +1200 Subject: [PATCH 5/7] preparation to 0.2.0 release --- CHANGELOG.md | 5 +++++ Cargo.lock | 2 +- Cargo.toml | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 025acb8..f21d316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ Possible sections are: +## [0.2.0] - 2025-04-30 + +- Added basic support for geo-primitive types (#1), including encoding Points and MultiPoints into Quadbin Cells and decoding Quadbin Cells into Polygons. +- Significantly rewrote the codebase to make it more idiomatic. Most functions now return either `Result<>` or `Option<>`. +- Added benchmarks to evaluate performance and compare with `geohash` and `h3o`. ## [0.1.0] - 2025-04-26 diff --git a/Cargo.lock b/Cargo.lock index f70c71e..34fdb99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -560,7 +560,7 @@ dependencies = [ [[package]] name = "qbin" -version = "0.1.1" +version = "0.2.0" dependencies = [ "approx", "criterion", diff --git a/Cargo.toml b/Cargo.toml index c0a83dd..642c73a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "qbin" -version = "0.1.1" +version = "0.2.0" authors = ["Anatoly Tsyplenkov "] edition = "2024" license = "MIT" @@ -10,7 +10,7 @@ documentation = "https://docs.rs/qbin" readme = "README.md" description = "Encoding and decoding geographical coordinates to and from Quadbin, a hierarchical geospatial indexing system for square cells in Web Mercator projection developed by Carto. An improved version of Microsoft's Bing Maps Tile System, aka Quadkey." keywords = ["quadbin", "quadkey", "spatial", "spatial-index"] -categories = ["data-structures", "science::geo"] +categories = ["science::geo"] [dependencies] geo = "0.30.0" From 8889cbb21357fcf286a11954a9728a2d4b6804f2 Mon Sep 17 00:00:00 2001 From: Anatolii Tsyplenkov Date: Wed, 30 Apr 2025 12:38:53 +1200 Subject: [PATCH 6/7] fmt: whitespaces --- src/geo.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/geo.rs b/src/geo.rs index 73d7c36..5ad076d 100644 --- a/src/geo.rs +++ b/src/geo.rs @@ -53,11 +53,11 @@ impl Cell { } /// Converts Quadbin cell into [geo::Polygon] - /// + /// /// # Example /// ``` /// use qbin::Cell; - /// + /// /// // Create a Polygon out of the Cell's bounding box /// let polygon = Cell::new(5309133744805926483).to_polygon(); /// // Check if the polygon is of type Polygon and with no interior rings From 89752d02b1ddab6c7236cd7bfa5c58a6ed1ca91a Mon Sep 17 00:00:00 2001 From: Anatolii Tsyplenkov Date: Wed, 30 Apr 2025 13:02:01 +1200 Subject: [PATCH 7/7] ci: add CD --- .github/workflows/rust-cd.yml | 19 +++++++++++++++++++ README.md | 22 +++++++++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/rust-cd.yml diff --git a/.github/workflows/rust-cd.yml b/.github/workflows/rust-cd.yml new file mode 100644 index 0000000..1717693 --- /dev/null +++ b/.github/workflows/rust-cd.yml @@ -0,0 +1,19 @@ +name: Rust CD + +on: + push: + tags: + - "v*.*.*" + +jobs: + publish-crates-io: + name: Publish on crates.io + needs: publish-github + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Publish + run: cargo publish --locked --token ${{ secrets.CARGO_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 21ee9b5..a8a0f21 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@
+ +
@@ -26,29 +28,40 @@ A Rust implementation of **Quadbin**, a hierarchical geospatial index tiling app This crate is a complete rewrite of the original implementation in [JavaScript](https://github.com/CartoDB/quadbin-js) and [Python](https://github.com/CartoDB/quadbin-py). Learn more about Quadbin in the [CARTO documentation](https://docs.carto.com/data-and-analysis/analytics-toolbox-for-snowflake/sql-reference/quadbin). +## Features + +* Fast encoding and decoding of geographical coordinates, with comparable speed to [`geohash`](https://github.com/georust/geohash) and [`h3o`](https://github.com/HydroniumLabs/h3o/). See [benchmarks](https://github.com/atsyplenkov/qbin/tree/master/benches) for details. +* Quadbin indices are stored as `NonZeroU64` types, which occupy only 8 bytes. +* Supports geospatial primitive types from the [`geo`](https://github.com/georust/geo) crate. + ## Example ```rust use qbin::Cell; use approx::assert_relative_eq; -// Convert a point into a Quadbin cell +// Convert a point into a Quadbin Cell let longitude = -3.7038; let latitude = 40.4168; let resolution = 10_u8; let qb = Cell::from_point(latitude, longitude, resolution).expect("cell index"); assert_eq!(qb, Cell::try_from(5234261499580514303_u64).expect("cell index")); -// Get a point from a Quadbin cell +// Get a point from a Quadbin Cell let coords = Cell::new(5209574053332910079_u64).to_point(); assert_eq!(coords, [-11.178401873711776, 33.75]); -// Quadbin resolution at equator in m² +// Convert a Quadbin Cell into a Polygon +let polygon = qb.to_polygon(); +assert_eq!(polygon.num_interior_rings(), 0); + +// Get Quadbin resolution at equator in m² let area = Cell::from_point(0.0, 0.0, 26).expect("cell index").area_m2(); assert_relative_eq!(area, 0.36, epsilon = 1e-2) ``` ## Quadbin vs. Quadkey + Similar to Quadkey, Quadbin divides each tile into four sub-tiles with a minor difference in tiling approach. However, the key difference lies in how the tiles are indexed. **Quadkey** uses a variable-length index, where the number of digits corresponds to the resolution level. For example, a Quadkey can range from 1 to 23 digits long. This format inherently encodes the hierarchy, as each digit represents a parent tile, making it convenient for human interpretation. With just the Quadkey string, you can infer the location, resolution, and parent-child relationship of the tile. In contrast, **Quadbin** (in its current implementation) uses a fixed-length 64-bit index (`NonZeroU64`) with a constant length of 19 digits. The bit layout is as follows: @@ -70,13 +83,16 @@ This structure makes Quadbin a more memory-efficient way to store tile indices, For example, Australia and New Zealand are located in the third tile at Level 1, and in the second tile at Level 2. Their corresponding Quadkey would be `31` (since tile numbering starts at 0). However, in the Quadbin spatial indexing, the same location is represented by the Quadbin cell `5201094619659501567`. Another example: at the highest resolution possible, the best beer in Wellington can be found in the Quadbin index `5309133744805926483` (level 26) or Quadkey `31311100030030030211121` (level 23). ## Reasoning + This repository is a proof-of-concept project, where I practised writing Rust code, and, moreover, writing Rust with R and Python bindings as a single project. Recently, I was excited by the newly proposed [`raquet`](https://github.com/CartoDB/raquet) format by [CARTO](https://github.com/CartoDB) for storing raster data in Parquet files and was eager to try it in my projects. However, the `raquet` file specification and conversion are written in pure Python and heavily relies on `gdal`; therefore, instead of implementing R-to-Python, I decided to rewrite everything in Rust, merely for fun and practice. This repository is the first step towards native, GDAL-free raster to `raquet` conversion. ## License and Attribution + This project includes a reimplementation of logic based on [`quadbin-py`](https://github.com/CartoDB/quadbin-py) and [`quadbin-js`](https://github.com/CartoDB/quadbin-js) developed and maintained by CARTO, which are licensed under the BSD 3-Clause License. See [`LICENSE-THIRD-PARTY`](LICENSE-THIRD-PARTY) for full license text. ## See also + * [`quadbin-js`](https://github.com/CartoDB/quadbin-js) and [`quadbin-py`](https://github.com/CartoDB/quadbin-py) – the original **Quadbin** implementations in JavaScript and Python by CARTO; * [`geo-quadkey-rs`](https://github.com/masaishi/geo-quadkey-rs) – a Rust crate for Quadkey (Microsoft's Bing Maps Tile System); * [`quadkeyr`](https://docs.ropensci.org/quadkeyr/) – an R package for working with Quadkey (Microsoft's Bing Maps Tile System);