bevy_a5 0.1.2

A Bevy plugin providing A5 geospatial pentagonal cells for floating origin use and spatial queries
Documentation
//! Example: cell-focused coordinate system, on top of the `planet_grid` demo.
//!
//! This is `planet_grid` with one addition: a HUD that prints, every frame,
//!   - the A5 cell currently under the camera
//!   - the camera's *local* heading (compass bearing in the cell's tangent
//!     frame: 0° = north, 90° = east, …)
//!   - the camera's *global* heading (world-space forward vector)
//!   - the camera's position in world XYZ, lon/lat, and altitude
//!
//! The cell ID is computed *live* from the camera's world position via
//! [`GeoCell::from_world_pos_f32`], so the value is always current and
//! independent of the floating-origin recentre system. This example uses
//! the camera-far-from-planet visualization style (same as `planet_grid`),
//! which doesn't exercise the floating-origin recentre — see the README
//! for the surface-walking variant where recentres actually fire.
//!
//! Run with:
//!   cargo run --example cell_origin

use bevy::prelude::*;
use bevy_a5::camera::FlyCam;
use bevy_a5::coord::tangent_frame;
use bevy_a5::geometry::build_grid_line_mesh;
use bevy_a5::prelude::*;
use bevy_a5::WORLD_CELL;
use bevy_math::DVec3;

/// Planet radius in world units (scaled down so it fits on screen).
const PLANET_RADIUS: f64 = 100.0;
/// A5 resolution for the grid. Cell counts: r3=192, r4=768, r5=3072, r6=12288.
const RESOLUTION: i32 = 5;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(BevyA5Plugins)
        .insert_resource(PlanetSettings::earth().with_radius(PLANET_RADIUS))
        .add_systems(Startup, setup)
        .add_systems(Update, update_hud)
        .run();
}

#[derive(Component)]
struct Hud;

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    // Planet sphere (slightly smaller than the grid radius so the lines sit on top).
    commands.spawn((
        Mesh3d(meshes.add(Sphere::new(PLANET_RADIUS as f32 * 0.99))),
        MeshMaterial3d(materials.add(StandardMaterial {
            base_color: Color::linear_rgb(0.05, 0.10, 0.20),
            ..default()
        })),
        Transform::default(),
    ));

    // Fly camera with the reusable FlyCam controller from bevy_a5::camera.
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(0.0, 0.0, PLANET_RADIUS as f32 * 2.5)
            .looking_at(Vec3::ZERO, Vec3::Y),
        FlyCam {
            speed: 80.0,
            boost: 4.0,
            sensitivity: 0.003,
            mouse_look: true,
        },
    ));

    commands.spawn((
        DirectionalLight {
            illuminance: 18_000.0,
            shadows_enabled: false,
            ..default()
        },
        Transform::from_xyz(300.0, 500.0, 300.0).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    // Build one LineList mesh containing every cell boundary on the planet.
    let world = GeoCell::new(WORLD_CELL);
    let cells = world.children(RESOLUTION).unwrap_or_default();
    let grid = build_grid_line_mesh(&cells, PLANET_RADIUS);

    commands.spawn((
        Mesh3d(meshes.add(grid)),
        MeshMaterial3d(materials.add(StandardMaterial {
            base_color: Color::srgb(0.0, 1.0, 0.75),
            emissive: LinearRgba::new(0.0, 1.8, 1.2, 1.0),
            unlit: true,
            ..default()
        })),
    ));

    // HUD readout in the top-left corner.
    commands.spawn((
        Hud,
        Text::new(""),
        TextFont {
            font_size: 14.0,
            ..default()
        },
        TextColor(Color::WHITE),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(8.0),
            left: Val::Px(12.0),
            padding: UiRect::axes(Val::Px(10.0), Val::Px(8.0)),
            ..default()
        },
        BackgroundColor(Color::linear_rgba(0.0, 0.0, 0.0, 0.65)),
    ));

    info!(
        "A5 planet grid ready — {} cells at resolution {} on a sphere of radius {}",
        cells.len(),
        RESOLUTION,
        PLANET_RADIUS,
    );
    info!("Controls: WASD to fly, Q/E down/up, Shift to boost, mouse to look (Esc releases cursor)");
}

/// HUD: cell, local heading, global heading, position. Cell ID is recomputed
/// every frame from the camera's world position so it always tracks the
/// camera, independent of the floating-origin recentre system.
fn update_hud(
    camera_q: Query<&Transform, With<FlyCam>>,
    mut hud_q: Query<&mut Text, With<Hud>>,
) {
    let Ok(transform) = camera_q.single() else {
        return;
    };
    let Ok(mut text) = hud_q.single_mut() else {
        return;
    };

    let world_pos = transform.translation;
    let world_pos_d = world_pos.as_dvec3();

    // Live cell lookup from world position.
    let cell = GeoCell::from_world_pos(world_pos_d, RESOLUTION);
    let cell_str = cell
        .map(|c| format!("0x{:016x}  (res {})", c.raw(), c.resolution()))
        .unwrap_or_else(|| "n/a".to_string());

    let ll = bevy_a5::coord::dvec3_to_lonlat(world_pos_d);
    let (lon_str, lat_str, local_heading_str) = match ll {
        Some(ll) => {
            let lon = ll.longitude();
            let lat = ll.latitude();

            // Project the camera's world-space forward onto the local tangent
            // plane and measure compass bearing (0° = N, 90° = E).
            let (east, up, north) = tangent_frame(&ll);
            let fwd_world: DVec3 = Vec3::from(transform.forward()).as_dvec3();
            let fwd_tangent = fwd_world - up * fwd_world.dot(up);
            let bearing_str = if fwd_tangent.length_squared() < 1e-12 {
                "—  (looking straight up/down)".to_string()
            } else {
                let n = fwd_tangent.dot(north);
                let e = fwd_tangent.dot(east);
                let mut deg = e.atan2(n).to_degrees();
                if deg < 0.0 {
                    deg += 360.0;
                }
                format!("{:>6.1}°  ({})", deg, compass_label(deg))
            };

            (
                format!("{:>9.4}°", lon),
                format!("{:>9.4}°", lat),
                bearing_str,
            )
        }
        None => ("    n/a".into(), "    n/a".into(), "n/a".into()),
    };

    let altitude = world_pos_d.length() - PLANET_RADIUS;
    let fwd = Vec3::from(transform.forward());

    text.0 = format!(
        "Camera\n\
         \n\
         Cell:           {}\n\
         \n\
         Local heading:  {}\n\
         Global heading: ({:>6.3}, {:>6.3}, {:>6.3})\n\
         \n\
         Position:\n\
           World XYZ:    ({:>8.2}, {:>8.2}, {:>8.2})\n\
           Lon / Lat:    {} / {}\n\
           Altitude:     {:>8.2}\n\
         \n\
         WASD/QE fly   Shift boost   Mouse look   Esc release",
        cell_str,
        local_heading_str,
        fwd.x,
        fwd.y,
        fwd.z,
        world_pos.x,
        world_pos.y,
        world_pos.z,
        lon_str,
        lat_str,
        altitude,
    );
}

fn compass_label(deg: f64) -> &'static str {
    let bins = [
        (22.5, "N"),
        (67.5, "NE"),
        (112.5, "E"),
        (157.5, "SE"),
        (202.5, "S"),
        (247.5, "SW"),
        (292.5, "W"),
        (337.5, "NW"),
    ];
    for (limit, label) in bins {
        if deg < limit {
            return label;
        }
    }
    "N"
}