plozone 0.1.2

3D spatial zone engine: geofencing, octree hole-scanning, realtime sync (WebSocket + QUIC + io_uring), voxel pathfinding, and AV sensor fusion.
Documentation
//! Global-scale tiled world (feature `tiled`).
//!
//! A single ENU origin accumulates flat-plane error beyond ~100 km. The tiled
//! world splits the globe into a grid of tiles, each with its own ENU origin and
//! its own [`ZoneStore`] / [`OctreeNode`]. Queries near a boundary fan out to
//! the eight neighbouring tiles.

use std::collections::HashMap;

use crate::coord::EnuConverter;
use crate::octree::OctreeNode;
use crate::store::ZoneStore;
use crate::zone::{Zone, ZoneEntry};

/// Web-map-style tile key: a `zoom`/`tx`/`ty` grid over lat/lon.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct TileKey {
    pub zoom: u8,
    pub tx: i32,
    pub ty: i32,
}

impl TileKey {
    /// Tile edge length in degrees at this zoom.
    pub fn deg_size(zoom: u8) -> f64 {
        360.0 / (1u64 << zoom) as f64
    }

    /// Which tile a geodetic point falls into.
    pub fn from_geodetic(lat: f64, lon: f64, zoom: u8) -> Self {
        let s = Self::deg_size(zoom);
        Self {
            zoom,
            tx: ((lon + 180.0) / s).floor() as i32,
            ty: ((lat + 90.0) / s).floor() as i32,
        }
    }

    /// ENU origin (south-west corner, sea level) of this tile.
    pub fn origin(&self) -> (f64, f64) {
        let s = Self::deg_size(self.zoom);
        let lat = self.ty as f64 * s - 90.0;
        let lon = self.tx as f64 * s - 180.0;
        (lat, lon)
    }

    /// Self plus the eight surrounding tiles (for cross-boundary queries).
    pub fn neighbors(&self) -> [TileKey; 9] {
        let mut out = [*self; 9];
        let mut i = 0;
        for dy in -1i32..=1 {
            for dx in -1i32..=1 {
                out[i] = TileKey { zoom: self.zoom, tx: self.tx + dx, ty: self.ty + dy };
                i += 1;
            }
        }
        out
    }
}

/// One tile: an ENU converter, a zone store, and an octree, all local to the
/// tile's origin.
pub struct WorldTile {
    pub key: TileKey,
    pub conv: EnuConverter,
    pub store: ZoneStore,
    pub octree: OctreeNode,
}

impl WorldTile {
    /// Create an empty tile anchored at its south-west corner.
    pub fn new(key: TileKey) -> Self {
        let (lat, lon) = key.origin();
        let conv = EnuConverter::new(lat, lon, 0.0);
        let store = ZoneStore::from_entries(&[], &conv);
        Self {
            key,
            conv,
            store,
            octree: OctreeNode::new([0.0; 3], 50_000.0), // ~100 km tile
        }
    }
}

/// A lazily-populated global world keyed by [`TileKey`].
pub struct TiledWorld {
    pub zoom: u8,
    pub tiles: HashMap<TileKey, WorldTile>,
}

impl TiledWorld {
    /// New empty world at the given zoom (e.g. 14 → ~2.4 km tiles).
    pub fn new(zoom: u8) -> Self {
        Self { zoom, tiles: HashMap::new() }
    }

    fn ensure_tile(&mut self, key: TileKey) {
        self.tiles.entry(key).or_insert_with(|| WorldTile::new(key));
    }

    fn tile_for(&mut self, lat: f64, lon: f64) -> &mut WorldTile {
        let key = TileKey::from_geodetic(lat, lon, self.zoom);
        self.tiles.entry(key).or_insert_with(|| WorldTile::new(key))
    }

    /// Insert a point into the octree of the tile that owns it.
    pub fn insert_point(&mut self, lat: f64, lon: f64, alt: f64) {
        let tile = self.tile_for(lat, lon);
        let enu = tile.conv.to_enu(lat, lon, alt);
        tile.octree.insert(enu, 8);
    }

    /// Query zones at a geodetic point, fanning out to the eight neighbouring
    /// tiles to catch zones whose footprint crosses a boundary. Each result is
    /// `(tile, zone ids in that tile)`.
    pub fn query_geodetic(&self, lat: f64, lon: f64, alt: f64) -> Vec<(TileKey, Vec<u32>)> {
        let key = TileKey::from_geodetic(lat, lon, self.zoom);
        let mut results = vec![];
        for nk in key.neighbors() {
            if let Some(tile) = self.tiles.get(&nk) {
                let enu = tile.conv.to_enu(lat, lon, alt);
                let hits = tile.store.query_enu(enu);
                if !hits.is_empty() {
                    results.push((nk, hits.to_vec()));
                }
            }
        }
        results
    }

    /// Add a zone into every tile its geodetic AABB spans.
    pub fn add_zone(&mut self, entry: ZoneEntry) {
        for key in self.tiles_for_zone(&entry.zone) {
            self.ensure_tile(key);
            let tile = self.tiles.get_mut(&key).unwrap();
            let conv = tile.conv;
            tile.store.add_zone(entry.id, &entry.zone, &conv);
        }
    }

