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.
423 lines
13 KiB
Rust
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, ¤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<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(®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<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,
|
|
¤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);
|
|
}
|
|
}
|