bevy_a5 0.1.2

A Bevy plugin providing A5 geospatial pentagonal cells for floating origin use and spatial queries
Documentation
//! Spatial query utilities for A5 cells.
//!
//! Provides convenient wrappers around the a5 crate's grid traversal functions
//! for querying neighboring cells, disks, and spherical caps.
//!
//! # Resolution invariant
//!
//! Every function in this module that takes a `&GeoCell` (or implies a
//! resolution via its arguments) and returns a `Vec<GeoCell>` is guaranteed
//! to return cells at **a single uniform resolution** — the resolution of
//! the input cell. This is true even when the underlying `a5` crate would
//! return a mixed-resolution compacted set for efficiency: we uncompact at
//! the boundary so callers never see partial-resolution results.
//!
//! The two explicit exceptions are [`compact`] and [`uncompact`], which
//! exist precisely to convert between the two representations.

use crate::cell::GeoCell;

/// Get all cells within `k` grid steps of the given cell (a filled disk).
///
/// Includes the center cell itself. Every returned cell is at
/// `cell.resolution()`; if the underlying `a5` traversal returns coarser
/// cells (which it can do near pentagon boundaries), we uncompact them.
///
/// Returns `None` if the cell index is invalid.
pub fn grid_disk(cell: &GeoCell, k: usize) -> Option<Vec<GeoCell>> {
    let resolution = cell.resolution();
    let raw = a5::grid_disk(cell.raw(), k).ok()?;
    let uniform = a5::uncompact(&raw, resolution).ok()?;
    Some(uniform.into_iter().map(GeoCell::new).collect())
}

/// Get all cells within `k` grid steps, returned as a flat list with vertex info.
///
/// Every returned cell is at `cell.resolution()`.
///
/// Returns `None` if the cell index is invalid.
pub fn grid_disk_vertex(cell: &GeoCell, k: usize) -> Option<Vec<GeoCell>> {
    let resolution = cell.resolution();
    let raw = a5::grid_disk_vertex(cell.raw(), k).ok()?;
    let uniform = a5::uncompact(&raw, resolution).ok()?;
    Some(uniform.into_iter().map(GeoCell::new).collect())
}

/// Get the immediate **edge**-adjacent neighbors of a cell (the grid disk
/// at k=1, excluding the centre).
///
/// A5 cells are pentagons; typically returns 5 cells. Every returned cell
/// is at `cell.resolution()`.
///
/// Use [`vertex_neighbors`] when you want every cell that shares at least
/// one vertex with the centre — that's a strictly larger set and is more
/// robust around A5's special pentagon vertices, where edge-adjacency can
/// miss diagonal neighbours.
///
/// Returns `None` if the cell index is invalid.
pub fn neighbors(cell: &GeoCell) -> Option<Vec<GeoCell>> {
    let resolution = cell.resolution();
    let raw = a5::grid_disk(cell.raw(), 1).ok()?;
    let uniform = a5::uncompact(&raw, resolution).ok()?;
    Some(
        uniform
            .into_iter()
            .filter(|&c| c != cell.raw())
            .map(GeoCell::new)
            .collect(),
    )
}

/// Get the immediate **vertex**-adjacent neighbours of a cell — every cell
/// sharing at least one vertex with this one (`grid_disk_vertex` at k=1,
/// excluding the centre).
///
/// This is a strict superset of [`neighbors`]: every edge-adjacent cell
/// shares two vertices with the centre, but cells that share only a single
/// vertex (the diagonals around A5's special pentagon vertices) are picked
/// up here too. Prefer this for proximity-classification use cases where
/// "is this cell *touching* mine?" matters and you don't want gaps near
/// the icosahedral vertices of the A5 tiling.
///
/// Every returned cell is at `cell.resolution()`.
///
/// Returns `None` if the cell index is invalid.
pub fn vertex_neighbors(cell: &GeoCell) -> Option<Vec<GeoCell>> {
    let resolution = cell.resolution();
    let raw = a5::grid_disk_vertex(cell.raw(), 1).ok()?;
    let uniform = a5::uncompact(&raw, resolution).ok()?;
    Some(
        uniform
            .into_iter()
            .filter(|&c| c != cell.raw())
            .map(GeoCell::new)
            .collect(),
    )
}

