1#![warn(missing_docs)]
44
45use animsmith_core::model::{
46 Bone, Clip, Document, Interpolation, MaterialAsset, MeshAsset, Primitive, Property,
47 SceneAssets, Skeleton, SourceInfo, TextureAsset, Track, TrackValues, Transform,
48};
49use glam::{Mat4, Quat, Vec3};
50use std::path::Path;
51
52#[derive(Debug, thiserror::Error)]
58#[non_exhaustive]
59pub enum LoadError {
60 #[error("path is not valid UTF-8: {0}")]
62 Path(String),
63 #[error("FBX parse error: {0}")]
65 Fbx(String),
66 #[error("animation bake failed for take {take:?}: {message}")]
68 Bake {
69 take: String,
71 message: String,
73 },
74}
75
76fn vec3(v: ufbx::Vec3) -> Vec3 {
77 Vec3::new(v.x as f32, v.y as f32, v.z as f32)
78}
79
80fn quat(q: ufbx::Quat) -> Quat {
81 Quat::from_xyzw(q.x as f32, q.y as f32, q.z as f32, q.w as f32)
82}
83
84fn transform(t: &ufbx::Transform) -> Transform {
85 Transform {
86 translation: vec3(t.translation),
87 rotation: quat(t.rotation),
88 scale: vec3(t.scale),
89 }
90}
91
92fn mat4(m: &ufbx::Matrix) -> Mat4 {
94 Mat4::from_cols_array(&[
95 m.m00 as f32,
96 m.m10 as f32,
97 m.m20 as f32,
98 0.0,
99 m.m01 as f32,
100 m.m11 as f32,
101 m.m21 as f32,
102 0.0,
103 m.m02 as f32,
104 m.m12 as f32,
105 m.m22 as f32,
106 0.0,
107 m.m03 as f32,
108 m.m13 as f32,
109 m.m23 as f32,
110 1.0,
111 ])
112}
113
114pub fn load(path: &Path) -> Result<Document, LoadError> {
126 let filename = path
127 .to_str()
128 .ok_or_else(|| LoadError::Path(path.display().to_string()))?;
129 let opts = ufbx::LoadOpts {
130 target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
131 target_unit_meters: 1.0,
132 space_conversion: ufbx::SpaceConversion::AdjustTransforms,
133 geometry_transform_handling: ufbx::GeometryTransformHandling::HelperNodes,
134 inherit_mode_handling: ufbx::InheritModeHandling::Compensate,
140 generate_missing_normals: true,
141 ..Default::default()
142 };
143 let scene = ufbx::load_file(filename, opts).map_err(|e| LoadError::Fbx(format!("{e:?}")))?;
144
145 let mut bones: Vec<Bone> = Vec::with_capacity(scene.nodes.len());
150 for node in &scene.nodes {
151 let name = if node.element.name.is_empty() {
152 if node.is_root {
153 "<fbx-root>".to_string()
154 } else {
155 format!("node{}", node.element.typed_id)
156 }
157 } else {
158 node.element.name.to_string()
159 };
160 bones.push(Bone {
161 name,
162 parent: node.parent.as_ref().map(|p| p.element.typed_id as usize),
163 rest: transform(&node.local_transform),
164 inverse_bind: None,
165 });
166 }
167 for cluster in &scene.skin_clusters {
168 if let Some(bone_node) = &cluster.bone_node {
169 let id = bone_node.element.typed_id as usize;
170 if id < bones.len() {
171 bones[id].inverse_bind = Some(mat4(&cluster.bind_to_world).inverse());
175 }
176 }
177 }
178
179 let mut clips = Vec::new();
180 for (index, stack) in scene.anim_stacks.iter().enumerate() {
181 let take = if stack.element.name.is_empty() {
182 format!("take{index}")
183 } else {
184 stack.element.name.to_string()
185 };
186 let baked = ufbx::bake_anim(
187 &scene,
188 &stack.anim,
189 ufbx::BakeOpts {
190 trim_start_time: true,
191 ..Default::default()
192 },
193 )
194 .map_err(|e| LoadError::Bake {
195 take: take.clone(),
196 message: format!("{e:?}"),
197 })?;
198
199 let mut tracks = Vec::new();
200 let mut duration = 0.0f64;
201 for node in &baked.nodes {
202 let bone = node.typed_id as usize;
203 if !node.translation_keys.is_empty() {
204 let times: Vec<f32> = node
205 .translation_keys
206 .iter()
207 .map(|k| k.time as f32)
208 .collect();
209 let values: Vec<Vec3> = node
210 .translation_keys
211 .iter()
212 .map(|k| vec3(k.value))
213 .collect();
214 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
215 tracks.push(Track {
216 bone,
217 property: Property::Translation,
218 interpolation: Interpolation::Linear,
219 times,
220 values: TrackValues::Vec3s(values),
221 });
222 }
223 if !node.rotation_keys.is_empty() {
224 let times: Vec<f32> = node.rotation_keys.iter().map(|k| k.time as f32).collect();
225 let values: Vec<Quat> = node.rotation_keys.iter().map(|k| quat(k.value)).collect();
226 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
227 tracks.push(Track {
228 bone,
229 property: Property::Rotation,
230 interpolation: Interpolation::Linear,
231 times,
232 values: TrackValues::Quats(values),
233 });
234 }
235 if !node.scale_keys.is_empty() {
236 let times: Vec<f32> = node.scale_keys.iter().map(|k| k.time as f32).collect();
237 let values: Vec<Vec3> = node.scale_keys.iter().map(|k| vec3(k.value)).collect();
238 duration = duration.max(times.last().copied().unwrap_or(0.0) as f64);
239 tracks.push(Track {
240 bone,
241 property: Property::Scale,
242 interpolation: Interpolation::Linear,
243 times,
244 values: TrackValues::Vec3s(values),
245 });
246 }
247 }
248 clips.push(Clip {
249 name: take,
250 duration_s: duration,
251 tracks,
252 });
253 }
254
255 let assets = extract_assets(&scene, path.parent());
256
257 Ok(Document {
258 skeleton: Skeleton { bones },
259 clips,
260 assets,
261 source: SourceInfo {
262 path: Some(path.display().to_string()),
263 format: Some("fbx".into()),
264 },
265 })
266}
267
268fn base_color_texture(material: &ufbx::Material, base_dir: Option<&Path>) -> Option<TextureAsset> {
275 let texture = material.pbr.base_color.texture.as_ref().or(material
276 .fbx
277 .diffuse_color
278 .texture
279 .as_ref())?;
280 let bytes: Vec<u8> = if !texture.content.is_empty() {
281 texture.content.to_vec()
282 } else {
283 let mut found = None;
284 for candidate in [
285 texture.absolute_filename.as_ref(),
286 texture.relative_filename.as_ref(),
287 texture.filename.as_ref(),
288 ] {
289 if candidate.is_empty() {
290 continue;
291 }
292 let direct = Path::new(candidate);
293 let path = if direct.is_absolute() {
294 direct.to_path_buf()
295 } else {
296 base_dir.unwrap_or(Path::new(".")).join(direct)
297 };
298 if let Ok(data) = std::fs::read(&path) {
299 found = Some(data);
300 break;
301 }
302 }
303 found?
304 };
305 let mime = match bytes.get(..3) {
306 Some([0x89, b'P', b'N']) => "image/png",
307 Some([0xFF, 0xD8, _]) => "image/jpeg",
308 _ => return None,
309 };
310 Some(TextureAsset {
311 bytes,
312 mime: mime.into(),
313 })
314}
315
316fn extract_assets(scene: &ufbx::Scene, base_dir: Option<&Path>) -> SceneAssets {
317 let mut assets = SceneAssets::default();
318 let mut material_index: std::collections::BTreeMap<u32, usize> =
319 std::collections::BTreeMap::new();
320
321 for node in &scene.nodes {
322 let Some(mesh) = &node.mesh else { continue };
323 let node_id = node.element.typed_id as usize;
324
325 let local_materials: Vec<usize> = mesh
327 .materials
328 .iter()
329 .map(|m| {
330 *material_index
331 .entry(m.element.element_id)
332 .or_insert_with(|| {
333 let base = if m.pbr.base_color.has_value {
334 m.pbr.base_color.value_vec4
335 } else {
336 m.fbx.diffuse_color.value_vec4
337 };
338 let texture = base_color_texture(m, base_dir);
339 assets.materials.push(MaterialAsset {
340 name: m.element.name.to_string(),
341 base_color: if texture.is_some() {
344 [1.0, 1.0, 1.0, 1.0]
345 } else {
346 [base.x as f32, base.y as f32, base.z as f32, base.w as f32]
347 },
348 metallic: if m.pbr.metalness.has_value {
349 m.pbr.metalness.value_vec4.x as f32
350 } else {
351 0.0
352 },
353 roughness: if m.pbr.roughness.has_value {
354 m.pbr.roughness.value_vec4.x as f32
355 } else {
356 1.0
357 },
358 base_color_texture: texture,
359 });
360 assets.materials.len() - 1
361 })
362 })
363 .collect();
364
365 let skin = mesh.skin_deformers.first();
368 let skin_joints: Vec<usize> = skin
369 .map(|s| {
370 s.clusters
371 .iter()
372 .map(|c| {
373 c.bone_node
374 .as_ref()
375 .map(|b| b.element.typed_id as usize)
376 .unwrap_or(0)
377 })
378 .collect()
379 })
380 .unwrap_or_default();
381 let skin_ibms: Vec<glam::Mat4> = skin
385 .map(|s| {
386 s.clusters
387 .iter()
388 .map(|c| mat4(&c.bind_to_world).inverse() * mat4(&c.geometry_to_world))
389 .collect()
390 })
391 .unwrap_or_default();
392 let vertex_influences: Vec<([u16; 4], [f32; 4])> = skin
393 .map(|s| {
394 (0..mesh.num_vertices)
395 .map(|v| {
396 let mut pairs: Vec<(u16, f32)> = Vec::new();
397 if let Some(sv) = s.vertices.get(v) {
398 for w in 0..sv.num_weights as usize {
399 let sw = &s.weights[sv.weight_begin as usize + w];
400 pairs.push((sw.cluster_index as u16, sw.weight as f32));
401 }
402 }
403 pairs.sort_by(|a, b| b.1.total_cmp(&a.1));
404 pairs.truncate(4);
405 let total: f32 = pairs.iter().map(|p| p.1).sum();
406 let mut joints = [0u16; 4];
407 let mut weights = [0f32; 4];
408 for (slot, (j, w)) in pairs.into_iter().enumerate() {
409 joints[slot] = j;
410 weights[slot] = if total > 0.0 { w / total } else { 0.0 };
411 }
412 (joints, weights)
413 })
414 .collect()
415 })
416 .unwrap_or_default();
417
418 let slots = local_materials.len().max(1);
420 let mut primitives: Vec<Primitive> = (0..slots)
421 .map(|slot| Primitive {
422 material: local_materials.get(slot).copied(),
423 ..Primitive::default()
424 })
425 .collect();
426
427 let mut tri_indices = vec![0u32; mesh.max_face_triangles * 3];
428 for (face_index, &face) in mesh.faces.iter().enumerate() {
429 let slot = mesh
430 .face_material
431 .get(face_index)
432 .map(|&m| m as usize)
433 .filter(|&m| m < slots)
434 .unwrap_or(0);
435 let prim = &mut primitives[slot];
436 let tris = mesh.triangulate_face(&mut tri_indices, face) as usize;
437 for &corner in &tri_indices[..tris * 3] {
438 let corner = corner as usize;
439 let p = mesh.vertex_position[corner];
440 prim.positions
441 .push(Vec3::new(p.x as f32, p.y as f32, p.z as f32));
442 if mesh.vertex_normal.exists {
443 let n = mesh.vertex_normal[corner];
444 prim.normals
445 .push(Vec3::new(n.x as f32, n.y as f32, n.z as f32));
446 }
447 if mesh.vertex_uv.exists {
448 let uv = mesh.vertex_uv[corner];
449 prim.uvs.push([uv.x as f32, 1.0 - uv.y as f32]);
452 }
453 if !vertex_influences.is_empty() {
454 let vertex = mesh.vertex_indices[corner] as usize;
455 let (joints, weights) = vertex_influences[vertex];
456 prim.joints.push(joints);
457 prim.weights.push(weights);
458 }
459 }
460 }
461 primitives.retain(|p| !p.positions.is_empty());
462 for prim in &mut primitives {
463 prim.weld();
464 }
465 if primitives.is_empty() {
466 continue;
467 }
468 assets.meshes.push(MeshAsset {
469 name: mesh.element.name.to_string(),
470 node: node_id,
471 primitives,
472 skin_joints,
473 skin_ibms,
474 });
475 }
476 assets
477}