use bevy::prelude::*;
use bevy::asset::embedded_asset;
use bevy::pbr::MaterialPlugin;
use rustial_engine::{GeoCoord, MapState, MAX_ZOOM};
use crate::components::MapCamera;
use crate::components::DeferredAssetDrop;
use crate::grid_scalar_material::GridScalarMaterial;
use crate::hillshade_material::HillshadeMaterial;
use crate::painter::{
update_painter_plan, update_terrain_interaction_buffers, PainterPlanResource, PainterSet,
TerrainInteractionBuffersResource,
};
use crate::systems::camera_sync::sync_camera;
use crate::systems::column_sync::{sync_columns, CachedColumnAssets, ColumnSyncState};
use crate::systems::debug_hud;
use crate::systems::distance_fog::sync_horizon_fade;
use crate::systems::distance_fog::FogDirtyState;
use crate::systems::frame_change_detection::{
update_frame_change_detection, FrameChangeDetection,
};
use crate::systems::geo_entity_sync::sync_geo_entities;
use crate::systems::grid_extrusion_sync::{sync_grid_extrusions, GridExtrusionSyncState};
use crate::systems::grid_scalar_sync::{sync_grid_scalars, GridScalarSyncState};
use crate::systems::map_input;
use crate::systems::model_sync::{sync_models, CachedModelAssets, ModelSyncState};
use crate::systems::placeholder_sync::sync_placeholders;
use crate::systems::point_cloud_sync::{sync_point_clouds, CachedPointCloudAssets, PointCloudSyncState};
use crate::systems::hillshade_sync::sync_hillshade;
use crate::systems::performance::{
begin_post_update_stage_timing, begin_update_stage_timing, end_post_update_stage_timing,
end_update_stage_timing, report_performance_trace, update_map_state_timed,
PerformanceTraceState,
};
use crate::systems::terrain_sync;
use crate::systems::terrain_sync::{
sync_terrain, SharedTerrainGridMeshes, UploadedTerrainHeightTextures,
};
use crate::systems::texture_upload::{upload_hillshade_textures, upload_textures, UploadedHillshadeTextures, UploadedTileTextures};
use crate::systems::tile_sync::{sync_tiles, CachedTileAssets};
use crate::systems::vector_sync::{sync_vectors, VectorSyncState};
use crate::systems::image_overlay_sync::{sync_image_overlays, ImageOverlaySyncState};
use crate::tile_fog_material::TileFogMaterial;
const WGS84_CIRCUMFERENCE: f64 = 2.0 * std::f64::consts::PI * 6_378_137.0;
const TILE_PX: f64 = 256.0;
const FALLBACK_VIEWPORT: (u32, u32) = (1280, 720);
const DEFAULT_TERRAIN_CACHE_SIZE: usize = 768;
#[cfg(feature = "http-tiles")]
fn terrain_cache_size(
tile_fetch_config: Option<&crate::systems::tile_fetch::TileFetchConfig>,
) -> usize {
tile_fetch_config.map_or(DEFAULT_TERRAIN_CACHE_SIZE, |config| config.max_cached.max(1))
}
#[cfg(not(feature = "http-tiles"))]
const fn terrain_cache_size() -> usize {
DEFAULT_TERRAIN_CACHE_SIZE
}
pub struct RustialBevyPlugin;
impl Plugin for RustialBevyPlugin {
fn build(&self, app: &mut App) {
if !app.world().contains_resource::<RustialBevyConfig>() {
app.insert_resource(RustialBevyConfig::default());
}
if app.is_plugin_added::<bevy::render::RenderPlugin>() {
app.add_plugins(MaterialPlugin::<TileFogMaterial>::default());
app.add_plugins(MaterialPlugin::<HillshadeMaterial>::default());
app.add_plugins(MaterialPlugin::<GridScalarMaterial>::default());
embedded_asset!(app, "shaders/tile_fog.wgsl");
embedded_asset!(app, "shaders/hillshade_overlay.wgsl");
embedded_asset!(app, "shaders/grid_scalar_overlay.wgsl");
} else {
app.init_resource::<Assets<TileFogMaterial>>();
app.init_resource::<Assets<HillshadeMaterial>>();
app.init_resource::<Assets<GridScalarMaterial>>();
}
use bevy::input::mouse::{MouseMotion, MouseWheel};
if !app.world().contains_resource::<ButtonInput<MouseButton>>() {
app.init_resource::<ButtonInput<MouseButton>>();
}
if !app.world().contains_resource::<ButtonInput<KeyCode>>() {
app.init_resource::<ButtonInput<KeyCode>>();
}
app.add_message::<CursorMoved>();
app.add_message::<MouseMotion>();
app.add_message::<MouseWheel>();
app.insert_resource(MapStateResource(MapState::new()))
.init_resource::<PainterPlanResource>()
.init_resource::<TerrainInteractionBuffersResource>()
.init_resource::<PerformanceTraceState>()
.init_resource::<map_input::PrevCursorPos>()
.init_resource::<map_input::MapInputEnabled>()
.init_resource::<DeferredAssetDrop>()
.init_resource::<UploadedTileTextures>()
.init_resource::<UploadedHillshadeTextures>()
.init_resource::<CachedTileAssets>()
.init_resource::<SharedTerrainGridMeshes>()
.init_resource::<UploadedTerrainHeightTextures>()
.init_resource::<terrain_sync::LastSceneOrigin>()
.init_resource::<CachedModelAssets>()
.init_resource::<CachedColumnAssets>()
.init_resource::<CachedPointCloudAssets>()
.init_resource::<ColumnSyncState>()
.init_resource::<PointCloudSyncState>()
.init_resource::<ModelSyncState>()
.init_resource::<FogDirtyState>()
.init_resource::<VectorSyncState>()
.init_resource::<ImageOverlaySyncState>()
.init_resource::<GridExtrusionSyncState>()
.init_resource::<GridScalarSyncState>()
.init_resource::<debug_hud::DebugHudState>()
.init_resource::<debug_hud::DebugFileTxtState>()
.init_resource::<debug_hud::DebugFileCsvState>()
.init_resource::<debug_hud::DebugFileJpgState>()
.init_resource::<FrameChangeDetection>()
.configure_sets(Update, (PainterSet::SkyAtmosphere, PainterSet::TerrainData, PainterSet::OpaqueScene, PainterSet::HillshadeOverlay).chain())
.configure_sets(PostUpdate, (PainterSet::SkyAtmosphere, PainterSet::TerrainData, PainterSet::OpaqueScene, PainterSet::HillshadeOverlay).chain())
.add_systems(Startup, (setup_from_config, terrain_sync::init_placeholder_texture))
.add_systems(PreUpdate, map_input::sync_viewport)
.add_systems(PreUpdate, map_input::handle_default_input.after(map_input::sync_viewport))
.add_systems(
PreUpdate,
update_map_state_timed.after(map_input::handle_default_input),
)
.add_systems(PreUpdate, update_painter_plan.after(update_map_state_timed))
.add_systems(PreUpdate, update_frame_change_detection.after(update_map_state_timed))
.add_systems(PreUpdate, update_terrain_interaction_buffers.after(update_map_state_timed))
.add_systems(PreUpdate, sync_camera.after(update_map_state_timed))
.add_systems(Update, begin_update_stage_timing.before(PainterSet::SkyAtmosphere))
.add_systems(Update, sync_background_clear_color.in_set(PainterSet::SkyAtmosphere))
.add_systems(Update, sync_tiles.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_placeholders.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_terrain.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_vectors.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_grid_extrusions.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_grid_scalars.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_columns.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_point_clouds.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_models.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_image_overlays.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_geo_entities.in_set(PainterSet::OpaqueScene))
.add_systems(Update, debug_hud::update_debug_hud.in_set(PainterSet::OpaqueScene))
.add_systems(Update, sync_hillshade.in_set(PainterSet::HillshadeOverlay))
.add_systems(Update, end_update_stage_timing.after(PainterSet::HillshadeOverlay))
.add_systems(PostUpdate, begin_post_update_stage_timing.before(PainterSet::SkyAtmosphere))
.add_systems(PostUpdate, sync_horizon_fade.in_set(PainterSet::SkyAtmosphere))
.add_systems(PostUpdate, upload_textures.in_set(PainterSet::OpaqueScene))
.add_systems(PostUpdate, upload_hillshade_textures.in_set(PainterSet::HillshadeOverlay))
.add_systems(PostUpdate, advance_deferred_drop.after(upload_hillshade_textures))
.add_systems(PostUpdate, end_post_update_stage_timing.after(advance_deferred_drop))
.add_systems(PostUpdate, report_performance_trace.after(end_post_update_stage_timing));
#[cfg(feature = "http-tiles")]
{
use crate::systems::tile_fetch;
if !app
.world()
.contains_resource::<tile_fetch::TileFetchConfig>()
{
app.insert_resource(tile_fetch::TileFetchConfig::default());
}
app.add_systems(Startup, tile_fetch::init_http_client)
.add_systems(Startup, tile_fetch::setup_engine_http_tile_layer.after(tile_fetch::init_http_client))
.add_systems(
PreUpdate,
tile_fetch::sync_builtin_tile_layer_visibility.after(update_map_state_timed),
);
}
}
}
#[derive(Resource)]
pub struct RustialBevyConfig {
pub center: (f64, f64),
pub zoom: u8,
pub viewport: (u32, u32),
pub terrain: Option<rustial_engine::TerrainConfig>,
pub pitch: Option<f64>,
pub max_pitch: Option<f64>,
pub debug: bool,
pub performance_trace: bool,
pub debug_file_txt: bool,
pub debug_file_csv: bool,
pub debug_file_jpg: bool,
}
impl Default for RustialBevyConfig {
fn default() -> Self {
Self {
center: (0.0, 0.0),
zoom: 2,
viewport: (0, 0),
terrain: None,
pitch: None,
max_pitch: None,
debug: false,
performance_trace: false,
debug_file_txt: false,
debug_file_csv: false,
debug_file_jpg: false,
}
}
}
impl Clone for RustialBevyConfig {
fn clone(&self) -> Self {
Self {
center: self.center,
zoom: self.zoom,
viewport: self.viewport,
terrain: None,
pitch: self.pitch,
max_pitch: self.max_pitch,
debug: self.debug,
performance_trace: self.performance_trace,
debug_file_txt: self.debug_file_txt,
debug_file_csv: self.debug_file_csv,
debug_file_jpg: self.debug_file_jpg,
}
}
}
impl std::fmt::Debug for RustialBevyConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RustialBevyConfig")
.field("center", &self.center)
.field("zoom", &self.zoom)
.field("viewport", &self.viewport)
.field("terrain", &self.terrain.is_some())
.field("pitch", &self.pitch)
.field("max_pitch", &self.max_pitch)
.field("debug", &self.debug)
.field("performance_trace", &self.performance_trace)
.field("debug_file_txt", &self.debug_file_txt)
.field("debug_file_csv", &self.debug_file_csv)
.field("debug_file_jpg", &self.debug_file_jpg)
.finish()
}
}
#[derive(Resource)]
pub struct MapStateResource(pub MapState);
fn setup_from_config(
mut commands: Commands,
mut state: ResMut<MapStateResource>,
mut config: ResMut<RustialBevyConfig>,
mut perf: ResMut<PerformanceTraceState>,
windows: Query<&Window>,
#[cfg(feature = "http-tiles")]
tile_fetch_config: Option<Res<crate::systems::tile_fetch::TileFetchConfig>>,
) {
let (vw, vh) = if config.viewport.0 > 0 && config.viewport.1 > 0 {
config.viewport
} else if let Ok(window) = windows.single() {
let w = window.resolution.width() as u32;
let h = window.resolution.height() as u32;
if w > 0 && h > 0 {
(w, h)
} else {
FALLBACK_VIEWPORT
}
} else {
FALLBACK_VIEWPORT
};
let fov_y = state.0.camera().fov_y();
let z = config.zoom.min(MAX_ZOOM);
let tile_mpp = WGS84_CIRCUMFERENCE / (TILE_PX * (1u64 << z) as f64);
let distance = tile_mpp * vh.max(1) as f64 / (2.0 * (fov_y / 2.0).tan());
state.0.set_camera_target(
GeoCoord::from_lat_lon(config.center.0, config.center.1),
);
state.0.set_camera_distance(distance);
state.0.set_viewport(vw, vh);
if let Some(terrain_config) = config.terrain.take() {
let cache_size = {
#[cfg(feature = "http-tiles")]
{
terrain_cache_size(tile_fetch_config.as_deref())
}
#[cfg(not(feature = "http-tiles"))]
{
terrain_cache_size()
}
};
state.0.set_terrain(rustial_engine::TerrainManager::new(terrain_config, cache_size));
if config.pitch.is_none() {
state.0.set_camera_pitch(60_f64.to_radians());
}
if config.max_pitch.is_none() {
state.0.set_max_pitch(85_f64.to_radians());
}
}
if let Some(pitch_deg) = config.pitch {
state.0.set_camera_pitch(pitch_deg.to_radians());
}
if let Some(max_pitch_deg) = config.max_pitch {
state.0.set_max_pitch(max_pitch_deg.to_radians());
}
perf.set_enabled(config.performance_trace);
log::info!(
"setup_from_config: center=({:.4}, {:.4}), zoom={}, viewport={}x{}, distance={:.0}m, pitch={:.1}\u{00B0}, perf_trace={}",
config.center.0,
config.center.1,
z,
vw,
vh,
distance,
state.0.camera().pitch().to_degrees(),
config.performance_trace,
);
let clear = state.0.background_color().unwrap_or([1.0, 1.0, 1.0, 1.0]);
commands.spawn((
Camera3d::default(),
Camera {
clear_color: ClearColorConfig::Custom(Color::srgba(clear[0], clear[1], clear[2], clear[3])),
..default()
},
Transform::default(),
MapCamera,
));
if config.debug {
debug_hud::spawn_debug_hud(commands);
}
}
fn sync_background_clear_color(
state: Res<MapStateResource>,
mut cameras: Query<&mut Camera, With<MapCamera>>,
) {
let clear = state.0.computed_fog().clear_color;
for mut camera in &mut cameras {
camera.clear_color = ClearColorConfig::Custom(Color::srgba(clear[0], clear[1], clear[2], clear[3]));
}
}
fn advance_deferred_drop(mut deferred: ResMut<DeferredAssetDrop>) {
deferred.advance_frame();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{ModelEntity, TerrainEntity, TileEntity, VectorEntity};
use crate::components::HillshadeEntity;
use bevy::camera::Projection;
use bevy::mesh::VertexAttributeValues;
use glam::DVec3;
use rustial_engine::{
CameraMode, CameraProjection, Feature, FeatureCollection, Geometry, ModelLayer, Point,
VectorLayer, VectorStyle,
};
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::default());
app.init_resource::<Assets<Mesh>>();
app.init_resource::<Assets<StandardMaterial>>();
app.init_resource::<Assets<Image>>();
app.add_plugins(RustialBevyPlugin);
app
}
#[test]
fn plugin_inserts_map_state_and_spawns_camera() {
let mut app = test_app();
app.update();
assert!(app.world().contains_resource::<MapStateResource>());
let camera_count = {
let world = app.world_mut();
world.query::<&MapCamera>().iter(world).count()
};
assert_eq!(camera_count, 1);
}
#[test]
fn plugin_sync_tiles_spawns_tile_entities() {
let mut app = test_app();
app.update();
app.update();
let tile_count = {
let world = app.world_mut();
world.query::<&TileEntity>().iter(world).count()
};
assert!(
tile_count > 0,
"expected at least one tile entity to be spawned"
);
}
#[test]
fn plugin_sync_terrain_with_enabled_terrain() {
let mut app = test_app();
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
use rustial_engine::{FlatElevationSource, TerrainConfig, TerrainManager};
let config = TerrainConfig {
enabled: true,
mesh_resolution: 4,
source: Box::new(FlatElevationSource::new(4, 4)),
..TerrainConfig::default()
};
state.0.set_terrain(TerrainManager::new(config, 100));
}
app.update();
app.update();
app.update();
let terrain_count = {
let world = app.world_mut();
world.query::<&TerrainEntity>().iter(world).count()
};
assert!(terrain_count > 0, "expected at least one terrain entity");
let hillshade_count = {
let world = app.world_mut();
world.query::<&HillshadeEntity>().iter(world).count()
};
assert_eq!(hillshade_count, 0, "no hillshade layer means no overlay entities");
}
#[test]
fn plugin_sync_vectors_with_data() {
let mut app = test_app();
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
state.0.push_layer(Box::new(VectorLayer::new(
"test_vector",
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(Point {
coord: GeoCoord::from_lat_lon(48.8566, 2.3522),
}),
properties: Default::default(),
}],
},
VectorStyle::default(),
)));
}
app.update();
app.update();
let vec_count = {
let world = app.world_mut();
world.query::<&VectorEntity>().iter(world).count()
};
assert!(vec_count > 0, "expected at least one vector entity");
}
#[test]
fn plugin_sync_models_with_data() {
let mut app = test_app();
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
use rustial_engine::{ModelInstance, ModelMesh};
use rustial_engine::rustial_math::GeoCoord;
let mesh = ModelMesh {
positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
normals: vec![[0.0, 0.0, 1.0]; 3],
uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
indices: vec![0, 1, 2],
};
let instance = ModelInstance::new(GeoCoord::from_lat_lon(48.8566, 2.3522), mesh);
let mut layer = ModelLayer::new("test_models");
layer.add(instance);
state.0.push_layer(Box::new(layer));
}
app.update();
app.update();
let model_count = {
let world = app.world_mut();
world.query::<&ModelEntity>().iter(world).count()
};
assert!(model_count > 0, "expected at least one model entity");
}
#[test]
fn disabled_terrain_produces_no_terrain_entities() {
let mut app = test_app();
app.update();
app.update();
let terrain_count = {
let world = app.world_mut();
world.query::<&TerrainEntity>().iter(world).count()
};
assert_eq!(terrain_count, 0);
}
#[test]
fn config_sets_center_and_zoom() {
let mut app = App::new();
app.add_plugins(bevy::app::TaskPoolPlugin::default());
app.add_plugins(bevy::time::TimePlugin::default());
app.init_resource::<Assets<Mesh>>();
app.init_resource::<Assets<StandardMaterial>>();
app.init_resource::<Assets<Image>>();
app.insert_resource(RustialBevyConfig {
center: (51.1, 17.0),
zoom: 10,
viewport: (1280, 720),
..default()
});
app.add_plugins(RustialBevyPlugin);
app.update();
let state = app.world().resource::<MapStateResource>();
assert!((state.0.camera().target().lat - 51.1).abs() < 0.01);
assert!((state.0.camera().target().lon - 17.0).abs() < 0.01);
}
#[test]
fn plugin_sync_tiles_rebuilds_for_equirectangular_projection() {
let mut app = test_app();
app.update();
app.update();
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
state.0.set_camera_projection(CameraProjection::Equirectangular);
}
app.update();
app.update();
let visible_count = app.world().resource::<MapStateResource>().0.visible_tiles().len();
assert!(visible_count > 0, "expected visible tiles after projection switch");
let world = app.world_mut();
let mut query = world.query::<&TileEntity>();
let tiles: Vec<_> = query.iter(world).collect();
assert!(!tiles.is_empty(), "expected tile entities after projection switch");
assert!(tiles.iter().all(|tile| tile.projection == CameraProjection::Equirectangular));
}
#[test]
fn plugin_sync_camera_uses_orthographic_projection_component() {
let mut app = test_app();
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
state.0.set_camera_mode(CameraMode::Orthographic);
state.0.set_camera_projection(CameraProjection::WebMercator);
}
app.update();
app.update();
let world = app.world_mut();
let mut query = world.query::<(&MapCamera, &Projection)>();
let (_, projection) = query.single(world).expect("map camera should exist");
assert!(matches!(projection, Projection::Orthographic(_)));
}
#[test]
fn plugin_sync_camera_uses_perspective_projection_for_equirectangular() {
let mut app = test_app();
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
state.0.set_camera_mode(CameraMode::Perspective);
state.0.set_camera_projection(CameraProjection::Equirectangular);
}
app.update();
app.update();
{
let world = app.world_mut();
let mut query = world.query::<(&MapCamera, &Projection)>();
let (_, projection) = query.single(world).expect("map camera should exist");
assert!(matches!(projection, Projection::Perspective(_)));
}
let state = app.world().resource::<MapStateResource>();
assert_eq!(state.0.camera().projection(), CameraProjection::Equirectangular);
}
#[test]
fn plugin_sync_camera_uses_orthographic_projection_for_equirectangular() {
let mut app = test_app();
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
state.0.set_camera_mode(CameraMode::Orthographic);
state.0.set_camera_projection(CameraProjection::Equirectangular);
}
app.update();
app.update();
{
let world = app.world_mut();
let mut query = world.query::<(&MapCamera, &Projection)>();
let (_, projection) = query.single(world).expect("map camera should exist");
assert!(matches!(projection, Projection::Orthographic(_)));
}
let state = app.world().resource::<MapStateResource>();
assert_eq!(state.0.camera().projection(), CameraProjection::Equirectangular);
}
#[test]
fn plugin_sync_tiles_places_projected_equirectangular_geometry() {
let mut app = test_app();
app.update();
app.update();
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
state.0.set_camera_projection(CameraProjection::Equirectangular);
}
app.update();
app.update();
let visible_count = app.world().resource::<MapStateResource>().0.visible_tiles().len();
assert!(visible_count > 0, "expected visible tiles after switching to equirectangular projection");
let tile_id = {
let state = app.world().resource::<MapStateResource>();
state
.0
.visible_tiles()
.first()
.map(|tile| tile.target)
.expect("expected at least one visible tile")
};
let expected_sw = {
let state = app.world().resource::<MapStateResource>();
let camera_origin = state.0.scene_world_origin();
DVec3::from_array(
CameraProjection::Equirectangular.project_tile_corner(&tile_id, 0.0, 1.0),
) - camera_origin
};
let world = app.world_mut();
let mut query = world.query::<(&TileEntity, &Mesh3d, &Transform)>();
let (tile, mesh3d, transform) = query
.iter(world)
.find(|(tile, _, _)| tile.tile_id == tile_id)
.expect("expected projected tile entity");
assert_eq!(tile.projection, CameraProjection::Equirectangular);
let meshes = world.resource::<Assets<Mesh>>();
let mesh = meshes.get(&mesh3d.0).expect("tile mesh should exist");
let positions = match mesh.attribute(Mesh::ATTRIBUTE_POSITION) {
Some(VertexAttributeValues::Float32x3(values)) => values,
other => panic!("unexpected tile position attribute: {other:?}"),
};
let actual_sw = [
positions[0][0] + transform.translation.x,
positions[0][1] + transform.translation.y,
positions[0][2] + transform.translation.z,
];
assert!((actual_sw[0] - expected_sw.x as f32).abs() < 1e-3);
assert!((actual_sw[1] - expected_sw.y as f32).abs() < 1e-3);
assert!((actual_sw[2] - expected_sw.z as f32).abs() < 1e-3);
}
#[test]
fn plugin_sync_terrain_repositions_entities_when_scene_origin_changes() {
let mut app = test_app();
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
use rustial_engine::{FlatElevationSource, TerrainConfig, TerrainManager};
state.0.set_terrain(TerrainManager::new(
TerrainConfig {
enabled: true,
mesh_resolution: 2,
source: Box::new(FlatElevationSource::new(2, 2)),
..TerrainConfig::default()
},
16,
));
state.0.set_camera_projection(CameraProjection::Equirectangular);
state.0.set_camera_target(GeoCoord::from_lat_lon(10.0, 20.0));
state.0.update_camera(1.0 / 60.0);
}
app.update();
app.update();
app.update();
let terrain_mesh_count = app.world().resource::<MapStateResource>().0.terrain_meshes().len();
assert!(terrain_mesh_count > 0, "expected terrain meshes after initial sync");
let (tile_id, spawn_origin) = {
let world = app.world_mut();
let mut query = world.query::<&TerrainEntity>();
query
.iter(world)
.map(|terrain| (terrain.tile_id, terrain.spawn_origin))
.next()
.expect("expected terrain entity after initial sync")
};
{
let mut state = app.world_mut().resource_mut::<MapStateResource>();
state.0.set_camera_target(GeoCoord::from_lat_lon(10.5, 20.5));
state.0.update_camera(1.0 / 60.0);
}
app.update();
let current_origin = app.world().resource::<MapStateResource>().0.scene_world_origin();
let expected = spawn_origin - current_origin;
let world = app.world_mut();
let mut query = world.query::<(&TerrainEntity, &Transform, &MeshMaterial3d<TileFogMaterial>)>();
let (terrain, transform, material_handle) = query
.iter(world)
.find(|(terrain, _, _)| terrain.tile_id == tile_id)
.expect("expected terrain entity");
assert_eq!(terrain.spawn_origin, spawn_origin);
if terrain.gpu_displaced {
assert!(transform.translation.length() < 1e-6);
let materials = world.resource::<Assets<TileFogMaterial>>();
let material = materials
.get(&material_handle.0)
.expect("terrain material should exist");
assert!((material.terrain.scene_origin.x - current_origin.x as f32).abs() < 1e-3);
assert!((material.terrain.scene_origin.y - current_origin.y as f32).abs() < 1e-3);
assert!((material.terrain.scene_origin.z - current_origin.z as f32).abs() < 1e-3);
} else {
assert!((transform.translation.x - expected.x as f32).abs() < 1e-3);
assert!((transform.translation.y - expected.y as f32).abs() < 1e-3);
assert!((transform.translation.z - expected.z as f32).abs() < 1e-3);
}
}
#[cfg(feature = "http-tiles")]
#[test]
fn terrain_cache_size_matches_tile_fetch_budget() {
let config = crate::systems::tile_fetch::TileFetchConfig {
max_cached: 1024,
..Default::default()
};
assert_eq!(terrain_cache_size(Some(&config)), 1024);
}
#[cfg(not(feature = "http-tiles"))]
#[test]
fn terrain_cache_size_uses_default_without_http_tiles() {
assert_eq!(terrain_cache_size(), DEFAULT_TERRAIN_CACHE_SIZE);
}
}