rustial-renderer-bevy 0.0.1

Bevy Engine renderer for the rustial 2.5D map engine
//! Synchronise point-cloud visualization overlays to Bevy ECS.

use crate::components::PointCloudEntity;
use crate::plugin::MapStateResource;
use crate::systems::frame_change_detection::{frame_unchanged, FrameChangeDetection};
use crate::systems::visualization_sync_stats::VisualizationSyncStats;
use crate::systems::visualization_visibility::points_intersect_scene_viewport;
use bevy::math::primitives::Cuboid;
use bevy::prelude::*;
use rustial_engine::{LayerId, PointInstance, VisualizationOverlay};
use std::collections::{HashMap, HashSet};

/// Shared mesh/material cache for point-cloud entities.
#[derive(Resource, Default)]
pub struct CachedPointCloudAssets {
    mesh: Option<Handle<Mesh>>,
    materials: HashMap<[u32; 4], Handle<StandardMaterial>>,
}

/// Retained sync statistics for point-cloud overlays.
#[derive(Resource, Default)]
pub struct PointCloudSyncState {
    /// Cumulative sync activity.
    pub stats: VisualizationSyncStats,
}

/// Synchronise `VisualizationOverlay::Points` items into Bevy entities.
#[allow(clippy::too_many_arguments)]
pub fn sync_point_clouds(
    mut commands: Commands,
    state: Res<MapStateResource>,
    existing: Query<(Entity, &PointCloudEntity)>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut cache: ResMut<CachedPointCloudAssets>,
    mut sync_state: ResMut<PointCloudSyncState>,
    detection: Res<FrameChangeDetection>,
) {
    if frame_unchanged(&detection, &state.0) {
        sync_state.stats.skipped_frames += 1;
        return;
    }

    let frame = state.0.frame_output();
    let overlays: Vec<_> = frame
        .visualization
        .iter()
        .filter_map(|overlay| match overlay {
            VisualizationOverlay::Points {
                layer_id,
                points,
                ramp,
            } => Some((*layer_id, points, ramp)),
            _ => None,
        })
        .collect();

    if overlays.is_empty() {
        for (entity, _) in &existing {
            commands.entity(entity).despawn();
            sync_state.stats.despawned_entities += 1;
        }
        return;
    }

    let mesh_handle = cache
        .mesh
        .get_or_insert_with(|| meshes.add(Mesh::from(Cuboid::new(1.0, 1.0, 1.0))))
        .clone();
    let camera_origin = state.0.scene_world_origin();
    let visible_overlay_ids: HashSet<LayerId> = overlays
        .iter()
        .filter_map(|(layer_id, points, _)| {
            points_intersect_scene_viewport(points, &state.0, camera_origin).then_some(*layer_id)
        })
        .collect();
    let mut existing_map = HashMap::new();

    for (entity, marker) in &existing {
        existing_map.insert((marker.layer_id, marker.point_index), entity);
    }

    for (layer_id, points, ramp) in overlays {
        if !visible_overlay_ids.contains(&layer_id) {
            continue;
        }
        for (point_index, point) in points.points.iter().enumerate() {
            let key = (layer_id, point_index);
            let transform = build_point_transform(point, &state.0, camera_origin);
            let color = point
                .color
                .unwrap_or_else(|| ramp.evaluate(point.intensity.clamp(0.0, 1.0)));
            let material_handle = cached_material_handle(&mut materials, &mut cache, color);

            if let Some(entity) = existing_map.remove(&key) {
                commands.entity(entity).insert((
                    transform,
                    MeshMaterial3d(material_handle),
                    Visibility::Visible,
                ));
                sync_state.stats.updated_entities += 1;
            } else {
                commands.spawn((
                    Mesh3d(mesh_handle.clone()),
                    MeshMaterial3d(material_handle),
                    transform,
                    Visibility::Visible,
                    PointCloudEntity {
                        layer_id,
                        point_index,
                    },
                ));
                sync_state.stats.spawned_entities += 1;
            }
        }
    }

    for ((layer_id, _), entity) in existing_map {
        if visible_overlay_ids.contains(&layer_id) {
            commands.entity(entity).despawn();
            sync_state.stats.despawned_entities += 1;
        } else {
            commands.entity(entity).insert(Visibility::Hidden);
            sync_state.stats.hidden_entities += 1;
        }
    }
}

