1#![warn(missing_docs)]
52
53use animsmith_core::model::{
54 Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, MeshInstance,
55 NormalTextureAsset, Primitive, Property, SceneAsset, SceneAssets, Skeleton, SourceInfo,
56 TextureAsset, Track, TrackValues, Transform,
57};
58use glam::{Mat4, Quat, Vec3};
59use std::path::Path;
60
61#[derive(Debug, thiserror::Error)]
67#[non_exhaustive]
68pub enum LoadError {
69 #[error("path is not valid UTF-8: {0}")]
71 Path(String),
72 #[error("FBX parse error: {0}")]
74 Fbx(String),
75 #[error("animation bake failed for take {take:?}: {message}")]
77 Bake {
78 take: String,
80 message: String,
82 },
83}
84
85fn vec3(v: ufbx::Vec3) -> Vec3 {
86 Vec3::new(v.x as f32, v.y as f32, v.z as f32)
87}
88
89fn quat(q: ufbx::Quat) -> Quat {
90 Quat::from_xyzw(q.x as f32, q.y as f32, q.z as f32, q.w as f32)
91}
92
93fn transform(t: &ufbx::Transform) -> Transform {
94 Transform {
95 translation: vec3(t.translation),
96 rotation: quat(t.rotation),
97 scale: vec3(t.scale),
98 }
99}
100
101fn mat4(m: &ufbx::Matrix) -> Mat4 {
103 Mat4::from_cols_array(&[
104 m.m00 as f32,
105 m.m10 as f32,
106 m.m20 as f32,
107 0.0,
108 m.m01 as f32,
109 m.m11 as f32,
110 m.m21 as f32,
111 0.0,
112 m.m02 as f32,
113 m.m12 as f32,
114 m.m22 as f32,
115 0.0,
116 m.m03 as f32,
117 m.m13 as f32,
118 m.m23 as f32,
119 1.0,
120 ])
121}
122
123pub fn load(path: &Path) -> Result<Document, LoadError> {
135 path.to_str()
136 .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
137 let bytes = std::fs::read(path).map_err(|error| LoadError::Fbx(error.to_string()))?;
138 load_bytes(path, &bytes)
139}
140
141pub fn load_bytes(path: &Path, bytes: &[u8]) -> Result<Document, LoadError> {
154 let filename = path
155 .to_str()
156 .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
157 let opts = ufbx::LoadOpts {
158 target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
159 target_unit_meters: 1.0,
160 space_conversion: ufbx::SpaceConversion::AdjustTransforms,
161 geometry_transform_handling: ufbx::GeometryTransformHandling::HelperNodes,
162 inherit_mode_handling: ufbx::InheritModeHandling::Compensate,
168 generate_missing_normals: true,
169 filename: filename.into(),
170 ..Default::default()
171 };
172 let scene = ufbx::load_memory(bytes, opts).map_err(|e| LoadError::Fbx(format!("{e:?}")))?;
173
174 let mut bones: Vec<Bone> = Vec::with_capacity(scene.nodes.len());
179 for node in &scene.nodes {
180 let name = if node.element.name.is_empty() {
181 if node.is_root {
182 "<fbx-root>".to_string()
183 } else {
184 format!("node{}", node.element.typed_id)
185 }
186 } else {
187 node.element.name.to_string()
188 };
189 bones.push(Bone {
190 name,
191 parent: node.parent.as_ref().map(|p| p.element.typed_id as usize),
192 rest: transform(&node.local_transform),
193 inverse_bind: None,
194 });
195 }
196 for cluster in &scene.skin_clusters {
197 if let Some(bone_node) = &cluster.bone_node {
198 let id = bone_node.element.typed_id as usize;
199 if id < bones.len() {
200 bones[id].inverse_bind = Some(mat4(&cluster.bind_to_world).inverse());
204 }
205 }
206 }
207
208 let mut clips = Vec::new();
209 for (index, stack) in scene.anim_stacks.iter().enumerate() {
210 let take = if stack.element.name.is_empty() {
211 format!("take{index}")
212 } else {
213 stack.element.name.to_string()
214 };
215 let baked = ufbx::bake_anim(
216 &scene,
217 &stack.anim,
218 ufbx::BakeOpts {
219 trim_start_time: true,
220 ..Default::default()
221 },
222 )
223 .map_err(|e| LoadError::Bake {
224 take: take.clone(),
225 message: format!("{e:?}"),
226 })?;
227
228 let mut tracks = Vec::new();
229 let mut duration = 0.0f64;
230 for node in &baked.nodes {
231 let bone = node.typed_id as usize;
232 if !node.translation_keys.is_empty() {
233 let times: Vec<f32> = node
234 .translation_keys
235 .iter()
236 .map(|k| k.time as f32)
237 .collect();
238 let values: Vec<Vec3> = node
239 .translation_keys
240 .iter()
241 .map(|k| vec3(k.value))
242 .collect();
243 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
244 tracks.push(Track {
245 bone,
246 property: Property::Translation,
247 interpolation: Interpolation::Linear,
248 times,
249 values: TrackValues::Vec3s(values),
250 });
251 }
252 if !node.rotation_keys.is_empty() {
253 let times: Vec<f32> = node.rotation_keys.iter().map(|k| k.time as f32).collect();
254 let values: Vec<Quat> = node.rotation_keys.iter().map(|k| quat(k.value)).collect();
255 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
256 tracks.push(Track {
257 bone,
258 property: Property::Rotation,
259 interpolation: Interpolation::Linear,
260 times,
261 values: TrackValues::Quats(values),
262 });
263 }
264 if !node.scale_keys.is_empty() {
265 let times: Vec<f32> = node.scale_keys.iter().map(|k| k.time as f32).collect();
266 let values: Vec<Vec3> = node.scale_keys.iter().map(|k| vec3(k.value)).collect();
267 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
268 tracks.push(Track {
269 bone,
270 property: Property::Scale,
271 interpolation: Interpolation::Linear,
272 times,
273 values: TrackValues::Vec3s(values),
274 });
275 }
276 }
277 clips.push(Clip {
278 name: take,
279 duration_s: duration,
280 tracks,
281 });
282 }
283
284 let assets = extract_assets(&scene, path.parent());
285
286 Ok(Document {
287 skeleton: Skeleton { bones },
288 clips,
289 assets,
290 source: SourceInfo {
291 path: Some(path.display().to_string()),
292 format: Some("fbx".into()),
293 },
294 })
295}
296
297fn texture_asset(texture: &ufbx::Texture, base_dir: Option<&Path>) -> Option<TextureAsset> {
300 let bytes: Vec<u8> = if !texture.content.is_empty() {
301 texture.content.to_vec()
302 } else {
303 let mut found = None;
304 for candidate in [
305 texture.absolute_filename.as_ref(),
306 texture.relative_filename.as_ref(),
307 texture.filename.as_ref(),
308 ] {
309 if candidate.is_empty() {
310 continue;
311 }
312 let direct = Path::new(candidate);
313 let path = if direct.is_absolute() {
314 direct.to_path_buf()
315 } else {
316 base_dir.unwrap_or(Path::new(".")).join(direct)
317 };
318 if let Ok(data) = std::fs::read(&path) {
319 found = Some(data);
320 break;
321 }
322 }
323 found?
324 };
325 let mime = match bytes.get(..3) {
326 Some([0x89, b'P', b'N']) => "image/png",
327 Some([0xFF, 0xD8, _]) => "image/jpeg",
328 _ => return None,
329 };
330 Some(TextureAsset {
331 bytes,
332 mime: mime.into(),
333 })
334}
335
336fn base_color_texture(material: &ufbx::Material, base_dir: Option<&Path>) -> Option<TextureAsset> {
337 let texture = material.pbr.base_color.texture.as_ref().or(material
338 .fbx
339 .diffuse_color
340 .texture
341 .as_ref())?;
342 texture_asset(texture, base_dir)
343}
344
345fn normal_texture(
346 material: &ufbx::Material,
347 base_dir: Option<&Path>,
348) -> Option<NormalTextureAsset> {
349 let texture = material.pbr.normal_map.texture.as_ref().or(material
350 .fbx
351 .normal_map
352 .texture
353 .as_ref())?;
354 texture_asset(texture, base_dir).map(|texture| NormalTextureAsset {
355 texture,
356 scale: 1.0,
360 })
361}
362
363fn extract_assets(scene: &ufbx::Scene, base_dir: Option<&Path>) -> SceneAssets {
368 let mut assets = SceneAssets::default();
369 let mut material_index: std::collections::BTreeMap<u32, usize> =
370 std::collections::BTreeMap::new();
371
372 for (source_node_index, node) in scene.nodes.iter().enumerate() {
373 let Some(mesh) = &node.mesh else { continue };
374 let node_id = node.element.typed_id as usize;
375
376 let local_materials: Vec<usize> = mesh
378 .materials
379 .iter()
380 .map(|m| {
381 *material_index
382 .entry(m.element.element_id)
383 .or_insert_with(|| {
384 let base = if m.pbr.base_color.has_value {
385 m.pbr.base_color.value_vec4
386 } else {
387 m.fbx.diffuse_color.value_vec4
388 };
389 let texture = base_color_texture(m, base_dir);
390 let normal_texture = normal_texture(m, base_dir);
391 assets.materials.push(MaterialAsset {
392 name: m.element.name.to_string(),
393 base_color: if texture.is_some() {
396 [1.0, 1.0, 1.0, 1.0]
397 } else {
398 [base.x as f32, base.y as f32, base.z as f32, base.w as f32]
399 },
400 metallic: if m.pbr.metalness.has_value {
401 m.pbr.metalness.value_vec4.x as f32
402 } else {
403 0.0
404 },
405 roughness: if m.pbr.roughness.has_value {
406 m.pbr.roughness.value_vec4.x as f32
407 } else {
408 1.0
409 },
410 base_color_texture: texture,
411 normal_texture,
412 metallic_roughness_texture: None,
413 occlusion_texture: None,
414 });
415 assets.materials.len() - 1
416 })
417 })
418 .collect();
419
420 let skin = mesh.skin_deformers.first();
423 let skin_joints: Vec<usize> = skin
424 .map(|s| {
425 s.clusters
426 .iter()
427 .map(|c| {
428 c.bone_node
429 .as_ref()
430 .map(|b| b.element.typed_id as usize)
431 .unwrap_or(0)
432 })
433 .collect()
434 })
435 .unwrap_or_default();
436 let skin_ibms: Vec<glam::Mat4> = skin
440 .map(|s| {
441 s.clusters
442 .iter()
443 .map(|c| mat4(&c.bind_to_world).inverse() * mat4(&c.geometry_to_world))
444 .collect()
445 })
446 .unwrap_or_default();
447 let vertex_influences: Vec<([u16; 4], [f32; 4])> = skin
448 .map(|s| {
449 (0..mesh.num_vertices)
450 .map(|v| {
451 let mut pairs: Vec<(u16, f32)> = Vec::new();
452 if let Some(sv) = s.vertices.get(v) {
453 for w in 0..sv.num_weights as usize {
454 let sw = &s.weights[sv.weight_begin as usize + w];
455 pairs.push((sw.cluster_index as u16, sw.weight as f32));
456 }
457 }
458 pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
459 pairs.truncate(4);
460 let total: f32 = pairs.iter().map(|p| p.1).sum();
461 let mut joints = [0u16; 4];
462 let mut weights = [0f32; 4];
463 for (slot, (j, w)) in pairs.into_iter().enumerate() {
464 joints[slot] = j;
465 weights[slot] = if total > 0.0 { w / total } else { 0.0 };
466 }
467 (joints, weights)
468 })
469 .collect()
470 })
471 .unwrap_or_default();
472
473 let slots = local_materials.len().max(1);
475 let mut primitives: Vec<Primitive> = (0..slots)
476 .map(|slot| Primitive {
477 material: local_materials.get(slot).copied(),
478 ..Primitive::default()
479 })
480 .collect();
481
482 let mut tri_indices = vec![0u32; mesh.max_face_triangles * 3];
483 for (face_index, &face) in mesh.faces.iter().enumerate() {
484 let slot = mesh
485 .face_material
486 .get(face_index)
487 .map(|&m| m as usize)
488 .filter(|&m| m < slots)
489 .unwrap_or(0);
490 let prim = &mut primitives[slot];
491 let tris = mesh.triangulate_face(&mut tri_indices, face) as usize;
492 for &corner in &tri_indices[..tris * 3] {
493 let corner = corner as usize;
494 let p = mesh.vertex_position[corner];
495 prim.positions
496 .push(Vec3::new(p.x as f32, p.y as f32, p.z as f32));
497 if mesh.vertex_normal.exists {
498 let n = mesh.vertex_normal[corner];
499 prim.normals
500 .push(Vec3::new(n.x as f32, n.y as f32, n.z as f32));
501 }
502 if mesh.vertex_uv.exists {
503 let uv = mesh.vertex_uv[corner];
504 prim.uvs.push([uv.x as f32, 1.0 - uv.y as f32]);
507 }
508 if !vertex_influences.is_empty() {
509 let vertex = mesh.vertex_indices[corner] as usize;
510 let (joints, weights) = vertex_influences[vertex];
511 prim.joints.push(joints);
512 prim.weights.push(weights);
513 }
514 }
515 }
516 primitives.retain(|p| !p.positions.is_empty());
517 for prim in &mut primitives {
518 prim.weld();
519 }
520 if primitives.is_empty() {
521 continue;
522 }
523 let source_mesh_index = assets.meshes.len();
524 assets.meshes.push(MeshAsset {
525 name: mesh.element.name.to_string(),
526 source_mesh_index,
530 primitives,
531 });
532 assets.instances.push(MeshInstance {
533 source_node_index,
534 node: node_id,
535 mesh: source_mesh_index,
536 skin_joints,
537 skin_ibms,
538 });
539 }
540 assets.scenes.push(SceneAsset {
541 source_scene_index: 0,
542 name: None,
543 roots: scene
544 .nodes
545 .iter()
546 .filter(|node| node.is_root)
547 .map(|node| node.element.typed_id as usize)
548 .collect(),
549 });
550 assets.default_scene = Some(0);
551 assets
552}