Blacksite/crates/editor/src/assets/static_mesh.rs

856 lines
29 KiB
Rust

//! 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<StaticMeshPart>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StaticMeshSource {
pub path: String,
pub format: String,
pub fingerprint: StaticMeshSourceFingerprint,
pub dependencies: Vec<String>,
}
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<String>,
pub material_slot_name: String,
pub material_label: Option<String>,
pub local_transform: Transform,
pub source_node: Option<String>,
pub source_mesh: Option<String>,
pub source_material: Option<String>,
/// 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<usize>, 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<StaticMeshManifest, String> {
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<StaticMeshManifest, String> {
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<String> {
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<StaticMeshManifest, String> {
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<StaticMeshManifest, String> {
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<StaticMeshPart>,
) {
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<String>,
node_index: Option<usize>,
node_path: Option<String>,
mesh: gltf::Mesh<'_>,
transform: Mat4,
skinned: bool,
parts: &mut Vec<StaticMeshPart>,
) {
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<StaticMeshManifest, String> {
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<u32>)> =
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<String> {
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<Vec<String>, 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::<Vec<_>>()
.join("; ")
)
})
.map(|paths| {
paths
.into_iter()
.map(|relative| parent.join(relative).to_string_lossy().replace('\\', "/"))
.collect()
})
}
fn source_format(path: &str) -> Result<String, String> {
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::<Vec<_>>()
.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_")));
}
}