Blacksite/crates/editor/src/assets/asset_db.rs
Rbanh 0798aa5d57 Build renderer and material component foundations
Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
2026-07-12 00:24:06 -04:00

423 lines
13 KiB
Rust

//! 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<String>,
#[serde(default)]
pub animation_manifest_path: Option<String>,
/// 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<String>,
}
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<String>,
}
#[derive(Resource, Debug, Default)]
pub struct AssetRegistry {
pub records: Vec<AssetRecord>,
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<crate::assets::EditorAssets>,
mut registry: ResMut<AssetRegistry>,
) {
if !assets.is_changed() {
return;
}
let mut existing: HashMap<String, AssetRecord> = registry
.records
.drain(..)
.map(|record| (record.path.clone(), record))
.collect();
let current_paths = assets
.assets
.iter()
.filter_map(|asset| asset.path.clone())
.collect::<HashSet<_>>();
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, &current_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(&registry) {
warn!("Asset registry save failed: {error}");
} else {
registry.index_dirty = false;
}
}
}
fn take_uniquely_moved_model_record(
existing: &mut HashMap<String, AssetRecord>,
current_paths: &HashSet<String>,
new_path: &str,
kind_tag: &str,
) -> Option<AssetRecord> {
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::<Vec<_>>();
if candidates.len() != 1 {
return None;
}
existing.remove(&candidates[0])
}
pub fn find_asset_by_path(registry: &AssetRegistry, path: &str) -> Option<AssetRecord> {
registry
.records
.iter()
.find(|record| record.path == path)
.cloned()
}
pub fn find_asset_by_id(registry: &AssetRegistry, id: &str) -> Option<AssetRecord> {
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<String>,
label: impl Into<String>,
kind_tag: impl Into<String>,
) -> Result<AssetRecord, String> {
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(&registry.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<AssetRecord> = 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,
&current_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);
}
}