//! Project asset registry (stable IDs + import metadata). Phase 5 foundation. use bevy::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use uuid::Uuid; /// Stable asset identity for dependency tracking and prefab references. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct AssetId(pub Uuid); impl AssetId { pub fn new() -> Self { Self(Uuid::new_v4()) } pub fn as_string(&self) -> String { self.0.to_string() } } impl Default for AssetId { fn default() -> Self { Self::new() } } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] pub enum ModelPlacementMode { /// Place renderable content through the appropriate normalized renderer. Imports containing /// skins or animation route to `SkinnedMeshRenderer`; unrigged, non-animated content routes to /// `StaticMeshRenderer`. #[default] StaticAsset, /// Instantiate the complete source scene as a generic imported model. SceneInstance, } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] pub enum ModelHierarchyMode { #[default] SingleActor, SourceHierarchy, } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] pub enum MaterialImportPolicy { #[default] SourceMaterials, AuthoringOverride, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ImportSettings { pub scale: f32, pub generate_collider: bool, pub lod0_only: bool, #[serde(default)] pub placement_mode: ModelPlacementMode, #[serde(default)] pub hierarchy_mode: ModelHierarchyMode, #[serde(default)] pub material_policy: MaterialImportPolicy, #[serde(default)] pub static_mesh_manifest_path: Option, #[serde(default)] pub animation_manifest_path: Option, /// Stable animation clip sub-asset ID used as this model's edit-mode rest presentation. /// `None` preserves the imported node pose and never guesses a clip. #[serde(default)] pub default_animation_clip_id: Option, } impl Default for ImportSettings { fn default() -> Self { Self { scale: 1.0, generate_collider: true, lod0_only: true, placement_mode: ModelPlacementMode::default(), hierarchy_mode: ModelHierarchyMode::default(), material_policy: MaterialImportPolicy::default(), static_mesh_manifest_path: None, animation_manifest_path: None, default_animation_clip_id: None, } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct AssetRecord { pub id: AssetId, pub path: String, pub label: String, pub kind_tag: String, #[serde(default)] pub import_settings: ImportSettings, #[serde(default)] pub dependencies: Vec, } #[derive(Resource, Debug, Default)] pub struct AssetRegistry { pub records: Vec, pub index_dirty: bool, } pub struct AssetDbPlugin; impl Plugin for AssetDbPlugin { fn build(&self, app: &mut App) { app.insert_resource(load_registry()) .add_systems(Update, sync_registry_from_browser); } } fn sync_registry_from_browser( assets: Res, mut registry: ResMut, ) { if !assets.is_changed() { return; } let mut existing: HashMap = registry .records .drain(..) .map(|record| (record.path.clone(), record)) .collect(); let current_paths = assets .assets .iter() .filter_map(|asset| asset.path.clone()) .collect::>(); let mut next_records = Vec::new(); let mut seen_paths = HashMap::new(); for asset in &assets.assets { let Some(path) = asset.path.clone() else { continue; }; if path.is_empty() || seen_paths.contains_key(&path) { continue; } seen_paths.insert(path.clone(), ()); let kind_tag = format!("{:?}", asset.kind); let mut record = if let Some(mut prior) = existing.remove(&path).or_else(|| { take_uniquely_moved_model_record(&mut existing, ¤t_paths, &path, &kind_tag) }) { if prior.path != path { info!( "Asset registry preserved model identity {} across move {} -> {}", prior.id.as_string(), prior.path, path ); prior.path = path.clone(); } prior.label = asset.label.clone(); prior.kind_tag = kind_tag; prior } else { AssetRecord { id: AssetId::new(), path: path.clone(), label: asset.label.clone(), kind_tag, import_settings: ImportSettings::default(), dependencies: Vec::new(), } }; if record.kind_tag == "Model" { if let Err(error) = super::refresh_model_artifacts(&mut record) { warn!( "Model artifact refresh failed for {} (asset id {}): {error}", record.path, record.id.as_string() ); } } next_records.push(record); } let changed = registry.records != next_records; registry.records = next_records; if changed { registry.index_dirty = true; } if registry.index_dirty { if let Err(error) = save_registry(®istry) { warn!("Asset registry save failed: {error}"); } else { registry.index_dirty = false; } } } fn take_uniquely_moved_model_record( existing: &mut HashMap, current_paths: &HashSet, new_path: &str, kind_tag: &str, ) -> Option { if kind_tag != "Model" { return None; } let bytes = std::fs::read(new_path).ok()?; let content_hash = blake3::hash(&bytes).to_hex().to_string(); let candidates = existing .iter() .filter_map(|(old_path, record)| { if current_paths.contains(old_path) || record.kind_tag != "Model" { return None; } let manifest_path = record.import_settings.animation_manifest_path.as_deref()?; let manifest = super::animation::load_animation_manifest(manifest_path).ok()?; (manifest.source.fingerprint.content_hash == content_hash).then(|| old_path.clone()) }) .collect::>(); if candidates.len() != 1 { return None; } existing.remove(&candidates[0]) } pub fn find_asset_by_path(registry: &AssetRegistry, path: &str) -> Option { registry .records .iter() .find(|record| record.path == path) .cloned() } pub fn find_asset_by_id(registry: &AssetRegistry, id: &str) -> Option { registry .records .iter() .find(|record| record.id.as_string() == id) .cloned() } pub fn find_asset_mut_by_path<'a>( registry: &'a mut AssetRegistry, path: &str, ) -> Option<&'a mut AssetRecord> { registry .records .iter_mut() .find(|record| record.path == path) } pub fn ensure_asset_record( registry: &mut AssetRegistry, path: impl Into, label: impl Into, kind_tag: impl Into, ) -> Result { let path = path.into(); if let Some(record) = find_asset_by_path(registry, &path) { return Ok(record); } let record = AssetRecord { id: AssetId::new(), path, label: label.into(), kind_tag: kind_tag.into(), import_settings: ImportSettings::default(), dependencies: Vec::new(), }; registry.records.push(record.clone()); registry.index_dirty = true; if let Err(error) = save_registry(registry) { registry.records.pop(); return Err(error); } registry.index_dirty = false; Ok(record) } pub fn update_import_settings( registry: &mut AssetRegistry, path: &str, settings: ImportSettings, ) -> bool { let Some(record) = registry .records .iter_mut() .find(|record| record.path == path) else { return false; }; record.import_settings = settings; registry.index_dirty = true; true } pub fn registry_index_path() -> &'static str { "assets/.index/registry.ron" } pub fn save_registry(registry: &AssetRegistry) -> Result<(), String> { let path = registry_index_path(); if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } let text = ron::ser::to_string_pretty(®istry.records, ron::ser::PrettyConfig::default()) .map_err(|e| e.to_string())?; std::fs::write(path, text).map_err(|e| e.to_string()) } pub fn load_registry() -> AssetRegistry { let path = registry_index_path(); match std::fs::read_to_string(path) { Ok(text) => { let records: Vec = ron::from_str(&text).unwrap_or_default(); AssetRegistry { records, index_dirty: false, } } Err(_) => AssetRegistry::default(), } } #[cfg(test)] mod tests { use super::*; use shared::{ AnimationManifest, AnimationManifestSource, AnimationSourceFingerprint, ANIMATION_MANIFEST_SCHEMA_VERSION, }; fn moved_model_fixture(bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf, AssetRecord) { let key = Uuid::new_v4(); let source_path = std::env::temp_dir().join(format!("blacksite-moved-model-{key}.glb")); let manifest_path = std::env::temp_dir().join(format!("blacksite-moved-model-{key}.animation.ron")); std::fs::write(&source_path, bytes).unwrap(); let id = AssetId::new(); let old_path = format!("assets/models/old-{key}.glb"); let manifest = AnimationManifest { schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION, asset_id: id.as_string(), label: "Moved Model".into(), default_animation_clip_id: None, source: AnimationManifestSource { path: old_path.clone(), format: "glb".into(), fingerprint: AnimationSourceFingerprint { byte_len: bytes.len() as u64, modified_unix_secs: 0, content_hash: blake3::hash(bytes).to_hex().to_string(), }, dependencies: Vec::new(), }, runtime_supported: true, skeletons: Vec::new(), clips: Vec::new(), diagnostics: Vec::new(), }; std::fs::write( &manifest_path, ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap(), ) .unwrap(); let record = AssetRecord { id, path: old_path, label: "Moved Model".into(), kind_tag: "Model".into(), import_settings: ImportSettings { animation_manifest_path: Some(manifest_path.to_string_lossy().into_owned()), ..Default::default() }, dependencies: Vec::new(), }; (source_path, manifest_path, record) } #[test] fn unique_content_match_preserves_model_record_across_move() { let (source_path, manifest_path, record) = moved_model_fixture(b"stable model bytes"); let expected_id = record.id.clone(); let mut existing = HashMap::from([(record.path.clone(), record)]); let moved = take_uniquely_moved_model_record( &mut existing, &HashSet::new(), &source_path.to_string_lossy(), "Model", ) .expect("unique moved model should retain its registry record"); assert_eq!(moved.id, expected_id); assert!(existing.is_empty()); let _ = std::fs::remove_file(source_path); let _ = std::fs::remove_file(manifest_path); } #[test] fn existing_source_path_is_not_reconciled_as_a_copy_move() { let (source_path, manifest_path, record) = moved_model_fixture(b"copied model bytes"); let old_path = record.path.clone(); let mut existing = HashMap::from([(old_path.clone(), record)]); let current_paths = HashSet::from([old_path]); let moved = take_uniquely_moved_model_record( &mut existing, ¤t_paths, &source_path.to_string_lossy(), "Model", ); assert!(moved.is_none()); assert_eq!(existing.len(), 1); let _ = std::fs::remove_file(source_path); let _ = std::fs::remove_file(manifest_path); } }