//! Normalized static mesh artifacts generated from model source files. use std::fs; use std::path::Path; use bevy::gltf::GltfAssetLabel; use bevy::prelude::*; use bevy_ufbx::label::FbxAssetLabel; use bevy_ufbx::mesh::group_faces_by_material; use bevy_ufbx::texture::external_texture_paths; use bevy_ufbx::utils::convert_matrix; use serde::{Deserialize, Serialize}; use crate::asset_db::{ AssetRecord, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode, }; use shared::{ AssetSourceFingerprint, ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet, RendererMaterialSlot, StaticMeshRenderer, StaticMeshRendererEntry, }; use crate::assets::fingerprint::{fingerprint_file, write_pretty_ron_if_changed}; pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 4; pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StaticMeshManifest { pub schema_version: u32, pub asset_id: String, pub label: String, pub source: StaticMeshSource, pub import: StaticMeshImportSnapshot, pub metadata: StaticMeshMetadata, pub parts: Vec, pub warnings: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StaticMeshSource { pub path: String, pub format: String, pub fingerprint: StaticMeshSourceFingerprint, pub dependencies: Vec, } pub type StaticMeshSourceFingerprint = AssetSourceFingerprint; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StaticMeshImportSnapshot { pub scale: f32, pub generate_collider: bool, pub lod0_only: bool, pub placement_mode: ModelPlacementMode, pub hierarchy_mode: ModelHierarchyMode, pub material_policy: MaterialImportPolicy, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StaticMeshMetadata { pub mesh_count: usize, pub material_count: usize, pub node_count: usize, pub animation_count: usize, pub skin_count: usize, pub light_count: usize, pub camera_count: usize, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct StaticMeshPart { #[serde(default)] pub id: String, pub name: String, pub mesh_label: String, #[serde(default)] pub material_id: Option, pub material_slot_name: String, pub material_label: Option, pub local_transform: Transform, pub source_node: Option, pub source_mesh: Option, pub source_material: Option, /// Whether the source primitive is bound to a skin and therefore must never become a static /// renderer slot. #[serde(default)] pub skinned: bool, } pub fn static_mesh_manifest_path(asset_id: &str) -> String { format!("{STATIC_MESH_ARTIFACT_DIR}/{asset_id}.static_mesh.ron") } pub fn part_id_from_label(label: &str) -> String { format!("mesh:{}", stable_sub_asset_slug(label)) } pub fn material_id_from_label(label: &str) -> String { format!("material:{}", stable_sub_asset_slug(label)) } fn gltf_draw_id(node_index: Option, mesh_index: usize, primitive_index: usize) -> String { let node = node_index .map(|index| index.to_string()) .unwrap_or_else(|| "unbound".into()); format!("draw:scene0:node{node}:mesh{mesh_index}:primitive{primitive_index}") } fn fbx_draw_id(node_index: usize, material_index: usize) -> String { format!("draw:scene0:node{node_index}:material{material_index}") } 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() } pub fn refresh_static_mesh_artifact( record: &mut AssetRecord, ) -> Result { let mut manifest = build_static_mesh_manifest(record)?; let path = static_mesh_manifest_path(&record.id.as_string()); record.import_settings.static_mesh_manifest_path = Some(path.clone()); manifest.source.dependencies.sort(); manifest.source.dependencies.dedup(); record.dependencies = manifest.source.dependencies.clone(); if write_pretty_ron_if_changed(&path, &manifest) .map_err(|error| format!("could not publish static mesh manifest {path}: {error}"))? { info!( "Static mesh manifest refreshed: source={} artifact={} parts={}", record.path, path, manifest.parts.len() ); } Ok(manifest) } pub fn load_static_mesh_manifest(path: &str) -> Result { let text = fs::read_to_string(path).map_err(|err| format!("could not read {path}: {err}"))?; ron::from_str(&text).map_err(|err| format!("could not parse {path}: {err}")) } pub fn renderer_from_manifest( manifest: &StaticMeshManifest, settings: &ImportSettings, ) -> StaticMeshRenderer { let use_source_materials = matches!( settings.material_policy, MaterialImportPolicy::SourceMaterials ); let parts: Vec<_> = manifest .parts .iter() .filter(|part| !part.skinned && manifest.metadata.animation_count == 0) .map(|part| StaticMeshRendererEntry { id: ComponentInstanceId::new(part_effective_id(part)), name: part.name.clone(), mesh: EditorAssetRef::new( manifest.asset_id.clone(), part_effective_id(part), part.name.clone(), ), material_slot_id: ComponentInstanceId::new(material_slot_id(part)), material: use_source_materials .then(|| { part_effective_material_id(part).map(|id| { EditorAssetRef::new( manifest.asset_id.clone(), id, part.material_slot_name.clone(), ) }) }) .flatten(), local_transform: part.local_transform, visible: true, cast_shadows: true, receive_shadows: true, }) .collect(); let materials = RendererMaterialSet { slots: manifest .parts .iter() .filter(|part| !part.skinned && manifest.metadata.animation_count == 0) .map(|part| RendererMaterialSlot { id: ComponentInstanceId::new(material_slot_id(part)), name: part.material_slot_name.clone(), source_material: use_source_materials .then(|| { part_effective_material_id(part).map(|id| { MaterialRef::new(EditorAssetRef::new( manifest.asset_id.clone(), id, part.material_slot_name.clone(), )) }) }) .flatten(), material: None, }) .collect(), orphaned_assignments: Vec::new(), }; StaticMeshRenderer { slots: parts, materials, } } /// Builds the shared material slots for a full imported hierarchy. Unlike static renderer /// construction this intentionally includes skin-bound and rigid animated draw bindings. pub fn renderer_materials_from_manifest( manifest: &StaticMeshManifest, settings: &ImportSettings, ) -> RendererMaterialSet { let use_source_materials = matches!( settings.material_policy, MaterialImportPolicy::SourceMaterials ); RendererMaterialSet { slots: manifest .parts .iter() .map(|part| RendererMaterialSlot { id: ComponentInstanceId::new(material_slot_id(part)), name: part.material_slot_name.clone(), source_material: use_source_materials .then(|| { part_effective_material_id(part).map(|id| { MaterialRef::new(EditorAssetRef::new( manifest.asset_id.clone(), id, part.material_slot_name.clone(), )) }) }) .flatten(), material: None, }) .collect(), orphaned_assignments: Vec::new(), } } fn material_slot_id(part: &StaticMeshPart) -> String { format!("slot:{}", part_effective_id(part)) } fn part_effective_id(part: &StaticMeshPart) -> String { if part.id.trim().is_empty() { part_id_from_label(&part.mesh_label) } else { part.id.clone() } } fn part_effective_material_id(part: &StaticMeshPart) -> Option { part.material_id.clone().or_else(|| { part.material_label .as_ref() .map(|label| material_id_from_label(label)) }) } fn build_static_mesh_manifest(record: &AssetRecord) -> Result { let format = source_format(&record.path)?; let fingerprint = fingerprint_file(&record.path)?; let import = StaticMeshImportSnapshot { scale: record.import_settings.scale, generate_collider: record.import_settings.generate_collider, lod0_only: record.import_settings.lod0_only, placement_mode: record.import_settings.placement_mode, hierarchy_mode: record.import_settings.hierarchy_mode, material_policy: record.import_settings.material_policy, }; let mut manifest = match format.as_str() { "gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, import)?, "fbx" => build_fbx_manifest(record, format, fingerprint, import)?, _ => return Err(format!("unsupported static mesh source format `{format}`")), }; if manifest.parts.is_empty() { manifest .warnings .push("No renderable static mesh parts were found.".into()); } Ok(manifest) } fn build_gltf_manifest( record: &AssetRecord, format: String, fingerprint: StaticMeshSourceFingerprint, import: StaticMeshImportSnapshot, ) -> Result { let gltf = gltf::Gltf::open(&record.path) .map_err(|err| format!("could not parse glTF {}: {err}", record.path))?; let mut parts = Vec::new(); let mut dependencies = Vec::new(); let mut warnings = Vec::new(); for buffer in gltf.document.buffers() { if let gltf::buffer::Source::Uri(uri) = buffer.source() { dependencies.push(resolve_dependency(&record.path, uri)); } } for image in gltf.document.images() { if let gltf::image::Source::Uri { uri, .. } = image.source() { dependencies.push(resolve_dependency(&record.path, uri)); } } if let Some(scene) = gltf .document .default_scene() .or_else(|| gltf.document.scenes().next()) { for node in scene.nodes() { collect_gltf_node_parts(node, Mat4::IDENTITY, "", &mut parts); } } else { for mesh in gltf.document.meshes() { collect_gltf_mesh_parts(None, None, None, mesh, Mat4::IDENTITY, false, &mut parts); } } if gltf.document.animations().count() > 0 { warnings.push( "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer." .into(), ); } if gltf.document.skins().count() > 0 { warnings.push( "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer." .into(), ); } parts.sort_by(|a, b| a.mesh_label.cmp(&b.mesh_label).then(a.name.cmp(&b.name))); Ok(StaticMeshManifest { schema_version: STATIC_MESH_MANIFEST_SCHEMA, asset_id: record.id.as_string(), label: record.label.clone(), source: StaticMeshSource { path: record.path.clone(), format, fingerprint, dependencies, }, import, metadata: StaticMeshMetadata { mesh_count: gltf.document.meshes().count(), material_count: gltf.document.materials().count(), node_count: gltf.document.nodes().count(), animation_count: gltf.document.animations().count(), skin_count: gltf.document.skins().count(), light_count: 0, camera_count: gltf.document.cameras().count(), }, parts, warnings, }) } fn collect_gltf_node_parts( node: gltf::Node<'_>, parent_transform: Mat4, parent_path: &str, parts: &mut Vec, ) { let local = Mat4::from_cols_array_2d(&node.transform().matrix()); let world_transform = parent_transform * local; let segment = node .name() .map(str::to_string) .unwrap_or_else(|| format!("Node{}", node.index())); let node_path = if parent_path.is_empty() { segment } else { format!("{parent_path}/{segment}") }; let skinned = node.skin().is_some(); if let Some(mesh) = node.mesh() { collect_gltf_mesh_parts( node.name().map(str::to_string), Some(node.index()), Some(node_path.clone()), mesh, world_transform, skinned, parts, ); } for child in node.children() { collect_gltf_node_parts(child, world_transform, &node_path, parts); } } fn collect_gltf_mesh_parts( node_name: Option, node_index: Option, node_path: Option, mesh: gltf::Mesh<'_>, transform: Mat4, skinned: bool, parts: &mut Vec, ) { let mesh_index = mesh.index(); let mesh_name = mesh.name().map(str::to_string); for primitive in mesh.primitives() { let primitive_index = primitive.index(); let mesh_label = GltfAssetLabel::Primitive { mesh: mesh_index, primitive: primitive_index, } .to_string(); let material = primitive.material(); let material_label = material .index() .map(|index| { GltfAssetLabel::Material { index, is_scale_inverted: false, } .to_string() }) .or_else(|| Some(GltfAssetLabel::DefaultMaterial.to_string())); let material_name = material .name() .map(str::to_string) .unwrap_or_else(|| "Default Material".into()); let name = node_name .clone() .or_else(|| mesh_name.clone()) .unwrap_or_else(|| format!("Mesh {mesh_index}")); let source_node = node_path .clone() .or_else(|| node_index.map(|index| format!("Node{index}"))); parts.push(StaticMeshPart { id: gltf_draw_id(node_index, mesh_index, primitive_index), name: format!("{name} / Primitive {primitive_index}"), material_id: material_label .as_ref() .map(|label| material_id_from_label(label)), mesh_label, material_slot_name: material_name.clone(), material_label, local_transform: Transform::from_matrix(transform), source_node, source_mesh: Some(format!("Mesh{mesh_index}")), source_material: Some(material_name), skinned, }); } } fn build_fbx_manifest( record: &AssetRecord, format: String, fingerprint: StaticMeshSourceFingerprint, import: StaticMeshImportSnapshot, ) -> Result { let bytes = fs::read(&record.path).map_err(|err| format!("could not read {}: {err}", record.path))?; let scene = ufbx::load_memory( &bytes, ufbx::LoadOpts { target_unit_meters: 1.0, target_axes: ufbx::CoordinateAxes::right_handed_y_up(), filename: ufbx::StringOpt::Ref(&record.path), ..Default::default() }, ) .map_err(|err| format!("could not parse FBX {}: {err:?}", record.path))?; let mut parts = Vec::new(); let mut warnings = Vec::new(); for (node_index, node) in scene.nodes.as_ref().iter().enumerate() { let Some(mesh_ref) = node.mesh.as_ref() else { continue; }; let mesh = mesh_ref.as_ref(); if mesh.num_vertices == 0 || mesh.faces.as_ref().is_empty() { continue; } let skinned = !mesh.skin_deformers.as_ref().is_empty(); let mut groups: Vec<(usize, Vec)> = group_faces_by_material(mesh).into_iter().collect(); groups.sort_by_key(|(material_index, _)| *material_index); for (material_index, indices) in groups { if indices.is_empty() { continue; } let material = mesh.materials.get(material_index).map(|mat| mat.as_ref()); let material_label = material .and_then(|mat| fbx_material_label(&scene, mat.element.element_id)) .or_else(|| Some(FbxAssetLabel::DefaultMaterial.to_string())); let material_name = material .map(|mat| mat.element.name.to_string()) .filter(|name| !name.is_empty()) .unwrap_or_else(|| "Default Material".into()); let node_name = if node.element.name.is_empty() { format!("Node {node_index}") } else { node.element.name.to_string() }; let mesh_label = FbxAssetLabel::Mesh(node_index * 1000 + material_index).to_string(); parts.push(StaticMeshPart { id: fbx_draw_id(node_index, material_index), name: format!("{node_name} / Material {material_index}"), material_id: material_label .as_ref() .map(|label| material_id_from_label(label)), mesh_label, material_slot_name: material_name.clone(), material_label, local_transform: Transform::from_matrix(convert_matrix(&node.geometry_to_world)), source_node: Some(format!("Node{node_index}")), source_mesh: Some(format!("Mesh{node_index}")), source_material: Some(material_name), skinned, }); } } if !scene.anim_stacks.as_ref().is_empty() { warnings.push( "Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer." .into(), ); } if !scene.skin_deformers.as_ref().is_empty() { warnings.push( "Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer." .into(), ); } parts.sort_by(|a, b| a.mesh_label.cmp(&b.mesh_label).then(a.name.cmp(&b.name))); Ok(StaticMeshManifest { schema_version: STATIC_MESH_MANIFEST_SCHEMA, asset_id: record.id.as_string(), label: record.label.clone(), source: StaticMeshSource { path: record.path.clone(), format, fingerprint, dependencies: fbx_dependencies(&record.path, &scene)?, }, import, metadata: StaticMeshMetadata { mesh_count: scene.meshes.as_ref().len(), material_count: scene.materials.as_ref().len(), node_count: scene.nodes.as_ref().len(), animation_count: scene.anim_stacks.as_ref().len(), skin_count: scene.skin_deformers.as_ref().len(), light_count: scene.lights.as_ref().len(), camera_count: scene.cameras.as_ref().len(), }, parts, warnings, }) } fn fbx_material_label(scene: &ufbx::Scene, element_id: u32) -> Option { if element_id == 0 { return None; } scene .materials .as_ref() .iter() .position(|material| material.element.element_id == element_id) .map(|index| FbxAssetLabel::Material(index).to_string()) } fn fbx_dependencies(source_path: &str, scene: &ufbx::Scene) -> Result, String> { let path = Path::new(source_path); let parent = path.parent().unwrap_or_else(|| Path::new("")); external_texture_paths(scene) .map_err(|errors| { format!( "unsafe FBX texture reference(s) in {source_path}: {}", errors .into_iter() .map(|error| error.to_string()) .collect::>() .join("; ") ) }) .map(|paths| { paths .into_iter() .map(|relative| parent.join(relative).to_string_lossy().replace('\\', "/")) .collect() }) } fn source_format(path: &str) -> Result { Path::new(path) .extension() .and_then(|ext| ext.to_str()) .map(|ext| ext.to_ascii_lowercase()) .ok_or_else(|| format!("asset path `{path}` has no extension")) } fn resolve_dependency(source_path: &str, uri: &str) -> String { if uri.starts_with("data:") || uri.contains("://") { return uri.to_string(); } Path::new(source_path) .parent() .unwrap_or_else(|| Path::new("")) .join(uri) .to_string_lossy() .replace('\\', "/") } #[cfg(test)] mod tests { use super::*; use crate::asset_db::AssetId; fn test_manifest() -> StaticMeshManifest { StaticMeshManifest { schema_version: STATIC_MESH_MANIFEST_SCHEMA, asset_id: "asset-1".into(), label: "Crate".into(), source: StaticMeshSource { path: "assets/models/crate.glb".into(), format: "glb".into(), fingerprint: StaticMeshSourceFingerprint { byte_len: 42, content_hash: "a".repeat(64), }, dependencies: Vec::new(), }, import: StaticMeshImportSnapshot { scale: 1.0, generate_collider: true, lod0_only: true, placement_mode: ModelPlacementMode::StaticAsset, hierarchy_mode: ModelHierarchyMode::SingleActor, material_policy: MaterialImportPolicy::SourceMaterials, }, metadata: StaticMeshMetadata { mesh_count: 1, material_count: 1, node_count: 1, animation_count: 0, skin_count: 0, light_count: 0, camera_count: 0, }, parts: vec![StaticMeshPart { id: "mesh:mesh0_primitive0".into(), name: "Crate / Primitive 0".into(), mesh_label: "Mesh0/Primitive0".into(), material_id: Some("material:material0".into()), material_slot_name: "Wood".into(), material_label: Some("Material0".into()), local_transform: Transform::from_xyz(1.0, 2.0, 3.0), source_node: Some("Node0".into()), source_mesh: Some("Mesh0".into()), source_material: Some("Wood".into()), skinned: false, }], warnings: Vec::new(), } } #[test] fn static_mesh_manifest_path_uses_asset_id() { assert_eq!( static_mesh_manifest_path("abc"), "assets/meshes/generated/abc.static_mesh.ron" ); } #[test] fn renderer_from_manifest_preserves_labels_and_collider_policy() { let manifest = test_manifest(); let settings = ImportSettings { generate_collider: true, ..Default::default() }; let renderer = renderer_from_manifest(&manifest, &settings); assert_eq!(renderer.slots.len(), 1); let entry = &renderer.slots[0]; assert_eq!(entry.mesh.asset_id, manifest.asset_id); assert_eq!(entry.mesh.sub_asset_id, "mesh:mesh0_primitive0"); assert_eq!( entry .material .as_ref() .map(|material| material.sub_asset_id.as_str()), Some("material:material0") ); } #[test] fn schema_v3_manifest_without_hash_migrates_to_content_fingerprint() { let root = std::env::temp_dir().join(format!( "blacksite-static-mesh-legacy-{}", uuid::Uuid::new_v4() )); fs::create_dir_all(&root).unwrap(); let path = root.join("legacy.static_mesh.ron"); let expected = test_manifest(); let canonical = ron::ser::to_string_pretty(&expected, ron::ser::PrettyConfig::default()).unwrap(); let legacy = canonical .replacen("schema_version: 4", "schema_version: 3", 1) .lines() .filter(|line| !line.contains("content_hash:")) .collect::>() .join("\n"); fs::write(&path, legacy).unwrap(); assert!(write_pretty_ron_if_changed(&path, &expected).unwrap()); assert_eq!( load_static_mesh_manifest(&path.to_string_lossy()).unwrap(), expected ); fs::remove_dir_all(root).unwrap(); } #[test] fn equivalent_static_manifest_preserves_existing_formatting_and_newline() { let root = std::env::temp_dir().join(format!( "blacksite-static-mesh-semantic-{}", uuid::Uuid::new_v4() )); fs::create_dir_all(&root).unwrap(); let path = root.join("stable.static_mesh.ron"); let manifest = test_manifest(); let exact = format!( "{}\n\n", ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap() ); fs::write(&path, &exact).unwrap(); assert!(!write_pretty_ron_if_changed(&path, &manifest).unwrap()); assert_eq!(fs::read_to_string(&path).unwrap(), exact); fs::remove_dir_all(root).unwrap(); } #[test] fn renderer_from_manifest_can_ignore_source_materials() { let manifest = test_manifest(); let settings = ImportSettings { material_policy: MaterialImportPolicy::AuthoringOverride, ..Default::default() }; let renderer = renderer_from_manifest(&manifest, &settings); assert!(renderer.slots[0].material.is_none()); } #[test] fn renderer_from_manifest_excludes_skinned_primitives() { let mut manifest = test_manifest(); manifest.parts[0].skinned = true; let renderer = renderer_from_manifest(&manifest, &ImportSettings::default()); assert!(renderer.slots.is_empty()); } #[test] fn renderer_from_manifest_excludes_node_animated_geometry() { let mut manifest = test_manifest(); manifest.metadata.animation_count = 1; let renderer = renderer_from_manifest(&manifest, &ImportSettings::default()); assert!(renderer.slots.is_empty()); } #[test] fn committed_rigged_fixture_never_builds_static_renderer_slots() { let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../assets/models/robot_expressive.glb") .to_string_lossy() .into_owned(); let record = AssetRecord { id: AssetId::new(), path, label: "Robot Expressive".into(), kind_tag: "Model".into(), source_fingerprint: None, import_settings: ImportSettings::default(), dependencies: Vec::new(), }; let manifest = build_static_mesh_manifest(&record).unwrap(); let renderer = renderer_from_manifest(&manifest, &record.import_settings); assert!(manifest.metadata.skin_count > 0); assert!(manifest.metadata.animation_count > 0); assert!(manifest.parts.iter().any(|part| part.skinned)); assert!(renderer.slots.is_empty()); } #[test] fn committed_fbx_records_sibling_texture_dependencies() { let path = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../assets/models/painted_wooden_chair_02_2k.fbx") .to_string_lossy() .into_owned(); let record = AssetRecord { id: AssetId::new(), path, label: "Painted Chair".into(), kind_tag: "Model".into(), source_fingerprint: None, import_settings: ImportSettings::default(), dependencies: Vec::new(), }; let manifest = build_static_mesh_manifest(&record).unwrap(); assert_eq!(manifest.source.dependencies.len(), 3); assert!(manifest .source .dependencies .iter() .all(|path| path.contains("assets/models/textures/painted_wooden_chair_02_"))); } }