856 lines
29 KiB
Rust
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_")));
|
|
}
|
|
}
|