//! CPU skeleton sampling and orbit camera for the live glTF preview. //! //! This is not the paperdoll export. The export camera is orthographic and //! still out of scope. The interactive camera can snap to the same five //! pitches and twelve yaws. The character forward axis of the test bodies is -Z, //! so yaw 0 places the camera on -Z, looking at the body. use std::path::Path; pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0]; /// Production pitches in integer degrees, atlas column order (t955u). pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60]; /// Production views per sample: five pitches × twelve yaws. pub const PRODUCTION_VIEWS: u32 = 60; pub const PAPERDOLL_YAW_COUNT: usize = 12; pub const MAX_JOINTS: usize = 128; pub type Mat4 = [f32; 16]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PreviewSex { Male, Female, } #[derive(Debug, Clone)] pub struct PreviewControls { pub sex: PreviewSex, pub clip_name: String, pub time: f32, pub playing: bool, pub yaw_deg: f32, pub pitch_deg: f32, pub distance: f32, } impl Default for PreviewControls { fn default() -> Self { Self { sex: PreviewSex::Male, clip_name: "walk".to_string(), time: 0.0, playing: true, yaw_deg: 0.0, pitch_deg: 0.0, distance: 3.4, } } } impl PreviewControls { pub fn drag(&mut self, dx_px: f32, dy_px: f32) { self.yaw_deg = wrap_deg(self.yaw_deg - dx_px * 0.45); self.pitch_deg = (self.pitch_deg + dy_px * 0.35).clamp(-80.0, 80.0); } pub fn zoom(&mut self, scroll_y: f32) { let factor = (-scroll_y * 0.0015).exp(); self.distance = (self.distance * factor).clamp(0.8, 12.0); } pub fn snap_yaw_index(&mut self, index: i32) { let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as f32; self.yaw_deg = wrapped * 30.0; } pub fn snap_pitch_row(&mut self, row: i32) { let row = row.clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1) as usize; self.pitch_deg = PAPERDOLL_PITCHES_DEG[row]; } pub fn advance(&mut self, dt: f32, duration: f32) { if self.playing && duration > 0.0 { self.time = wrap_time(self.time + dt, duration); } } pub fn select_clip(&mut self, name: &str) { if self.clip_name != name { self.clip_name = name.to_string(); self.time = 0.0; } } } #[derive(Debug, Clone)] pub struct PreviewCharacter { pub nodes: Vec, pub joint_nodes: Vec, pub joint_names: Vec, pub inverse_binds: Vec, pub positions: Vec<[f32; 3]>, pub normals: Vec<[f32; 3]>, /// A placement-only fallback must never masquerade as a production normal map. pub source_normals_complete: bool, pub joints: Vec<[u16; 4]>, pub weights: Vec<[f32; 4]>, pub indices: Vec, pub base_color: [f32; 4], pub colors: Vec<[f32; 4]>, pub uvs: Vec<[f32; 2]>, pub surfaces: Vec, pub clips: Vec, /// Skinned (skeleton + clips) or static geometry without a skeleton. pub rig: BodyRig, } /// Whether a body has a real skin. A static body keeps an empty skeleton and /// no clips; it is drawn in its stored (bind/static) pose and the production /// batch renders the single `clip000-bind` sample. Nothing invents bones. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum BodyRig { #[default] Skinned, Static { /// Source format label, e.g. `OBJ`, `glTF`, `FBX`. format: String, /// Human-readable (Dutch) explanation shown in the editor. reason: String, }, } impl PreviewCharacter { /// True when the body has no skin: no bones, no clips, static pose only. pub fn is_static(&self) -> bool { matches!(self.rig, BodyRig::Static { .. }) } /// Editor explanation for a static body; `None` for a skinned body. pub fn static_reason(&self) -> Option<&str> { match &self.rig { BodyRig::Static { reason, .. } => Some(reason), BodyRig::Skinned => None, } } } #[derive(Debug, Clone)] pub struct BodySurface { pub first: usize, pub count: usize, pub color: [f32; 4], pub albedo: Option>, pub cutoff: f32, pub flat_normals: bool, } #[derive(Debug, Clone)] pub struct SceneNode { pub name: String, pub parent: Option, pub translation: [f32; 3], pub rotation: [f32; 4], pub scale: [f32; 3], } #[derive(Debug, Clone)] pub struct PreviewClip { pub name: String, pub duration: f32, pub channels: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChannelProperty { Translation, Rotation, Scale, } #[derive(Debug, Clone)] pub struct AnimChannel { pub node: usize, pub property: ChannelProperty, pub times: Vec, pub values: Vec, } /// Whether a body model path is drawable by the live viewport and production batch. pub fn is_drawable_body(path: &Path) -> bool { matches!( path.extension() .and_then(|e| e.to_str()) .map(|e| e.to_ascii_lowercase()) .as_deref(), Some("gltf" | "glb" | "fbx" | "obj") ) } /// Load a body for the live viewport and the production batch. /// glTF/GLB use the glTF reader; FBX goes through the normalized /// `fbx_scene` loader (axis/unit conversion is applied there exactly once). /// Bodies without a skin (OBJ+MTL, glTF without `skins`, FBX without a skin /// deformer) load as [`BodyRig::Static`]: geometry in its stored pose, no /// skeleton and no clips. pub fn load_body_file(path: &Path) -> Result { match path .extension() .and_then(|e| e.to_str()) .map(|e| e.to_ascii_lowercase()) .as_deref() { Some("gltf" | "glb") => load_gltf_file(path), Some("fbx") => match crate::fbx_scene::load_fbx_drawable(path) { Ok(scene) => Ok(scene.character), Err(error) if error == crate::fbx_scene::NO_SKINNED_MESH => load_static_rigid( path, "FBX", "FBX zonder skin: statische rustpose, geen skeleton of animaties", ), Err(error) => Err(error), }, Some("obj") => load_static_rigid( path, "OBJ", "OBJ heeft geen skeleton of animaties: statische pose", ), _ => Err(format!( "{} is geen tekenbaar lichaam; gebruik .glb, .gltf, .fbx of .obj", path.display() )), } } /// Static OBJ/FBX body through the same rigid reader as equipment /// (`equipment::import_obj`/`import_fbx`): OBJ is used as stored, FBX gets /// the ufbx axis/unit/geometry-transform normalization once, there. The only /// conversion here is the UV origin (bottom-left in OBJ/FBX, top-left in /// `PreviewCharacter`), done exactly once. fn load_static_rigid(path: &Path, format: &str, reason: &str) -> Result { let mut preset = crate::equipment::Preset::new("static-body".into()); let part = match format { "OBJ" => crate::equipment::import_obj_limited( &mut preset, path, crate::equipment::MAX_STATIC_BODY_VERTICES, ), _ => crate::equipment::import_fbx_limited( &mut preset, path, crate::equipment::MAX_STATIC_BODY_VERTICES, ), } .map_err(|error| format!("statisch {format}-lichaam: {error}"))?; let mut cache = crate::equipment::TextureCache::default(); let mut acc = StaticAcc::default(); for mesh in &preset.attachments[part].meshes { let albedo = mesh .albedo .map(|index| cache.get(&preset, index)) .transpose()?; let uvs: Vec<[f32; 2]> = mesh.uvs.iter().map(|[u, v]| [*u, 1. - *v]).collect(); acc.push( &mesh.positions, &mesh.normals, &uvs, None, &mesh.indices, mesh.color, albedo, 0.001, false, )?; } acc.finish(format, reason.to_string()) } /// Accumulates static-body primitives into one `PreviewCharacter` mesh. #[derive(Default)] struct StaticAcc { positions: Vec<[f32; 3]>, normals: Vec<[f32; 3]>, uvs: Vec<[f32; 2]>, colors: Vec<[f32; 4]>, indices: Vec, surfaces: Vec, normals_complete: bool, any: bool, } impl StaticAcc { #[allow(clippy::too_many_arguments)] fn push( &mut self, positions: &[[f32; 3]], normals: &[[f32; 3]], uvs: &[[f32; 2]], colors: Option<&[[f32; 4]]>, indices: &[u32], color: [f32; 4], albedo: Option>, cutoff: f32, flat_normals: bool, ) -> Result<(), String> { if positions.is_empty() || indices.len() < 3 { return Ok(()); } if !indices.len().is_multiple_of(3) || indices.iter().any(|i| *i as usize >= positions.len()) { return Err("statisch lichaam: ongeldige driehoeksindices".into()); } if positions .iter() .chain(normals.iter()) .any(|v| v.iter().any(|x| !x.is_finite())) { return Err("statisch lichaam: niet-eindige posities of normalen".into()); } let total = self.positions.len() + positions.len(); if total > crate::equipment::MAX_STATIC_BODY_VERTICES { return Err("statisch lichaam heeft te veel vertices".into()); } if !self.any { self.normals_complete = true; self.any = true; } self.normals_complete &= !flat_normals && normals.len() == positions.len(); let base = self.positions.len() as u32; let first = self.indices.len(); self.indices.extend(indices.iter().map(|i| i + base)); self.positions.extend_from_slice(positions); for i in 0..positions.len() { self.normals .push(normals.get(i).copied().unwrap_or([0., 1., 0.])); self.uvs.push(uvs.get(i).copied().unwrap_or([0., 0.])); self.colors .push(colors.and_then(|c| c.get(i).copied()).unwrap_or([1.; 4])); } self.surfaces.push(BodySurface { first, count: self.indices.len() - first, color, albedo, cutoff, flat_normals, }); Ok(()) } fn finish(self, format: &str, reason: String) -> Result { if self.positions.is_empty() || self.indices.len() < 3 { return Err(format!( "statisch {format}-lichaam heeft geen tekenbare driehoeken" )); } let count = self.positions.len(); Ok(PreviewCharacter { nodes: Vec::new(), joint_nodes: Vec::new(), joint_names: Vec::new(), inverse_binds: Vec::new(), positions: self.positions, normals: self.normals, source_normals_complete: self.normals_complete, // Weight 1 on slot 0 with no bones: every skinning path resolves // to identity (empty bone list), i.e. the stored pose. joints: vec![[0; 4]; count], weights: vec![[1., 0., 0., 0.]; count], indices: self.indices, base_color: self .surfaces .first() .map(|s| s.color) .unwrap_or([0.7, 0.7, 0.75, 1.0]), colors: self.colors, uvs: self.uvs, surfaces: self.surfaces, clips: Vec::new(), rig: BodyRig::Static { format: format.to_string(), reason, }, }) } } pub fn load_gltf_file(path: &Path) -> Result { let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?; let mut nodes = Vec::new(); let mut parents = vec![None; document.nodes().len()]; for node in document.nodes() { for child in node.children() { parents[child.index()] = Some(node.index()); } } for node in document.nodes() { let (translation, rotation, scale) = node.transform().decomposed(); nodes.push(SceneNode { name: node.name().unwrap_or("").to_string(), parent: parents[node.index()], translation, rotation, scale, }); } let Some(skin) = document.skins().next() else { return load_static_gltf(&document, &buffers, &images, &nodes); }; if skin.joints().len() > MAX_JOINTS { return Err(format!( "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}", skin.joints().len() )); } let joint_nodes: Vec = skin.joints().map(|joint| joint.index()).collect(); let joint_names: Vec = joint_nodes .iter() .map(|index| nodes[*index].name.clone()) .collect(); let reader = skin.reader(|buffer| Some(buffers[buffer.index()].as_ref())); let inverse_binds = reader .read_inverse_bind_matrices() .map(|iter| iter.map(columns_to_mat).collect()) .unwrap_or_else(|| vec![identity(); joint_nodes.len()]); if inverse_binds.len() != joint_nodes.len() { return Err("inverse-bindmatrices en joints verschillen".to_string()); } let mut positions = Vec::new(); let mut normals = Vec::new(); let mut source_normals_complete = true; let mut joints = Vec::new(); let mut weights = Vec::new(); let mut indices = Vec::new(); let mut base_color = [0.7, 0.7, 0.75, 1.0]; let mut colors = Vec::new(); let mut uvs = Vec::new(); let mut surfaces = Vec::new(); if let Some(mesh) = document.meshes().next() { for primitive in mesh.primitives() { let prim = read_gltf_primitive(&primitive, &buffers, &images)?; source_normals_complete &= prim.normals.is_some(); let count = prim.positions.len(); let first = indices.len(); let base = positions.len() as u32; indices.extend(prim.indices.iter().map(|index| index + base)); positions.extend(prim.positions); normals.extend(prim.normals.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; count])); joints.extend(prim.joints); weights.extend(prim.weights); colors.extend(prim.colors); uvs.extend(prim.uvs); base_color = prim.color; surfaces.push(BodySurface { flat_normals: prim.flat_normals, first, count: indices.len() - first, color: prim.color, albedo: prim.albedo, cutoff: prim.cutoff, }); } } if positions.is_empty() || indices.len() < 3 { return Err("het glTF-bestand heeft geen tekenbare driehoeken".to_string()); } let mut clips = Vec::new(); for animation in document.animations() { let mut channels = Vec::new(); let mut duration = 0.0_f32; for channel in animation.channels() { let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref())); let times: Vec = reader .read_inputs() .map(|iter| iter.collect()) .unwrap_or_default(); if let Some(last) = times.last() { duration = duration.max(*last); } let Some(outputs) = reader.read_outputs() else { continue; }; let (property, values) = match outputs { gltf::animation::util::ReadOutputs::Translations(values) => { (ChannelProperty::Translation, values.flatten().collect()) } gltf::animation::util::ReadOutputs::Scales(values) => { (ChannelProperty::Scale, values.flatten().collect()) } gltf::animation::util::ReadOutputs::Rotations(values) => ( ChannelProperty::Rotation, values.into_f32().flatten().collect(), ), gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => continue, }; channels.push(AnimChannel { node: channel.target().node().index(), property, times, values, }); } clips.push(PreviewClip { name: animation.name().unwrap_or("naamloos").to_string(), duration, channels, }); } Ok(PreviewCharacter { nodes, joint_nodes, joint_names, inverse_binds, positions, normals, source_normals_complete, joints, weights, indices, base_color, colors, uvs, surfaces, clips, rig: BodyRig::Skinned, }) } /// One glTF triangle primitive read in its mesh-local space. struct GltfPrimitive { positions: Vec<[f32; 3]>, normals: Option>, joints: Vec<[u16; 4]>, weights: Vec<[f32; 4]>, colors: Vec<[f32; 4]>, uvs: Vec<[f32; 2]>, indices: Vec, color: [f32; 4], albedo: Option>, cutoff: f32, flat_normals: bool, } fn read_gltf_primitive( primitive: &gltf::Primitive<'_>, buffers: &[gltf::buffer::Data], images: &[gltf::image::Data], ) -> Result { if primitive.mode() != gltf::mesh::Mode::Triangles { return Err("live/productie ondersteunt alleen driehoekprimitieven".into()); } let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref())); let positions: Vec<[f32; 3]> = reader .read_positions() .map(|iter| iter.collect()) .unwrap_or_default(); let normals: Option> = reader.read_normals().map(|iter| iter.collect()); let joints: Vec<[u16; 4]> = reader .read_joints(0) .map(|iter| iter.into_u16().collect()) .unwrap_or_else(|| vec![[0, 0, 0, 0]; positions.len()]); let weights: Vec<[f32; 4]> = reader .read_weights(0) .map(|iter| iter.into_f32().collect()) .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; positions.len()]); let material = primitive.material(); let pbr = material.pbr_metallic_roughness(); let texture = pbr.base_color_texture(); let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0); let colors = reader .read_colors(0) .map(|v| v.into_rgba_f32().collect::>()) .unwrap_or_else(|| vec![[1.; 4]; positions.len()]); let uvs = reader .read_tex_coords(uvset) .map(|v| v.into_f32().collect::>()) .unwrap_or_else(|| vec![[0.; 2]; positions.len()]); let indices: Vec = match reader.read_indices() { Some(read_indices) => read_indices.into_u32().collect(), None => (0..positions.len() as u32).collect(), }; let albedo = texture .map(|t| { let image = images .get(t.texture().source().index()) .ok_or("ontbrekende bodytexture")?; use gltf::image::Format; let channels = match image.format { Format::R8 => 1, Format::R8G8 => 2, Format::R8G8B8 => 3, Format::R8G8B8A8 => 4, _ => return Err("bodytexture moet 8-bit zijn".to_string()), }; if image.width > 2048 || image.height > 2048 { return Err("bodytexture groter dan 2048×2048".into()); } let mut rgba = Vec::new(); for p in image.pixels.chunks_exact(channels) { rgba.extend_from_slice(&match channels { 1 => [p[0], p[0], p[0], 255], 2 => [p[0], p[0], p[0], p[1]], 3 => [p[0], p[1], p[2], 255], _ => [p[0], p[1], p[2], p[3]], }); } use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); rgba.hash(&mut hash); Ok(std::sync::Arc::new(crate::equipment::Texture { content_hash: hash.finish(), width: image.width, height: image.height, rgba, })) }) .transpose()?; Ok(GltfPrimitive { flat_normals: normals.is_none(), positions, normals, joints, weights, colors, uvs, indices, color: pbr.base_color_factor(), albedo, cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask { material.alpha_cutoff().unwrap_or(0.5) } else { 0.001 }, }) } /// glTF without a skin: every mesh instance of the default scene, with its /// node's rest world transform baked into positions (and the inverse- /// transpose into normals) exactly once; mirrored transforms flip winding. /// Animations are not applied (no skeleton is invented). fn load_static_gltf( document: &gltf::Document, buffers: &[gltf::buffer::Data], images: &[gltf::image::Data], nodes: &[SceneNode], ) -> Result { let mut roots: Vec = match document .default_scene() .or_else(|| document.scenes().next()) { Some(scene) => scene.nodes().map(|n| n.index()).collect(), None => (0..nodes.len()) .filter(|i| nodes[*i].parent.is_none()) .collect(), }; let children: Vec> = document .nodes() .map(|n| n.children().map(|c| c.index()).collect()) .collect(); let mut acc = StaticAcc::default(); let mut stack: Vec<(usize, Mat4, usize)> = roots.drain(..).rev().map(|r| (r, identity(), 0)).collect(); let mut visited = 0usize; while let Some((index, parent, depth)) = stack.pop() { visited += 1; if depth > nodes.len() || visited > nodes.len() * 4 + 16 { return Err("glTF-nodehiërarchie bevat een cyclus".into()); } let node = nodes.get(index).ok_or("ongeldige glTF-node")?; let world = mul(parent, trs(node.translation, node.rotation, node.scale)); if !world.iter().all(|x| x.is_finite()) { return Err("glTF-node heeft een niet-eindige transformatie".into()); } if let Some(mesh) = document.nodes().nth(index).and_then(|n| n.mesh()) { let normal_matrix = normal_matrix(&world).ok_or("glTF-node heeft een singuliere transformatie")?; let mirrored = det3(&world) < 0.0; for primitive in mesh.primitives() { let prim = read_gltf_primitive(&primitive, buffers, images)?; let positions: Vec<[f32; 3]> = prim .positions .iter() .map(|p| transform_point3(&world, *p)) .collect(); let normals: Vec<[f32; 3]> = match &prim.normals { Some(normals) => normals .iter() .map(|n| normalize3(mat3_mul(&normal_matrix, *n))) .collect(), None => vec![[0., 1., 0.]; positions.len()], }; let mut indices = prim.indices.clone(); if !indices.len().is_multiple_of(3) { return Err("glTF-primitive heeft een onvolledige driehoek".into()); } if mirrored { for tri in indices.chunks_exact_mut(3) { tri.swap(1, 2); } } acc.push( &positions, &normals, &prim.uvs, Some(&prim.colors), &indices, prim.color, prim.albedo, prim.cutoff, prim.flat_normals, )?; } } for child in children.get(index).into_iter().flatten().rev() { stack.push((*child, world, depth + 1)); } } let animations = document.animations().count(); let reason = if animations == 0 { "glTF zonder skin: statische pose, geen skeleton of animaties".to_string() } else { format!( "glTF zonder skin: statische pose; {animations} animatie(s) niet toegepast (geen skeleton)" ) }; acc.finish("glTF", reason) } fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] { [ m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14], ] } fn det3(m: &Mat4) -> f32 { m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2]) + m[8] * (m[1] * m[6] - m[5] * m[2]) } /// Inverse-transpose of the upper 3×3 (row-major result for `mat3_mul`). fn normal_matrix(m: &Mat4) -> Option<[[f32; 3]; 3]> { let det = det3(m); if !det.is_finite() || det.abs() < 1e-12 { return None; } // a[r][c] = element at row r, column c of the column-major matrix. let a = |r: usize, c: usize| m[c * 4 + r]; let cof = |r: usize, c: usize| { let rows: Vec = (0..3).filter(|x| *x != r).collect(); let cols: Vec = (0..3).filter(|x| *x != c).collect(); let minor = a(rows[0], cols[0]) * a(rows[1], cols[1]) - a(rows[0], cols[1]) * a(rows[1], cols[0]); if (r + c).is_multiple_of(2) { minor } else { -minor } }; // inverse-transpose = cofactor matrix / det. let mut out = [[0.; 3]; 3]; for (r, row) in out.iter_mut().enumerate() { for (c, value) in row.iter_mut().enumerate() { *value = cof(r, c) / det; } } Some(out) } fn mat3_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] { [ m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], ] } pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec { let locals = sample_local_matrices(character, clip_name, time); let worlds = world_matrices(&character.nodes, &locals); character .joint_nodes .iter() .enumerate() .map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint])) .collect() } /// Stable hierarchy path includes node indices to disambiguate duplicate names. pub fn joint_paths(character: &PreviewCharacter) -> Vec { character .joint_nodes .iter() .map(|&node| { let mut names = Vec::new(); let mut cursor = Some(node); while let Some(index) = cursor { let item = &character.nodes[index]; names.push(format!("{}[{}]", item.name, index)); cursor = item.parent; if names.len() > character.nodes.len() { break; } } names.reverse(); names.join("/") }) .collect() } /// Rest-world joint transform, never selected by a possibly empty clip name. /// Bounded parent walk rejects malformed cycles/indices instead of recursing. pub fn rest_joint_world(character: &PreviewCharacter, joint: usize) -> Option { let mut node = Some(*character.joint_nodes.get(joint)?); let mut chain = Vec::new(); while let Some(i) = node { if chain.len() >= character.nodes.len() { return None; } let n = character.nodes.get(i)?; chain.push(i); node = n.parent; } let mut world = identity(); for i in chain.into_iter().rev() { let n = &character.nodes[i]; world = mul(world, trs(n.translation, n.rotation, n.scale)); } world.iter().all(|n| n.is_finite()).then_some(world) } pub fn animated_joint_world( character: &PreviewCharacter, clip: &str, time: f32, joint: usize, ) -> Option { let locals = sample_local_matrices(character, clip, time); let worlds = world_matrices(&character.nodes, &locals); character .joint_nodes .get(joint) .and_then(|node| worlds.get(*node)) .copied() } pub fn joint_world_translation( character: &PreviewCharacter, clip_name: &str, time: f32, joint_name: &str, ) -> Option<[f32; 3]> { let joint = character .joint_names .iter() .position(|name| name == joint_name)?; let node = character.joint_nodes[joint]; let locals = sample_local_matrices(character, clip_name, time); let worlds = world_matrices(&character.nodes, &locals); Some([worlds[node][12], worlds[node][13], worlds[node][14]]) } pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 { character .clips .iter() .find(|clip| { clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase()) }) .map(|clip| clip.duration) .unwrap_or(0.0) } pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] { let yaw = yaw_deg.to_radians(); let pitch = pitch_deg.to_radians(); [ -distance * pitch.cos() * yaw.sin(), distance * pitch.sin(), -distance * pitch.cos() * yaw.cos(), ] } pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] { let mut yaws = [0.0; PAPERDOLL_YAW_COUNT]; for (index, yaw) in yaws.iter_mut().enumerate() { *yaw = index as f32 * 30.0; } yaws } fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec { let mut translation: Vec<[f32; 3]> = character .nodes .iter() .map(|node| node.translation) .collect(); let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect(); let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect(); if let Some(clip) = character.clips.iter().find(|clip| { clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase()) }) { let time = wrap_time(time, clip.duration); for channel in &clip.channels { if channel.node >= character.nodes.len() || channel.times.is_empty() { continue; } let (left, right, blend) = key_blend(&channel.times, time); match channel.property { ChannelProperty::Translation => { translation[channel.node] = lerp3( key3(&channel.values, left), key3(&channel.values, right), blend, ); } ChannelProperty::Scale => { scale[channel.node] = lerp3( key3(&channel.values, left), key3(&channel.values, right), blend, ); } ChannelProperty::Rotation => { rotation[channel.node] = slerp( key4(&channel.values, left), key4(&channel.values, right), blend, ); } } } } translation .into_iter() .zip(rotation) .zip(scale) .map(|((translation, rotation), scale)| trs(translation, rotation, scale)) .collect() } fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec { fn world( index: usize, nodes: &[SceneNode], locals: &[Mat4], worlds: &mut [Option], depth: usize, ) -> Mat4 { if let Some(matrix) = worlds[index] { return matrix; } if depth > nodes.len() { return locals[index]; } let matrix = match nodes[index].parent { Some(parent) => mul( world(parent, nodes, locals, worlds, depth + 1), locals[index], ), None => locals[index], }; worlds[index] = Some(matrix); matrix } let mut cached = vec![None; nodes.len()]; (0..nodes.len()) .map(|index| world(index, nodes, locals, &mut cached, 0)) .collect() } fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) { if times.len() == 1 || time <= times[0] { return (0, 0, 0.0); } if time >= times[times.len() - 1] { let last = times.len() - 1; return (last, last, 0.0); } let mut index = 0; while index + 1 < times.len() && times[index + 1] < time { index += 1; } let span = times[index + 1] - times[index]; let blend = if span.abs() < 1e-6 { 0.0 } else { (time - times[index]) / span }; (index, index + 1, blend) } fn key3(values: &[f32], index: usize) -> [f32; 3] { let start = index * 3; [ values.get(start).copied().unwrap_or(0.0), values.get(start + 1).copied().unwrap_or(0.0), values.get(start + 2).copied().unwrap_or(0.0), ] } fn key4(values: &[f32], index: usize) -> [f32; 4] { let start = index * 4; [ values.get(start).copied().unwrap_or(0.0), values.get(start + 1).copied().unwrap_or(0.0), values.get(start + 2).copied().unwrap_or(0.0), values.get(start + 3).copied().unwrap_or(1.0), ] } fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { [ a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, ] } fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] { let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; if dot < 0.0 { dot = -dot; b = [-b[0], -b[1], -b[2], -b[3]]; } if dot > 0.9995 { return normalize4([ a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t, ]); } let theta = dot.clamp(-1.0, 1.0).acos(); let sin_theta = theta.sin(); let w1 = ((1.0 - t) * theta).sin() / sin_theta; let w2 = (t * theta).sin() / sin_theta; [ a[0] * w1 + b[0] * w2, a[1] * w1 + b[1] * w2, a[2] * w1 + b[2] * w2, a[3] * w1 + b[3] * w2, ] } fn normalize4(value: [f32; 4]) -> [f32; 4] { let length = (value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3]) .sqrt(); if length < 1e-8 { [0.0, 0.0, 0.0, 1.0] } else { [ value[0] / length, value[1] / length, value[2] / length, value[3] / length, ] } } fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 { let mut matrix = quat_to_mat(normalize4(rotation)); matrix[0] *= scale[0]; matrix[1] *= scale[0]; matrix[2] *= scale[0]; matrix[4] *= scale[1]; matrix[5] *= scale[1]; matrix[6] *= scale[1]; matrix[8] *= scale[2]; matrix[9] *= scale[2]; matrix[10] *= scale[2]; matrix[12] = translation[0]; matrix[13] = translation[1]; matrix[14] = translation[2]; matrix } fn quat_to_mat(q: [f32; 4]) -> Mat4 { let (x, y, z, w) = (q[0], q[1], q[2], q[3]); let xx = x * x; let yy = y * y; let zz = z * z; let xy = x * y; let xz = x * z; let yz = y * z; let wx = w * x; let wy = w * y; let wz = w * z; [ 1.0 - 2.0 * (yy + zz), 2.0 * (xy + wz), 2.0 * (xz - wy), 0.0, 2.0 * (xy - wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz + wx), 0.0, 2.0 * (xz + wy), 2.0 * (yz - wx), 1.0 - 2.0 * (xx + yy), 0.0, 0.0, 0.0, 0.0, 1.0, ] } pub fn identity() -> Mat4 { [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, ] } pub fn mul(left: Mat4, right: Mat4) -> Mat4 { let mut out = [0.0; 16]; for column in 0..4 { for row in 0..4 { out[column * 4 + row] = left[row] * right[column * 4] + left[4 + row] * right[column * 4 + 1] + left[8 + row] * right[column * 4 + 2] + left[12 + row] * right[column * 4 + 3]; } } out } fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 { let mut matrix = [0.0; 16]; for column in 0..4 { for row in 0..4 { matrix[column * 4 + row] = columns[column][row]; } } matrix } pub fn wrap_time(time: f32, duration: f32) -> f32 { if duration <= 1e-5 { return 0.0; } let mut wrapped = time % duration; if wrapped < 0.0 { wrapped += duration; } wrapped } fn wrap_deg(mut degrees: f32) -> f32 { degrees %= 360.0; if degrees < 0.0 { degrees += 360.0; } degrees } pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 { let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]); let up = [0.0, 1.0, 0.0]; let mut side = normalize3(cross(forward, up)); if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 { side = [1.0, 0.0, 0.0]; } let up = cross(side, forward); [ side[0], up[0], -forward[0], 0.0, side[1], up[1], -forward[1], 0.0, side[2], up[2], -forward[2], 0.0, -dot(side, eye), -dot(up, eye), dot(forward, eye), 1.0, ] } pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 { let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan(); let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect }; let nf = 1.0 / (near - far); [ f / aspect, 0.0, 0.0, 0.0, 0.0, f, 0.0, 0.0, 0.0, 0.0, (far + near) * nf, -1.0, 0.0, 0.0, 2.0 * far * near * nf, 0.0, ] } fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], ] } fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } fn normalize3(value: [f32; 3]) -> [f32; 3] { let length = dot(value, value).sqrt(); if length < 1e-8 { [0.0, 0.0, 1.0] } else { [value[0] / length, value[1] / length, value[2] / length] } } #[cfg(test)] mod tests { use super::*; fn male() -> PreviewCharacter { load_gltf_file( &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap() } fn distance(a: [f32; 3], b: [f32; 3]) -> f32 { let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; dot(d, d).sqrt() } #[test] fn mirrored_static_gltf_node_flips_winding_and_keeps_outward_normals() { let dir = std::env::temp_dir().join(format!( "paperdoll-static-mirror-{}-{:?}", std::process::id(), std::thread::current().id() )); std::fs::create_dir_all(&dir).unwrap(); // One triangle facing -Z (counter-clockwise seen from -Z), normal -Z. let pos: [[f32; 3]; 3] = [[0., 0., 0.], [0., 1., 0.], [1., 0., 0.]]; let mut bin = Vec::new(); for p in pos { for v in p { bin.extend(v.to_le_bytes()); } } for _ in 0..3 { for v in [0f32, 0., -1.] { bin.extend(v.to_le_bytes()); } } std::fs::write(dir.join("tri.bin"), &bin).unwrap(); let gltf = serde_json::json!({ "asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0]}], "nodes": [{"mesh": 0, "scale": [-2.0, 1.0, 1.0], "translation": [0.0, 0.5, 0.0]}], "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}}]}], "buffers": [{"uri": "tri.bin", "byteLength": bin.len()}], "bufferViews": [ {"buffer": 0, "byteOffset": 0, "byteLength": 36}, {"buffer": 0, "byteOffset": 36, "byteLength": 36} ], "accessors": [ {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]}, {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"} ] }); std::fs::write(dir.join("tri.gltf"), gltf.to_string()).unwrap(); let body = load_body_file(&dir.join("tri.gltf")).unwrap(); std::fs::remove_dir_all(&dir).ok(); assert!(body.is_static()); assert_eq!(body.positions[2], [-2., 0.5, 0.]); assert_eq!(body.indices, vec![0, 2, 1], "mirror must flip winding"); let [a, b, c] = [0, 2, 1].map(|i| body.positions[i]); let n = cross( [b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]], ); assert!(n[2] < 0., "winding still faces -Z: {n:?}"); for normal in &body.normals { assert!((normal[2] + 1.).abs() < 1e-6, "{normal:?}"); } assert!(normal_matrix(&trs([0.; 3], [0., 0., 0., 1.], [0., 1., 1.])).is_none()); } #[test] fn walk_and_attack_move_the_matching_bones() { let character = male(); assert!(character.clips.iter().any(|clip| clip.name == "walk")); assert!(character.clips.iter().any(|clip| clip.name == "attack")); assert_eq!(character.joint_names.len(), 19); assert!(character.indices.len() >= 3); assert!(character.positions.len() >= 19 * 8); let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap(); let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap(); assert!( distance(leg_a, leg_b) > 0.05, "walk should move the lower leg, delta {}", distance(leg_a, leg_b) ); let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap(); let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap(); assert!( distance(arm_a, arm_b) > 0.05, "attack should move the right hand, delta {}", distance(arm_a, arm_b) ); let matrices = sample_skin_matrices(&character, "walk", 0.2); assert_eq!(matrices.len(), 19); assert!(matrices.iter().flatten().all(|value| value.is_finite())); } #[test] fn female_fixture_has_the_same_clip_names() { let character = load_gltf_file( &Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-female.glb"), ) .unwrap(); assert!(character.clips.iter().any(|clip| clip.name == "walk")); assert!(character.clips.iter().any(|clip| clip.name == "attack")); let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap(); let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap(); assert!(distance(a, b) > 0.05); } #[test] fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() { let front = eye_offset(0.0, 0.0, 4.0); assert!(front[2] < -3.5, "yaw 0 camera belongs on -Z, got {front:?}"); assert!(front[0].abs() < 0.05); let mut seen = Vec::new(); for yaw in paperdoll_yaws_deg() { let eye = eye_offset(yaw, 0.0, 4.0); assert!(seen .iter() .all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5)); seen.push(eye); } assert_eq!(seen.len(), 12); let low = eye_offset(0.0, -30.0, 4.0); let high = eye_offset(0.0, 30.0, 4.0); assert!(low[1] < 0.0 && high[1] > 0.0); assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3); } #[test] fn controls_snap_wrap_and_keep_a_clip_change() { let mut controls = PreviewControls::default(); controls.drag(-100.0, 40.0); assert!(controls.yaw_deg > 40.0); assert!(controls.pitch_deg > 10.0); controls.zoom(400.0); assert!(controls.distance < 3.4); controls.snap_yaw_index(-1); assert!((controls.yaw_deg - 330.0).abs() < 0.01); controls.snap_pitch_row(99); assert!((controls.pitch_deg - 30.0).abs() < 0.01); controls.snap_pitch_row(-4); assert!((controls.pitch_deg + 30.0).abs() < 0.01); controls.time = 0.4; controls.select_clip("attack"); assert_eq!(controls.clip_name, "attack"); assert_eq!(controls.time, 0.0); controls.time = 1.2; controls.advance(0.0, 1.0); controls.playing = true; controls.advance(0.3, 1.0); assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02); } }