/// Find all cells within a spherical cap around a center cell.
///
/// `radius` is in meters on an authalic Earth sphere.
///
/// More efficient than `grid_disk` for large radii because it uses A5's
/// hierarchical subdivision to prune the search space. Internally this calls
/// `a5::spherical_cap` (which returns a mixed-resolution compacted set for
/// efficiency) and then `a5::uncompact` to expand back to the centre cell's
/// resolution. Every cell in the returned vec is guaranteed to be at
/// `center.resolution()`.
///
/// Returns `None` if the computation fails.
pub fn spherical_cap(center: &GeoCell, radius: f64) -> Option<Vec<GeoCell>> {
    let resolution = center.resolution();
    let mixed = a5::spherical_cap(center.raw(), radius).ok()?;
    let uniform = a5::uncompact(&mixed, resolution).ok()?;
    Some(uniform.into_iter().map(GeoCell::new).collect())
}

/// Get all base cells at resolution 0 (the 12 dodecahedron faces).
pub fn res0_cells() -> Vec<GeoCell> {
    a5::get_res0_cells()
        .unwrap_or_default()
        .into_iter()
        .map(GeoCell::new)
        .collect()
}

/// Get the total number of cells at a given resolution.
pub fn num_cells(resolution: i32) -> u64 {
    a5::get_num_cells(resolution)
}

/// Compact a set of cells into a minimal representation using parent cells
/// where all children are present.
pub fn compact(cells: &[GeoCell]) -> Vec<GeoCell> {
    let raw: Vec<u64> = cells.iter().map(|c| c.raw()).collect();
    a5::compact(&raw)
        .unwrap_or_default()
        .into_iter()
        .map(GeoCell::new)
        .collect()
}

/// Expand compacted cells back to a uniform resolution.
pub fn uncompact(cells: &[GeoCell], resolution: i32) -> Vec<GeoCell> {
    let raw: Vec<u64> = cells.iter().map(|c| c.raw()).collect();
    a5::uncompact(&raw, resolution)
        .unwrap_or_default()
        .into_iter()
        .map(GeoCell::new)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn neighbors_returns_cells() {
        let cell = GeoCell::from_lon_lat(2.3522, 48.8566, 5).unwrap();
        let n = neighbors(&cell).unwrap();
        assert!(!n.is_empty());
        // Hexagons have 5 or 6 neighbors
        assert!(n.len() >= 3 && n.len() <= 6);
    }

    #[test]
    fn grid_disk_includes_center() {
        let cell = GeoCell::from_lon_lat(2.3522, 48.8566, 5).unwrap();
        let disk = grid_disk(&cell, 1).unwrap();
        assert!(disk.contains(&cell));
    }

    #[test]
    fn res0_cells_count() {
        let cells = res0_cells();
        // A5 has 12 base cells at resolution 0
        assert!(!cells.is_empty());
    }

    #[test]
    fn spherical_cap_returns_uniform_resolution() {
        // Pick a high-ish resolution where `a5::spherical_cap` would normally
        // return a mixed-resolution compacted set.
        for res in [5, 7, 9] {
            let center = GeoCell::from_lon_lat(2.3522, 48.8566, res).unwrap();
            // Radius wide enough that the underlying a5 query keeps interior
            // cells coarse — uncompact must expand them back to `res`.
            let cells = spherical_cap(&center, 50_000.0).expect("cap should succeed");
            assert!(!cells.is_empty(), "spherical_cap empty at res {}", res);
            for cell in &cells {
                assert_eq!(
                    cell.resolution(),
                    res,
                    "spherical_cap leaked resolution {} cell at requested res {}",
                    cell.resolution(),
                    res
                );
            }
        }
    }

    #[test]
    fn vertex_neighbors_includes_edge_neighbours_and_more() {
        let cell = GeoCell::from_lon_lat(2.3522, 48.8566, 5).unwrap();
        let edge = neighbors(&cell).unwrap();
        let vertex = vertex_neighbors(&cell).unwrap();
        // Every edge-adjacent cell must also be vertex-adjacent.
        let vertex_set: std::collections::HashSet<u64> =
            vertex.iter().map(|c| c.raw()).collect();
        for c in &edge {
            assert!(
                vertex_set.contains(&c.raw()),
                "edge-neighbour {:#x} missing from vertex_neighbors",
                c.raw()
            );
        }
        // And vertex-adjacent should be a strict superset (or equal in degenerate cases).
        assert!(
            vertex.len() >= edge.len(),
            "vertex_neighbors ({}) should be ≥ neighbors ({})",
            vertex.len(),
            edge.len()
        );
        // Uniform resolution.
        for c in &vertex {
            assert_eq!(c.resolution(), 5);
            assert_ne!(c.raw(), cell.raw());
        }
    }

    #[test]
    fn grid_disk_returns_uniform_resolution() {
        let center = GeoCell::from_lon_lat(2.3522, 48.8566, 7).unwrap();
        let disk = grid_disk(&center, 4).expect("grid_disk should succeed");
        for cell in &disk {
            assert_eq!(cell.resolution(), 7);
        }
    }
}