use bevy_ecs::component::Component;
use bevy_ecs::prelude::*;
use bevy_reflect::Reflect;
use bevy_transform::components::GlobalTransform;
use bevy_math::{DVec3, Vec3};
use a5::{
cell_area, cell_to_boundary, cell_to_children, cell_to_lonlat, cell_to_parent,
get_resolution, lonlat_to_cell, LonLat, WORLD_CELL,
};
use crate::coord;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Reflect)]
#[reflect(Component)]
pub struct GeoCell {
cell: u64,
}
impl GeoCell {
pub fn new(cell: u64) -> Self {
Self { cell }
}
pub fn from_lon_lat(longitude: f64, latitude: f64, resolution: i32) -> Option<Self> {
let lonlat = LonLat::new(longitude, latitude);
lonlat_to_cell(lonlat, resolution).ok().map(Self::new)
}
pub fn from_world_pos(pos: DVec3, resolution: i32) -> Option<Self> {
coord::world_pos_to_cell(pos, resolution).map(Self::new)
}
pub fn from_world_pos_f32(pos: Vec3, resolution: i32) -> Option<Self> {
Self::from_world_pos(pos.as_dvec3(), resolution)
}
pub fn raw(&self) -> u64 {
self.cell
}
pub fn center(&self) -> Option<LonLat> {
cell_to_lonlat(self.cell).ok()
}
pub fn resolution(&self) -> i32 {
get_resolution(self.cell)
}
pub fn parent(&self, resolution: i32) -> Option<Self> {
cell_to_parent(self.cell, Some(resolution))
.ok()
.map(Self::new)
}
pub fn parent_immediate(&self) -> Option<Self> {
cell_to_parent(self.cell, None).ok().map(Self::new)
}
pub fn children(&self, resolution: i32) -> Option<Vec<Self>> {
cell_to_children(self.cell, Some(resolution))
.ok()
.map(|cells| cells.into_iter().map(Self::new).collect())
}
pub fn children_immediate(&self) -> Option<Vec<Self>> {
cell_to_children(self.cell, None)
.ok()
.map(|cells| cells.into_iter().map(Self::new).collect())
}
pub fn boundary(&self) -> Option<Vec<LonLat>> {
cell_to_boundary(self.cell, None).ok()
}
pub fn area(&self) -> f64 {
cell_area(self.resolution())
}
pub fn is_world_cell(&self) -> bool {
self.cell == WORLD_CELL
}
}
impl Default for GeoCell {
fn default() -> Self {
Self { cell: WORLD_CELL }
}
}
impl From<u64> for GeoCell {
fn from(cell: u64) -> Self {
Self::new(cell)
}
}
impl From<GeoCell> for u64 {
fn from(cell: GeoCell) -> Self {
cell.cell
}
}
impl core::fmt::Display for GeoCell {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "A5Cell(0x{:x}, res={})", self.cell, self.resolution())
}
}
#[derive(Component, Debug, Clone, Reflect)]
#[reflect(Component)]
pub struct CellTracker {
pub resolution: i32,
last_resolved_at: Option<Vec3>,
}
impl CellTracker {
pub fn new(resolution: i32) -> Self {
Self {
resolution,
last_resolved_at: None,
}
}
pub fn invalidate(&mut self) {
self.last_resolved_at = None;
}
}
impl Default for CellTracker {
fn default() -> Self {
Self::new(9)
}
}
#[doc(hidden)]
#[derive(Default)]
pub struct BucketLookupCache {
resolution: Option<i32>,
inscribed_squared: f32,
bucket_size_m: f64,
entries: bevy_platform::collections::HashMap<(i32, i32), CachedCell>,
}
#[derive(Clone, Copy)]
struct CachedCell {
raw: u64,
centre: Vec3,
}
const BUCKET_CACHE_MAX_ENTRIES: usize = 65_536;
impl BucketLookupCache {
fn ensure_resolution(&mut self, resolution: i32) {
if self.resolution == Some(resolution) {
return;
}
self.entries.clear();
self.resolution = Some(resolution);
let half_edge = (a5::cell_area(resolution) as f32).sqrt() * 0.5;
self.inscribed_squared = half_edge * half_edge;
self.bucket_size_m = (half_edge * 0.5).max(1.0) as f64;
}
fn key_for(&self, pos: Vec3) -> (i32, i32) {
let bs = self.bucket_size_m;
(
(pos.x as f64 / bs).floor() as i32,
(pos.z as f64 / bs).floor() as i32,
)
}
fn try_lookup(&self, key: (i32, i32), pos: Vec3) -> Option<GeoCell> {
let cached = self.entries.get(&key)?;
if (pos - cached.centre).length_squared() < self.inscribed_squared {
Some(GeoCell::new(cached.raw))
} else {
None
}
}
fn store(&mut self, key: (i32, i32), cell: GeoCell, centre_world: Vec3) {
if self.entries.len() >= BUCKET_CACHE_MAX_ENTRIES {
self.entries.clear();
}
self.entries.insert(
key,
CachedCell {
raw: cell.raw(),
centre: centre_world,
},
);
}
}
pub fn update_geo_cells_from_world_pos(
planet: Res<crate::planet::PlanetSettings>,
mut cache: Local<BucketLookupCache>,
mut q: Query<(&GlobalTransform, &mut GeoCell, &mut CellTracker)>,
) {
for (gt, mut cell, mut tracker) in q.iter_mut() {
let world = gt.translation();
let half_edge = (a5::cell_area(tracker.resolution) as f32).sqrt() * 0.5;
let needs_resolve = match tracker.last_resolved_at {
None => true,
Some(last) => (world - last).length_squared() > half_edge * half_edge,
};
if !needs_resolve {
continue;
}
cache.ensure_resolution(tracker.resolution);
let key = cache.key_for(world);
let new_cell = if let Some(c) = cache.try_lookup(key, world) {
c
} else {
let Some(c) = GeoCell::from_world_pos_f32(world, tracker.resolution) else {
continue;
};
if let Some(centre) = coord::cell_to_dvec3(c.raw(), planet.radius) {
cache.store(key, c, centre.as_vec3());
}
c
};
cell.set_if_neq(new_cell);
tracker.last_resolved_at = Some(world);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy_ecs::schedule::Schedule;
use bevy_ecs::world::World;
use bevy_transform::components::{GlobalTransform, Transform};
fn world_with_planet() -> World {
let mut world = World::new();
world.insert_resource(crate::planet::PlanetSettings::earth());
world
}
#[test]
fn cell_tracker_skips_resolve_within_half_edge() {
let mut world = world_with_planet();
let resolution = 9;
let half_edge = (a5::cell_area(resolution) as f32).sqrt() * 0.5;
let initial_pos = Vec3::new(6_400_000.0, 0.0, 0.0);
let initial_cell = GeoCell::from_world_pos_f32(initial_pos, resolution).unwrap();
let mut tracker = CellTracker::new(resolution);
tracker.last_resolved_at = Some(initial_pos);
let nudge = Vec3::new(half_edge * 0.4, 0.0, 0.0);
let entity = world
.spawn((
initial_cell,
tracker,
GlobalTransform::from(Transform::from_translation(initial_pos + nudge)),
))
.id();
let mut schedule = Schedule::default();
schedule.add_systems(update_geo_cells_from_world_pos);
schedule.run(&mut world);
let tracker_after = world.get::<CellTracker>(entity).unwrap();
assert_eq!(
tracker_after.last_resolved_at,
Some(initial_pos),
"tracker should not have re-resolved after a sub-half-edge nudge"
);
let cell_after = *world.get::<GeoCell>(entity).unwrap();
assert_eq!(cell_after, initial_cell);
}
#[test]
fn cell_tracker_resolves_on_first_pass() {
let mut world = world_with_planet();
let pos = Vec3::new(6_400_000.0, 0.0, 0.0);
let entity = world
.spawn((
GeoCell::default(),
CellTracker::new(7),
GlobalTransform::from(Transform::from_translation(pos)),
))
.id();
let mut schedule = Schedule::default();
schedule.add_systems(update_geo_cells_from_world_pos);
schedule.run(&mut world);
let tracker_after = world.get::<CellTracker>(entity).unwrap();
assert!(tracker_after.last_resolved_at.is_some());
let cell_after = *world.get::<GeoCell>(entity).unwrap();
assert_eq!(cell_after.resolution(), 7);
let expected = GeoCell::from_world_pos_f32(pos, 7).unwrap();
assert_eq!(cell_after, expected);
}
#[test]
fn bucket_cache_returns_same_cell_as_uncached_path() {
let mut world = world_with_planet();
let resolution = 7;
let pos_a = Vec3::new(6_400_000.0, 0.0, 0.0);
let pos_b = pos_a + Vec3::new(1.0, 0.0, 0.0);
let entity_a = world
.spawn((
GeoCell::default(),
CellTracker::new(resolution),
GlobalTransform::from(Transform::from_translation(pos_a)),
))
.id();
let entity_b = world
.spawn((
GeoCell::default(),
CellTracker::new(resolution),
GlobalTransform::from(Transform::from_translation(pos_b)),
))
.id();
let mut schedule = Schedule::default();
schedule.add_systems(update_geo_cells_from_world_pos);
schedule.run(&mut world);
let cell_a = *world.get::<GeoCell>(entity_a).unwrap();
let cell_b = *world.get::<GeoCell>(entity_b).unwrap();
let direct = GeoCell::from_world_pos_f32(pos_b, resolution).unwrap();
assert_eq!(cell_a, direct);
assert_eq!(cell_b, direct);
}
#[test]
fn bucket_cache_invalidates_on_resolution_change() {
let mut world = world_with_planet();
let pos = Vec3::new(6_400_000.0, 0.0, 0.0);
let entity = world
.spawn((
GeoCell::default(),
CellTracker::new(7),
GlobalTransform::from(Transform::from_translation(pos)),
))
.id();
let mut schedule = Schedule::default();
schedule.add_systems(update_geo_cells_from_world_pos);
schedule.run(&mut world);
assert_eq!(world.get::<GeoCell>(entity).unwrap().resolution(), 7);
{
let mut tracker = world.get_mut::<CellTracker>(entity).unwrap();
tracker.resolution = 9;
tracker.invalidate();
}
schedule.run(&mut world);
assert_eq!(world.get::<GeoCell>(entity).unwrap().resolution(), 9);
}
#[test]
fn children_returns_uniform_resolution() {
let world = GeoCell::new(WORLD_CELL);
for res in [3, 5, 7] {
let kids = world.children(res).expect("children should succeed");
assert!(!kids.is_empty(), "no children at res {}", res);
for c in &kids {
assert_eq!(c.resolution(), res);
}
}
}
}