    /// All tile keys overlapping a zone's geodetic AABB.
    fn tiles_for_zone(&self, zone: &Zone) -> Vec<TileKey> {
        let (min_lat, min_lon, max_lat, max_lon) = zone_geodetic_bbox(zone);
        let deg = TileKey::deg_size(self.zoom);
        let tx0 = ((min_lon + 180.0) / deg).floor() as i32;
        let ty0 = ((min_lat + 90.0) / deg).floor() as i32;
        let tx1 = ((max_lon + 180.0) / deg).floor() as i32;
        let ty1 = ((max_lat + 90.0) / deg).floor() as i32;
        let mut keys = vec![];
        for ty in ty0..=ty1 {
            for tx in tx0..=tx1 {
                keys.push(TileKey { zoom: self.zoom, tx, ty });
            }
        }
        keys
    }
}

/// Geodetic bounding box of a zone as `(min_lat, min_lon, max_lat, max_lon)`.
pub fn zone_geodetic_bbox(zone: &Zone) -> (f64, f64, f64, f64) {
    match zone {
        Zone::Aabb { min, max } => (min[0], min[1], max[0], max[1]),
        Zone::Cylinder { center, radius_m, .. } => {
            // 1 deg lat ≈ 111_111 m; widen lon by the latitude cosine.
            let dlat = radius_m / 111_111.0;
            let dlon = radius_m / (111_111.0 * center[0].to_radians().cos());
            (center[0] - dlat, center[1] - dlon, center[0] + dlat, center[1] + dlon)
        }
        Zone::ExtrudedPolygon { ring, .. } => bbox_of(ring.iter().map(|v| (v[0], v[1]))),
        Zone::ConvexHull { vertices } => bbox_of(vertices.iter().map(|v| (v[0], v[1]))),
    }
}

fn bbox_of(coords: impl Iterator<Item = (f64, f64)>) -> (f64, f64, f64, f64) {
    let (mut min_lat, mut min_lon, mut max_lat, mut max_lon) =
        (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    for (lat, lon) in coords {
        min_lat = min_lat.min(lat);
        min_lon = min_lon.min(lon);
        max_lat = max_lat.max(lat);
        max_lon = max_lon.max(lon);
    }
    (min_lat, min_lon, max_lat, max_lon)
}

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

    #[test]
    fn tile_origin_is_south_west_of_point() {
        let zoom = 14;
        let (lat, lon) = (10.7626, 106.6601);
        let key = TileKey::from_geodetic(lat, lon, zoom);
        let (olat, olon) = key.origin();
        let s = TileKey::deg_size(zoom);
        assert!(olat <= lat && lat < olat + s, "lat in tile: {olat}..{}", olat + s);
        assert!(olon <= lon && lon < olon + s, "lon in tile: {olon}..{}", olon + s);
    }

    #[test]
    fn neighbors_include_self_and_eight() {
        let key = TileKey { zoom: 10, tx: 5, ty: 7 };
        let n = key.neighbors();
        assert_eq!(n.len(), 9);
        assert!(n.contains(&key));
        assert!(n.contains(&TileKey { zoom: 10, tx: 4, ty: 6 }));
        assert!(n.contains(&TileKey { zoom: 10, tx: 6, ty: 8 }));
    }

    #[test]
    fn add_zone_then_query_finds_it() {
        let mut world = TiledWorld::new(14);
    world.add_zone(ZoneEntry::new(
        7,
        Zone::Cylinder { center: [10.7626, 106.6601], radius_m: 40.0, z_min: 0.0, z_max: 20.0 },
    ));
        let hits = world.query_geodetic(10.7626, 106.6601, 5.0);
        let ids: Vec<u32> = hits.iter().flat_map(|(_, v)| v.iter().copied()).collect();
        assert!(ids.contains(&7), "expected zone 7, got {hits:?}");
    }

    #[test]
    fn query_empty_world_is_empty() {
        let world = TiledWorld::new(12);
        assert!(world.query_geodetic(0.0, 0.0, 0.0).is_empty());
    }

    #[test]
    fn cylinder_bbox_brackets_center() {
        let z = Zone::Cylinder { center: [48.0, 11.0], radius_m: 1000.0, z_min: 0.0, z_max: 1.0 };
        let (min_lat, min_lon, max_lat, max_lon) = zone_geodetic_bbox(&z);
        assert!(min_lat < 48.0 && 48.0 < max_lat);
        assert!(min_lon < 11.0 && 11.0 < max_lon);
        // ~1 km ≈ 0.009 deg lat.
        assert!((max_lat - min_lat - 0.018).abs() < 0.001, "lat span ≈ 0.018");
    }

    #[test]
    fn insert_point_creates_tile() {
        let mut world = TiledWorld::new(14);
        assert_eq!(world.tiles.len(), 0);
        world.insert_point(10.7626, 106.6601, 5.0);
        assert_eq!(world.tiles.len(), 1);
    }
}