use crate::components::ColumnEntity;
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::columns_intersect_scene_viewport;
use bevy::math::primitives::Cuboid;
use bevy::prelude::*;
use rustial_engine::{ColumnInstance, LayerId, VisualizationOverlay};
use std::collections::{HashMap, HashSet};
#[derive(Resource, Default)]
pub struct CachedColumnAssets {
mesh: Option<Handle<Mesh>>,
materials: HashMap<[u32; 4], Handle<StandardMaterial>>,
}
#[derive(Resource, Default)]
pub struct ColumnSyncState {
pub stats: VisualizationSyncStats,
}
#[allow(clippy::too_many_arguments)]
pub fn sync_columns(
mut commands: Commands,
state: Res<MapStateResource>,
existing: Query<(Entity, &ColumnEntity)>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut cache: ResMut<CachedColumnAssets>,
mut sync_state: ResMut<ColumnSyncState>,
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::Columns {
layer_id,
columns,
ramp,
} => Some((*layer_id, columns, 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, columns, _)| {
columns_intersect_scene_viewport(columns, &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.column_index), entity);
}
for (layer_id, columns, ramp) in overlays {
if !visible_overlay_ids.contains(&layer_id) {
continue;
}
let (min_height, max_height) = column_height_range(columns);
for (column_index, column) in columns.columns.iter().enumerate() {
let key = (layer_id, column_index);
let transform = build_column_transform(column, &state.0, camera_origin);
let color = resolve_column_color(column, ramp, min_height, max_height);
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,
ColumnEntity {
layer_id,
column_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 CachedColumnAssets,
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_column_transform(
column: &ColumnInstance,
state: &rustial_engine::MapState,
camera_origin: glam::DVec3,
) -> Transform {
let projected = state.camera().projection().project(&column.position);
let base_z = resolve_column_base_altitude(column, state);
Transform {
translation: Vec3::new(
(projected.position.x - camera_origin.x) as f32,
(projected.position.y - camera_origin.y) as f32,
(base_z - camera_origin.z + column.height * 0.5) as f32,
),
scale: Vec3::new(
column.width as f32,
column.width as f32,
column.height as f32,
),
..Default::default()
}
}
fn resolve_column_base_altitude(column: &ColumnInstance, state: &rustial_engine::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_column_color(
column: &ColumnInstance,
ramp: &rustial_engine::ColorRamp,
min_height: f64,
max_height: f64,
) -> [f32; 4] {
if let Some(color) = column.color {
return color;
}
let t = if (max_height - min_height).abs() < f64::EPSILON {
0.5
} else {
((column.height - min_height) / (max_height - min_height)).clamp(0.0, 1.0)
} as f32;
ramp.evaluate(t)
}
fn column_height_range(columns: &rustial_engine::ColumnInstanceSet) -> (f64, f64) {
let mut min_height = f64::INFINITY;
let mut max_height = f64::NEG_INFINITY;
for column in &columns.columns {
min_height = min_height.min(column.height);
max_height = max_height.max(column.height);
}
if min_height.is_infinite() || max_height.is_infinite() {
(0.0, 0.0)
} else {
(min_height, max_height)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin::RustialBevyPlugin;
use rustial_engine::rustial_math::GeoCoord;
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
}
#[test]
fn column_sync_spawns_column_entities() {
let mut app = test_app();
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0.set_instanced_columns(
"columns",
rustial_engine::ColumnInstanceSet::new(vec![
rustial_engine::ColumnInstance::new(
GeoCoord::from_lat_lon(51.5, -0.12),
10.0,
5.0,
)
.with_pick_id(1),
rustial_engine::ColumnInstance::new(
GeoCoord::from_lat_lon(51.5005, -0.1195),
20.0,
6.0,
)
.with_pick_id(2),
]),
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],
},
]),
);
}
app.update();
app.update();
let count = {
let world = app.world_mut();
world.query::<&ColumnEntity>().iter(world).count()
};
assert_eq!(count, 2);
}
#[test]
fn column_sync_despawns_removed_columns() {
let mut app = test_app();
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0.set_instanced_columns(
"columns",
rustial_engine::ColumnInstanceSet::new(vec![rustial_engine::ColumnInstance::new(
GeoCoord::from_lat_lon(51.5, -0.12),
10.0,
5.0,
)
.with_pick_id(1)]),
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],
},
]),
);
}
app.update();
app.update();
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0 = rustial_engine::MapState::new();
}
app.update();
let count = {
let world = app.world_mut();
world.query::<&ColumnEntity>().iter(world).count()
};
assert_eq!(count, 0);
}
#[test]
fn column_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_instanced_columns(
"columns",
rustial_engine::ColumnInstanceSet::new(vec![rustial_engine::ColumnInstance::new(
GeoCoord::from_lat_lon(51.5, -0.12),
10.0,
5.0,
)
.with_pick_id(1)]),
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],
},
]),
);
}
app.update();
app.update();
let (entity_before, z_before, material_before) = {
let world = app.world_mut();
let mut query = world.query::<(
Entity,
&ColumnEntity,
&Transform,
&MeshMaterial3d<StandardMaterial>,
)>();
let (entity, _, transform, material) = query.single(world).expect("column entity");
(entity, transform.translation.z, material.0.clone())
};
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0.set_instanced_columns(
"columns",
rustial_engine::ColumnInstanceSet::new(vec![rustial_engine::ColumnInstance::new(
GeoCoord::from_lat_lon(51.5, -0.12),
30.0,
7.0,
)
.with_pick_id(1)
.with_color([0.2, 0.8, 0.3, 0.9])]),
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],
},
]),
);
}
app.update();
let (entity_after, z_after, scale_after, material_after) = {
let world = app.world_mut();
let mut query = world.query::<(
Entity,
&ColumnEntity,
&Transform,
&MeshMaterial3d<StandardMaterial>,
)>();
let (entity, _, transform, material) = query.single(world).expect("column entity");
(
entity,
transform.translation.z,
transform.scale,
material.0.clone(),
)
};
assert_eq!(entity_before, entity_after);
assert!(z_after > z_before);
assert!((scale_after.x - 7.0).abs() < 1e-6);
assert!((scale_after.z - 30.0).abs() < 1e-6);
assert_ne!(material_before, material_after);
}
#[test]
fn column_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.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_instanced_columns(
"columns",
rustial_engine::ColumnInstanceSet::new(vec![rustial_engine::ColumnInstance::new(
GeoCoord::from_lat_lon(70.0, 120.0),
10.0,
5.0,
)
.with_pick_id(1)]),
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],
},
]),
);
}
app.update();
let count = {
let world = app.world_mut();
world.query::<&ColumnEntity>().iter(world).count()
};
assert_eq!(count, 0);
}
#[test]
fn column_sync_reports_steady_state_skip_on_unchanged_frame() {
let mut app = test_app();
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0.set_instanced_columns(
"columns",
rustial_engine::ColumnInstanceSet::new(vec![rustial_engine::ColumnInstance::new(
GeoCoord::from_lat_lon(0.0, 0.0),
10.0,
5.0,
)
.with_pick_id(1)]),
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],
},
]),
);
}
app.update();
app.update();
let before = app.world().resource::<ColumnSyncState>().stats;
app.update();
let after = app.world().resource::<ColumnSyncState>().stats;
assert_eq!(after.skipped_frames, before.skipped_frames + 1);
assert_eq!(after.spawned_entities, before.spawned_entities);
assert_eq!(after.updated_entities, before.updated_entities);
assert_eq!(after.hidden_entities, before.hidden_entities);
assert_eq!(after.despawned_entities, before.despawned_entities);
}
#[test]
fn column_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_columns = |updated: bool| {
let mut columns = 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 column = rustial_engine::ColumnInstance::new(
GeoCoord::from_lat_lon(lat, lon),
10.0 + (idx % 20) as f64,
8.0,
)
.with_pick_id(idx as u64 + 1);
if updated && (count / 4..count / 4 + 512).contains(&idx) {
column.height += 15.0;
}
columns.push(column);
}
rustial_engine::ColumnInstanceSet::new(columns)
};
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0.set_instanced_columns(
"columns",
make_columns(false),
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],
},
]),
);
}
app.update();
app.update();
let before = app.world().resource::<ColumnSyncState>().stats;
{
let mut state = app
.world_mut()
.resource_mut::<crate::plugin::MapStateResource>();
state.0.set_instanced_columns(
"columns",
make_columns(true),
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],
},
]),
);
}
app.update();
let (after_stats, entity_count) = {
let world = app.world_mut();
let count = world.query::<&ColumnEntity>().iter(world).count() as u64;
(world.resource::<ColumnSyncState>().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);
}
}