fn cached_material_handle(
    materials: &mut Assets<StandardMaterial>,
    cache: &mut CachedPointCloudAssets,
    color: [f32; 4],
) -> Handle<StandardMaterial> {
    let key = [
        color[0].to_bits(),
        color[1].to_bits(),
        color[2].to_bits(),
        color[3].to_bits(),
    ];
    if let Some(handle) = cache.materials.get(&key) {
        return handle.clone();
    }
    let handle = materials.add(StandardMaterial {
        base_color: Color::srgba(color[0], color[1], color[2], color[3]),
        alpha_mode: AlphaMode::Blend,
        perceptual_roughness: 0.9,
        metallic: 0.0,
        ..Default::default()
    });
    cache.materials.insert(key, handle.clone());
    handle
}

fn build_point_transform(
    point: &PointInstance,
    state: &rustial_engine::MapState,
    camera_origin: glam::DVec3,
) -> Transform {
    let projected = state.camera().projection().project(&point.position);
    let z = resolve_point_altitude(point, state);
    let diameter = point.radius * 2.0;
    Transform {
        translation: Vec3::new(
            (projected.position.x - camera_origin.x) as f32,
            (projected.position.y - camera_origin.y) as f32,
            (z - camera_origin.z) as f32,
        ),
        scale: Vec3::splat(diameter as f32),
        ..Default::default()
    }
}

