rustial-renderer-bevy 1.0.0

Bevy Engine renderer for the rustial 2.5D map engine
//! Shared viewport-based visibility checks for visualization overlays.

use glam::DVec3;
use rustial_engine::{ColumnInstanceSet, GeoGrid, MapState, PointInstance, PointInstanceSet, WorldBounds, WorldCoord};
use rustial_engine as rustial_math;

/// Return whether a georeferenced grid intersects the current scene viewport bounds.
pub fn grid_intersects_scene_viewport(
    grid: &GeoGrid,
    state: &MapState,
    scene_origin: DVec3,
) -> bool {
    let bounds = grid_world_bounds(grid, state, scene_origin);
    bounds.intersects(state.scene_viewport_bounds())
}

/// Return whether any point in the set intersects the current scene viewport bounds.
pub fn points_intersect_scene_viewport(
    points: &PointInstanceSet,
    state: &MapState,
    scene_origin: DVec3,
) -> bool {
    let Some(bounds) = point_world_bounds(points, state, scene_origin) else {
        return false;
    };
    bounds.intersects(state.scene_viewport_bounds())
}

/// Return whether any column in the set intersects the current scene viewport bounds.
pub fn columns_intersect_scene_viewport(
    columns: &ColumnInstanceSet,
    state: &MapState,
    scene_origin: DVec3,
) -> bool {
    let Some(bounds) = column_world_bounds(columns, state, scene_origin) else {
        return false;
    };
    bounds.intersects(state.scene_viewport_bounds())
}

fn grid_world_bounds(
    grid: &GeoGrid,
    state: &MapState,
    scene_origin: DVec3,
) -> WorldBounds {
    let corners = [
        project_grid_corner(grid, 0, 0, state, scene_origin),
        project_grid_corner(grid, 0, grid.cols, state, scene_origin),
        project_grid_corner(grid, grid.rows, 0, state, scene_origin),
        project_grid_corner(grid, grid.rows, grid.cols, state, scene_origin),
    ];

    let mut bounds = WorldBounds::new(
        WorldCoord::new(corners[0].x, corners[0].y, corners[0].z),
        WorldCoord::new(corners[0].x, corners[0].y, corners[0].z),
    );
    for corner in corners.into_iter().skip(1) {
        bounds.extend_point(&WorldCoord::new(corner.x, corner.y, corner.z));
    }
    bounds
}

fn project_grid_corner(
    grid: &GeoGrid,
    row: usize,
    col: usize,
    state: &MapState,
    scene_origin: DVec3,
) -> glam::DVec3 {
    let dx = col as f64 * grid.cell_width;
    let dy = row as f64 * grid.cell_height;
    let (sin_r, cos_r) = grid.rotation.sin_cos();
    let rx = dx * cos_r - dy * sin_r;
    let ry = dx * sin_r + dy * cos_r;
    let coord = offset_geo_coord(&grid.origin, rx, ry);
    let altitude = resolve_grid_surface_altitude(grid, &coord, state);
    let projected = state.camera().projection().project(&rustial_math::GeoCoord::new(
        coord.lat,
        coord.lon,
        altitude,
    ));
    glam::DVec3::new(
        projected.position.x - scene_origin.x,
        projected.position.y - scene_origin.y,
        projected.position.z - scene_origin.z,
    )
}

fn column_world_bounds(
    columns: &ColumnInstanceSet,
    state: &MapState,
    scene_origin: DVec3,
) -> Option<WorldBounds> {
    let mut bounds: Option<WorldBounds> = None;

    for column in &columns.columns {
        let projected = state.camera().projection().project(&column.position);
        let base_z = resolve_column_base_altitude(column, state);
        let half_width = column.width * 0.5;
        let min = WorldCoord::new(
            projected.position.x - scene_origin.x - half_width,
            projected.position.y - scene_origin.y - half_width,
            base_z - scene_origin.z,
        );
        let max = WorldCoord::new(
            projected.position.x - scene_origin.x + half_width,
            projected.position.y - scene_origin.y + half_width,
            base_z - scene_origin.z + column.height,
        );
        let column_bounds = WorldBounds::new(min, max);
        if let Some(existing) = bounds.as_mut() {
            existing.extend(&column_bounds);
        } else {
            bounds = Some(column_bounds);
        }
    }

    bounds
}

