//! Static mesh renderer hydration from normalized authoring entries. use avian3d::prelude::ColliderConstructor; use bevy::light::{NotShadowCaster, NotShadowReceiver}; use bevy::prelude::*; use serde::Deserialize; use std::collections::{HashMap, HashSet}; use crate::{ asset_server_path, inspector_component_active, ColliderDesc, ColliderShapeDesc, EditorAssetRef, InspectorOrder, LevelObject, MaterialDesc, MaterialOverride, StaticMeshRenderer, StaticMeshRendererEntry, COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_STATIC_MESH_RENDERER, }; use super::materials::material_from_desc; pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated"; /// Runtime marker for mesh children generated from [`StaticMeshRenderer`]. #[derive(Component, Reflect, Default, Debug, Clone, Copy)] #[reflect(Component, Default, Debug)] pub struct HydratedStaticMeshPart; #[derive(Debug, Clone)] struct ResolvedStaticMeshPart { source_path: String, mesh_label: String, material_label: Option, } #[derive(Debug, Clone)] struct ResolvedStaticMeshMaterial { source_path: String, material_label: String, } /// Cache for generated static mesh artifact lookups. #[derive(Resource, Default)] pub struct StaticMeshArtifactCache { parts: HashMap<(String, String), ResolvedStaticMeshPart>, materials: HashMap<(String, String), ResolvedStaticMeshMaterial>, loaded_assets: HashSet, failed_assets: HashSet, } impl StaticMeshArtifactCache { fn resolve(&mut self, mesh: &EditorAssetRef) -> Option { if !mesh.is_resolved() { return None; } let key = (mesh.asset_id.clone(), mesh.sub_asset_id.clone()); if let Some(part) = self.parts.get(&key) { return Some(part.clone()); } if !self.loaded_assets.contains(&mesh.asset_id) && !self.failed_assets.contains(&mesh.asset_id) { self.load_artifact(&mesh.asset_id); } self.parts.get(&key).cloned() } fn resolve_material( &mut self, material: &EditorAssetRef, ) -> Option { if !material.is_resolved() { return None; } let key = (material.asset_id.clone(), material.sub_asset_id.clone()); if let Some(material) = self.materials.get(&key) { return Some(material.clone()); } if !self.loaded_assets.contains(&material.asset_id) && !self.failed_assets.contains(&material.asset_id) { self.load_artifact(&material.asset_id); } self.materials.get(&key).cloned() } fn load_artifact(&mut self, asset_id: &str) { let path = static_mesh_manifest_path(asset_id); match std::fs::read_to_string(&path) .map_err(|err| format!("could not read {path}: {err}")) .and_then(|text| { ron::from_str::(&text) .map_err(|err| format!("could not parse {path}: {err}")) }) { Ok(manifest) => { for part in manifest.parts { let part_id = if part.id.trim().is_empty() { part_id_from_label(&part.mesh_label) } else { part.id.clone() }; self.parts.insert( (asset_id.to_string(), part_id), ResolvedStaticMeshPart { source_path: manifest.source.path.clone(), mesh_label: part.mesh_label.clone(), material_label: part.material_label.clone(), }, ); if let Some(material_label) = part.material_label { let material_id = part .material_id .filter(|id| !id.trim().is_empty()) .unwrap_or_else(|| material_id_from_label(&material_label)); self.materials .entry((asset_id.to_string(), material_id)) .or_insert(ResolvedStaticMeshMaterial { source_path: manifest.source.path.clone(), material_label, }); } } self.loaded_assets.insert(asset_id.to_string()); } Err(error) => { warn!("Static mesh artifact lookup failed for asset {asset_id}: {error}"); self.failed_assets.insert(asset_id.to_string()); } } } } #[derive(Debug, Deserialize)] struct StaticMeshResolveManifest { source: StaticMeshResolveSource, #[serde(default)] parts: Vec, } #[derive(Debug, Deserialize)] struct StaticMeshResolveSource { path: String, } #[derive(Debug, Deserialize)] struct StaticMeshResolvePart { #[serde(default)] id: String, mesh_label: String, #[serde(default)] material_id: Option, #[serde(default)] material_label: Option, } #[allow(clippy::type_complexity)] pub fn hydrate_static_mesh_renderers( mut commands: Commands, asset_server: Res, mut materials: ResMut>, mut artifact_cache: ResMut, renderers: Query< ( Entity, &StaticMeshRenderer, Option<&MaterialDesc>, Option<&MaterialOverride>, Option<&ColliderDesc>, Option<&InspectorOrder>, ), ( With, Or<( Added, Changed, Changed, Changed, Changed, Changed, )>, ), >, children: Query<&Children>, generated_parts: Query<(), With>, ) { for (entity, renderer, parent_material, material_override, collider, order) in &renderers { despawn_static_mesh_parts(&mut commands, entity, &children, &generated_parts); if !inspector_component_active(order, COMPONENT_STATIC_MESH_RENDERER) { continue; } let parent_material = parent_material.filter(|_| inspector_component_active(order, COMPONENT_MATERIAL_DESC)); let collider = collider.filter(|_| inspector_component_active(order, COMPONENT_COLLIDER_DESC)); spawn_static_mesh_parts( &mut commands, &asset_server, &mut materials, &mut artifact_cache, entity, renderer, parent_material, material_override, collider, ); } } #[expect( clippy::too_many_arguments, reason = "hydration keeps Bevy asset stores and authored mesh inputs explicit" )] pub fn spawn_static_mesh_parts( commands: &mut Commands, asset_server: &AssetServer, materials: &mut Assets, artifact_cache: &mut StaticMeshArtifactCache, parent: Entity, renderer: &StaticMeshRenderer, parent_material: Option<&MaterialDesc>, material_override: Option<&MaterialOverride>, collider: Option<&ColliderDesc>, ) { let mut spawned = 0usize; for (index, entry) in renderer.slots.iter().enumerate() { if !entry.visible { continue; } let Some(resolved) = artifact_cache.resolve(&entry.mesh) else { warn!( "Static mesh renderer slot skipped: unresolved mesh asset ref asset_id='{}' part='{}' label='{}'", entry.mesh.asset_id, entry.mesh.sub_asset_id, entry.mesh.label ); continue; }; let source_path = asset_server_path(&resolved.source_path); let mesh_handle: Handle = asset_server.load(labeled_asset_path(&source_path, &resolved.mesh_label)); let material_handle = material_for_entry( asset_server, materials, artifact_cache, entry, &resolved, parent_material, material_override, ); let name = if entry.name.trim().is_empty() { format!("Static Mesh Part {index}") } else { entry.name.clone() }; let mut part = commands.spawn(( HydratedStaticMeshPart, Name::new(name), Mesh3d(mesh_handle), MeshMaterial3d(material_handle), entry.local_transform, Visibility::Visible, ChildOf(parent), )); if !entry.cast_shadows { part.insert(NotShadowCaster); } if !entry.receive_shadows { part.insert(NotShadowReceiver); } if should_attach_static_mesh_collider(collider, &entry.mesh) { part.insert(ColliderConstructor::TrimeshFromMesh); } spawned += 1; } debug!( "Hydrated static mesh renderer: parent={parent:?} slots={} spawned={spawned}", renderer.slots.len() ); } pub fn despawn_static_mesh_parts( commands: &mut Commands, entity: Entity, children: &Query<&Children>, generated_parts: &Query<(), With>, ) { let Ok(child_list) = children.get(entity) else { return; }; for child in child_list.iter() { if generated_parts.get(child).is_ok() { commands.entity(child).despawn(); } } } fn material_for_entry( asset_server: &AssetServer, materials: &mut Assets, artifact_cache: &mut StaticMeshArtifactCache, entry: &StaticMeshRendererEntry, resolved: &ResolvedStaticMeshPart, parent_material: Option<&MaterialDesc>, material_override: Option<&MaterialOverride>, ) -> Handle { if let Some(parent_material) = parent_material { return materials.add(material_from_desc(asset_server, parent_material)); } if let Some(override_material) = material_override.and_then(|overrides| { overrides .slots .iter() .find(|slot| slot.slot_id == entry.id) .map(|slot| &slot.material) }) { return materials.add(material_from_desc(asset_server, override_material)); } if let Some(material_ref) = entry.material.as_ref() { if let Some(resolved_material) = artifact_cache.resolve_material(material_ref) { let source_path = asset_server_path(&resolved_material.source_path); return asset_server.load(labeled_asset_path( &source_path, &resolved_material.material_label, )); } debug!( "Static mesh material ref unresolved: asset_id='{}' material='{}' label='{}'", material_ref.asset_id, material_ref.sub_asset_id, material_ref.label ); } if let Some(label) = resolved.material_label.as_deref() { let source_path = asset_server_path(&resolved.source_path); return asset_server.load(labeled_asset_path(&source_path, label)); } materials.add(StandardMaterial::default()) } fn labeled_asset_path(path: &str, label: &str) -> String { format!("{path}#{label}") } fn should_attach_static_mesh_collider( collider: Option<&ColliderDesc>, mesh: &EditorAssetRef, ) -> bool { let Some(collider) = collider else { return false; }; if !collider.enabled { return false; } let ColliderShapeDesc::StaticMesh { meshes, .. } = &collider.shape else { return false; }; meshes.iter().any(|candidate| candidate == mesh) } pub fn static_mesh_manifest_path(asset_id: &str) -> String { format!("{STATIC_MESH_ARTIFACT_DIR}/{asset_id}.static_mesh.ron") } fn part_id_from_label(label: &str) -> String { format!("mesh:{}", stable_sub_asset_slug(label)) } fn material_id_from_label(label: &str) -> String { format!("material:{}", stable_sub_asset_slug(label)) } fn stable_sub_asset_slug(label: &str) -> String { let mut slug = String::new(); for ch in label.chars() { if ch.is_ascii_alphanumeric() { slug.push(ch.to_ascii_lowercase()); } else if !slug.ends_with('_') { slug.push('_'); } } slug.trim_matches('_').to_string() }