fn resolve_point_altitude(point: &PointInstance, state: &rustial_engine::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,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::plugin::RustialBevyPlugin;
    use rustial_engine::{GeoCoord, MapState, PointCloudLayer, PointInstanceSet};

    fn test_app() -> App {
        let mut app = App::new();
        app.add_plugins(bevy::app::TaskPoolPlugin::default());
        app.add_plugins(bevy::time::TimePlugin);
        app.init_resource::<Assets<Mesh>>();
        app.init_resource::<Assets<StandardMaterial>>();
        app.init_resource::<Assets<Image>>();
        app.add_plugins(RustialBevyPlugin);
        app
    }

    fn test_ramp() -> rustial_engine::ColorRamp {
        rustial_engine::ColorRamp::new(vec![
            rustial_engine::ColorStop {
                value: 0.0,
                color: [0.0, 0.0, 1.0, 0.5],
            },
            rustial_engine::ColorStop {
                value: 1.0,
                color: [1.0, 0.0, 0.0, 0.8],
            },
        ])
    }

    #[test]
    fn point_cloud_sync_spawns_point_entities() {
        let mut app = test_app();
        {
            let mut state = app
                .world_mut()
                .resource_mut::<crate::plugin::MapStateResource>();
            state.0 = MapState::new();
            state.0.push_layer(Box::new(PointCloudLayer::new(
                "points",
                PointInstanceSet::new(vec![
                    rustial_engine::PointInstance::new(GeoCoord::from_lat_lon(0.0, 0.0), 5.0)
                        .with_pick_id(1),
                    rustial_engine::PointInstance::new(GeoCoord::from_lat_lon(0.001, 0.001), 4.0)
                        .with_pick_id(2),
                ]),
                test_ramp(),
            )));
        }

        app.update();
        app.update();

        let count = {
            let world = app.world_mut();
            world.query::<&PointCloudEntity>().iter(world).count()
        };
        assert_eq!(count, 2);
    }

    #[test]
    fn point_cloud_sync_skips_far_offscreen_overlay() {
        let mut app = test_app();
        app.update();
        {
            let mut state = app
                .world_mut()
                .resource_mut::<crate::plugin::MapStateResource>();
            state.0 = MapState::new();
            state.0.set_viewport(1280, 720);
            state.0.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
            state.0.set_camera_distance(1_000.0);
            state.0.update_camera(1.0 / 60.0);
            state.0.set_point_cloud(
                "points",
                PointInstanceSet::new(vec![rustial_engine::PointInstance::new(
                    GeoCoord::from_lat_lon(70.0, 120.0),
                    5.0,
                )
                .with_pick_id(1)]),
                test_ramp(),
            );
        }

        app.update();

        let count = {
            let world = app.world_mut();
            world.query::<&PointCloudEntity>().iter(world).count()
        };
        assert_eq!(count, 0);
    }

    #[test]
    fn point_cloud_sync_updates_existing_entities_in_place() {
        let mut app = test_app();
        {
            let mut state = app
                .world_mut()
                .resource_mut::<crate::plugin::MapStateResource>();
            state.0.set_point_cloud(
                "points",
                PointInstanceSet::new(vec![rustial_engine::PointInstance::new(
                    GeoCoord::from_lat_lon(0.0, 0.0),
                    5.0,
                )
                .with_pick_id(1)]),
                test_ramp(),
            );
        }

        app.update();
        app.update();

        let entity_before = {
            let world = app.world_mut();
            let mut query = world.query::<(Entity, &PointCloudEntity)>();
            let (entity, _) = query.single(world).expect("point entity");
            entity
        };

        {
            let mut state = app
                .world_mut()
                .resource_mut::<crate::plugin::MapStateResource>();
            state.0.set_point_cloud(
                "points",
                PointInstanceSet::new(vec![rustial_engine::PointInstance::new(
                    GeoCoord::from_lat_lon(0.0, 0.0),
                    8.0,
                )
                .with_pick_id(1)
                .with_color([0.2, 0.8, 0.3, 0.9])]),
                test_ramp(),
            );
        }

        app.update();

        let (entity_after, scale_after) = {
            let world = app.world_mut();
            let mut query = world.query::<(Entity, &PointCloudEntity, &Transform)>();
            let (entity, _, transform) = query.single(world).expect("point entity");
            (entity, transform.scale)
        };

        assert_eq!(entity_before, entity_after);
        assert!((scale_after.x - 16.0).abs() < 1e-6);
    }

    #[test]
    fn point_cloud_sync_large_update_reuses_entities_without_respawn() {
        let mut app = test_app();
        let count = 10_000usize;
        let side = (count as f64).sqrt().ceil() as usize;

        let make_points = |updated: bool| {
            let mut points = Vec::with_capacity(count);
            for idx in 0..count {
                let row = idx / side;
                let col = idx % side;
                let lat = (row as f64 - side as f64 * 0.5) * 0.0005;
                let lon = (col as f64 - side as f64 * 0.5) * 0.0005;
                let mut point = rustial_engine::PointInstance::new(
                    GeoCoord::from_lat_lon(lat, lon),
                    4.0 + (idx % 8) as f64,
                )
                .with_pick_id(idx as u64 + 1)
                .with_intensity(((idx % 100) as f32) / 100.0);
                if updated && (count / 4..count / 4 + 512).contains(&idx) {
                    point.radius += 4.0;
                    point.intensity = 1.0 - point.intensity;
                }
                points.push(point);
            }
            PointInstanceSet::new(points)
        };

        {
            let mut state = app
                .world_mut()
                .resource_mut::<crate::plugin::MapStateResource>();
            state
                .0
                .set_point_cloud("points", make_points(false), test_ramp());
        }

        app.update();
        app.update();

        let before = app.world().resource::<PointCloudSyncState>().stats;

        {
            let mut state = app
                .world_mut()
                .resource_mut::<crate::plugin::MapStateResource>();
            state
                .0
                .set_point_cloud("points", make_points(true), test_ramp());
        }

        app.update();

        let (after_stats, entity_count) = {
            let world = app.world_mut();
            let count = world.query::<&PointCloudEntity>().iter(world).count() as u64;
            (world.resource::<PointCloudSyncState>().stats, count)
        };

        assert_eq!(entity_count, count as u64);
        assert_eq!(after_stats.spawned_entities, before.spawned_entities);
        assert_eq!(after_stats.despawned_entities, before.despawned_entities);
        assert!(after_stats.updated_entities >= before.updated_entities + count as u64);
    }
}