fn point_world_bounds(
    points: &PointInstanceSet,
    state: &MapState,
    scene_origin: DVec3,
) -> Option<WorldBounds> {
    let mut bounds: Option<WorldBounds> = None;

    for point in &points.points {
        let projected = state.camera().projection().project(&point.position);
        let center_z = resolve_point_altitude(point, state);
        let radius = point.radius;
        let min = WorldCoord::new(
            projected.position.x - scene_origin.x - radius,
            projected.position.y - scene_origin.y - radius,
            center_z - scene_origin.z - radius,
        );
        let max = WorldCoord::new(
            projected.position.x - scene_origin.x + radius,
            projected.position.y - scene_origin.y + radius,
            center_z - scene_origin.z + radius,
        );
        let point_bounds = WorldBounds::new(min, max);
        if let Some(existing) = bounds.as_mut() {
            existing.extend(&point_bounds);
        } else {
            bounds = Some(point_bounds);
        }
    }

    bounds
}

fn offset_geo_coord(origin: &rustial_math::GeoCoord, dx_meters: f64, dy_meters: f64) -> rustial_math::GeoCoord {
    const METERS_PER_DEG_LAT: f64 = 111_320.0;
    let lat = origin.lat - dy_meters / METERS_PER_DEG_LAT;
    let cos_lat = origin.lat.to_radians().cos().max(1e-10);
    let lon = origin.lon + dx_meters / (METERS_PER_DEG_LAT * cos_lat);
    rustial_math::GeoCoord::new(lat, lon, origin.alt)
}

fn resolve_column_base_altitude(
    column: &rustial_engine::ColumnInstance,
    state: &MapState,
) -> f64 {
    let terrain = state.elevation_at(&column.position).unwrap_or(0.0);
    match column.altitude_mode {
        rustial_engine::AltitudeMode::ClampToGround => terrain + column.base,
        rustial_engine::AltitudeMode::RelativeToGround => terrain + column.position.alt + column.base,
        rustial_engine::AltitudeMode::Absolute => column.position.alt + column.base,
    }
}

fn resolve_point_altitude(
    point: &PointInstance,
    state: &MapState,
) -> f64 {
    let terrain = state.elevation_at(&point.position).unwrap_or(0.0);
    match point.altitude_mode {
        rustial_engine::AltitudeMode::ClampToGround => terrain,
        rustial_engine::AltitudeMode::RelativeToGround => terrain + point.position.alt,
        rustial_engine::AltitudeMode::Absolute => point.position.alt,
    }
}

fn resolve_grid_surface_altitude(
    grid: &GeoGrid,
    coord: &rustial_math::GeoCoord,
    state: &MapState,
) -> f64 {
    let terrain = state.elevation_at(coord).unwrap_or(0.0);
    match grid.altitude_mode {
        rustial_engine::AltitudeMode::ClampToGround => terrain,
        rustial_engine::AltitudeMode::RelativeToGround => terrain + grid.origin.alt,
        rustial_engine::AltitudeMode::Absolute => grid.origin.alt,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rustial_engine::{ColumnInstance, ColumnInstanceSet, MapState, PointInstance, PointInstanceSet};
    use rustial_engine::GeoCoord;

    #[test]
    fn grid_visibility_rejects_far_grid() {
        let mut state = MapState::new();
        state.set_viewport(1280, 720);
        state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
        state.set_camera_distance(1_000.0);
        state.update_camera(1.0 / 60.0);

        let grid = GeoGrid::new(GeoCoord::from_lat_lon(70.0, 120.0), 2, 2, 100.0, 100.0);
        assert!(!grid_intersects_scene_viewport(&grid, &state, state.scene_world_origin()));
    }

    #[test]
    fn columns_visibility_rejects_far_columns() {
        let mut state = MapState::new();
        state.set_viewport(1280, 720);
        state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
        state.set_camera_distance(1_000.0);
        state.update_camera(1.0 / 60.0);

        let columns = ColumnInstanceSet::new(vec![
            ColumnInstance::new(GeoCoord::from_lat_lon(70.0, 120.0), 10.0, 5.0),
        ]);
        assert!(!columns_intersect_scene_viewport(&columns, &state, state.scene_world_origin()));
    }

    #[test]
    fn points_visibility_rejects_far_points() {
        let mut state = MapState::new();
        state.set_viewport(1280, 720);
        state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
        state.set_camera_distance(1_000.0);
        state.update_camera(1.0 / 60.0);

        let points = PointInstanceSet::new(vec![
            PointInstance::new(GeoCoord::from_lat_lon(70.0, 120.0), 10.0),
        ]);
        assert!(!points_intersect_scene_viewport(&points, &state, state.scene_world_origin()));
    }
}