//! 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, /// Source channels not applied, with the reason (e.g. morph weights). pub skipped: Vec, } /// Keyframe interpolation as stored in the source (glTF sampler). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Interpolation { #[default] Linear, Step, /// Hermite spline; `values` holds (in-tangent, value, out-tangent) per key. CubicSpline, } #[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, pub interpolation: Interpolation, } impl ChannelProperty { pub fn width(self) -> usize { match self { Self::Rotation => 4, _ => 3, } } } impl AnimChannel { /// Times finite and non-decreasing; one value (or tangent triple) per key, finite. pub fn validate(&self) -> Result<(), String> { if self.times.is_empty() { return Err("kanaal zonder keyframes".into()); } if self.times.iter().any(|t| !t.is_finite() || *t < 0.) || self.times.windows(2).any(|w| w[1] < w[0]) { return Err("keyframetijden niet eindig of niet oplopend".into()); } let per_key = self.property.width() * if self.interpolation == Interpolation::CubicSpline { 3 } else { 1 }; if self.values.len() != self.times.len() * per_key { return Err(format!( "{} waarden voor {} keyframes (verwacht {per_key} per key)", self.values.len(), self.times.len() )); } if self.values.iter().any(|v| !v.is_finite()) { return Err("niet-eindige keyframewaarde".into()); } Ok(()) } fn value(&self, key: usize, part: usize) -> &[f32] { let w = self.property.width(); let index = if self.interpolation == Interpolation::CubicSpline { key * 3 + part } else { key }; &self.values[index * w..index * w + w] } /// Sampled value at `time` (clamped to the first/last key). pub fn sample(&self, time: f32) -> Vec { let (left, right, blend) = key_blend(&self.times, time); let w = self.property.width(); let a = self.value(left, 1); if left == right || self.interpolation == Interpolation::Step { return a.to_vec(); } let b = self.value(right, 1); match self.interpolation { Interpolation::Step => a.to_vec(), Interpolation::Linear => { if self.property == ChannelProperty::Rotation { let q = slerp([a[0], a[1], a[2], a[3]], [b[0], b[1], b[2], b[3]], blend); q.to_vec() } else { (0..w).map(|i| a[i] + (b[i] - a[i]) * blend).collect() } } Interpolation::CubicSpline => { let dt = self.times[right] - self.times[left]; let out_a = self.value(left, 2); let in_b = self.value(right, 0); let t = blend; let (t2, t3) = (t * t, t * t * t); let h00 = 2. * t3 - 3. * t2 + 1.; let h10 = t3 - 2. * t2 + t; let h01 = -2. * t3 + 3. * t2; let h11 = t3 - t2; let v: Vec = (0..w) .map(|i| h00 * a[i] + h10 * dt * out_a[i] + h01 * b[i] + h11 * dt * in_b[i]) .collect(); if self.property == ChannelProperty::Rotation { normalize4([v[0], v[1], v[2], v[3]]).to_vec() } else { v } } } } } /// Clip for a requested name: exact match first, then the first clip whose /// name contains it (case-insensitive), so "walk" never picks "walk_fast" /// when "walk" exists. pub fn find_clip<'a>(clips: &'a [PreviewClip], name: &str) -> Option<&'a PreviewClip> { clips.iter().find(|c| c.name == name).or_else(|| { let lower = name.to_lowercase(); clips .iter() .find(|c| c.name.to_lowercase().contains(&lower)) }) } /// 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 { // The gltf crate can panic on some malformed documents; that must stay a // recoverable load error for the editor and batch. std::panic::catch_unwind(|| load_gltf_file_unguarded(path)) .unwrap_or_else(|_| Err("ongeldig glTF-bestand (parser weigerde het document)".into())) } /// `gltf::import` with parser panics turned into an error. pub fn gltf_import_guarded( path: &Path, ) -> Result< ( gltf::Document, Vec, Vec, ), String, > { std::panic::catch_unwind(|| gltf::import(path)) .map_err(|_| "ongeldig glTF-bestand (parser weigerde het document)".to_string())? .map_err(|err| err.to_string()) } fn load_gltf_file_unguarded(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() )); } // Union of all skins' joints (first skin first); every skinned mesh node // is remapped onto it, so multi-mesh / multi-skin bodies draw completely. let mut joint_nodes: Vec = Vec::new(); let mut inverse_binds: Vec = Vec::new(); let mut skin_maps: Vec> = Vec::new(); for s in document.skins() { let reader = s.reader(|buffer| Some(buffers[buffer.index()].as_ref())); let binds: Vec = reader .read_inverse_bind_matrices() .map(|iter| iter.map(columns_to_mat).collect()) .unwrap_or_else(|| vec![identity(); s.joints().len()]); if binds.len() != s.joints().len() { return Err("inverse-bindmatrices en joints verschillen".to_string()); } let mut map = Vec::new(); for (joint, bind) in s.joints().zip(binds) { let index = match joint_nodes.iter().position(|n| *n == joint.index()) { Some(i) => i, None => { joint_nodes.push(joint.index()); inverse_binds.push(bind); joint_nodes.len() - 1 } }; map.push(index as u16); } skin_maps.push(map); } if joint_nodes.len() > MAX_JOINTS { return Err(format!( "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}", joint_nodes.len() )); } let joint_names: Vec = joint_nodes .iter() .map(|index| nodes[*index].name.clone()) .collect(); let rest_locals: Vec = nodes .iter() .map(|n| trs(n.translation, n.rotation, n.scale)) .collect(); let rest_world = world_matrices(&nodes, &rest_locals); 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(); for node in document.nodes() { let Some(mesh) = node.mesh() else { continue }; // Skinned: vertices are in skin space, joints remapped onto the union. // Rigid (no skin): follows its nearest joint ancestor (or the first // root joint), with the rest-pose node transform baked in once. let rigid = match node.skin() { Some(s) => Err(&skin_maps[s.index()]), None => { let mut current = Some(node.index()); let mut joint = None; let mut guard = 0; while let Some(n) = current { if let Some(j) = joint_nodes.iter().position(|x| *x == n) { joint = Some(j); break; } current = nodes[n].parent; guard += 1; if guard > nodes.len() { return Err("glTF-nodehiërarchie bevat een cyclus".into()); } } let j = joint.unwrap_or(0); let to_joint = affine_inverse(&rest_world[joint_nodes[j]]) .and_then(|jr| affine_inverse(&inverse_binds[j]).map(|b| mul(b, jr))) .ok_or("singuliere joint-rustmatrix")?; Ok((j as u16, mul(to_joint, rest_world[node.index()]))) } }; for primitive in mesh.primitives() { let mut prim = read_gltf_primitive(&primitive, &buffers, &images)?; let count = prim.positions.len(); match &rigid { Err(map) => { for j in prim.joints.iter_mut() { for k in j.iter_mut() { *k = *map.get(*k as usize).ok_or("skinjoint buiten skin")?; } } } Ok((joint, m)) => { let nm = normal_matrix(m).ok_or("glTF-node heeft een singuliere transformatie")?; for p in prim.positions.iter_mut() { *p = transform_point3(m, *p); } if let Some(ns) = prim.normals.as_mut() { for n in ns.iter_mut() { *n = normalize3(mat3_mul(&nm, *n)); } } if det3(m) < 0. { for tri in prim.indices.chunks_exact_mut(3) { tri.swap(1, 2); } } prim.joints = vec![[*joint, 0, 0, 0]; count]; prim.weights = vec![[1., 0., 0., 0.]; count]; } } source_normals_complete &= prim.normals.is_some(); 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()); } if positions .iter() .chain(&normals) .flatten() .any(|v| !v.is_finite()) { return Err("glTF-mesh heeft niet-eindige posities na de node-transformatie".into()); } let mut clips = Vec::new(); for animation in document.animations() { let mut channels = Vec::new(); let mut skipped = Vec::new(); let mut duration = 0.0_f32; for channel in animation.channels() { let target = channel.target().node().index(); let interpolation = match channel.sampler().interpolation() { gltf::animation::Interpolation::Linear => Interpolation::Linear, gltf::animation::Interpolation::Step => Interpolation::Step, gltf::animation::Interpolation::CubicSpline => Interpolation::CubicSpline, }; let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref())); let times: Vec = reader .read_inputs() .map(|iter| iter.collect()) .unwrap_or_default(); let Some(outputs) = reader.read_outputs() else { skipped.push(format!("node {target}: geen uitvoerwaarden")); 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(_) => { skipped.push(format!( "node {target}: morph-target-gewichten (niet ondersteund)" )); continue; } }; let channel = AnimChannel { node: target, property, times, values, interpolation, }; if let Err(reason) = channel.validate() { return Err(format!( "animatie '{}': ongeldig kanaal op node {target}: {reason}", animation.name().unwrap_or("naamloos") )); } if let Some(last) = channel.times.last() { duration = duration.max(*last); } channels.push(channel); } clips.push(PreviewClip { name: animation .name() .filter(|n| !n.trim().is_empty()) .map(str::to_string) .unwrap_or_else(|| format!("animatie {}", animation.index())), duration, channels, skipped, }); } 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()]); if positions.iter().flatten().any(|v| !v.is_finite()) { return Err("glTF-primitive heeft niet-eindige posities".into()); } if normals .as_ref() .is_some_and(|n| n.len() != positions.len() || n.iter().flatten().any(|v| !v.is_finite())) { return Err("glTF-primitive heeft ongeldige normalen".into()); } if joints.len() != positions.len() || weights.len() != positions.len() || weights.iter().flatten().any(|w| !w.is_finite() || *w < 0.) { return Err("glTF-primitive heeft ongeldige joints/gewichten".into()); } let material = primitive.material(); let pbr = material.pbr_metallic_roughness(); let texture = pbr.base_color_texture(); let transform = texture.as_ref().and_then(|t| t.texture_transform()); let uvset = transform .as_ref() .and_then(|t| t.tex_coord()) .or_else(|| 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 uvs = match &transform { Some(t) => { let m = uv_transform(t.offset(), t.rotation(), t.scale()); uvs.into_iter().map(|uv| apply_uv(&m, uv)).collect() } None => uvs, }; let indices: Vec = match reader.read_indices() { Some(read_indices) => read_indices.into_u32().collect(), None => (0..positions.len() as u32).collect(), }; if indices.iter().any(|i| *i as usize >= positions.len()) { return Err("glTF-primitive heeft indices buiten het vertexbereik".into()); } 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) } /// KHR_texture_transform as a 2×3 affine UV matrix (T·R·S, rotation /// counter-clockwise in UV space as in the extension's examples). pub fn uv_transform(offset: [f32; 2], rotation: f32, scale: [f32; 2]) -> [[f32; 3]; 2] { let (s, c) = rotation.sin_cos(); [ [c * scale[0], s * scale[1], offset[0]], [-s * scale[0], c * scale[1], offset[1]], ] } fn apply_uv(m: &[[f32; 3]; 2], uv: [f32; 2]) -> [f32; 2] { [ m[0][0] * uv[0] + m[0][1] * uv[1] + m[0][2], m[1][0] * uv[0] + m[1][1] * uv[1] + m[1][2], ] } /// Inverse of an affine matrix (None when singular). fn affine_inverse(m: &Mat4) -> Option { let it = normal_matrix(m)?; // inverse-transpose of the 3×3 // inverse 3×3 (row r, col c) = it[c][r] let mut out = identity(); for r in 0..3 { for c in 0..3 { out[c * 4 + r] = it[c][r]; } } let t = [m[12], m[13], m[14]]; for r in 0..3 { out[12 + r] = -(0..3).map(|c| out[c * 4 + r] * t[c]).sum::(); } Some(out) } 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) = find_clip(&character.clips, clip_name) { let time = wrap_time(time, clip.duration); for channel in &clip.channels { if channel.node >= character.nodes.len() || channel.validate().is_err() { continue; } let v = channel.sample(time); match channel.property { ChannelProperty::Translation => translation[channel.node] = [v[0], v[1], v[2]], ChannelProperty::Scale => scale[channel.node] = [v[0], v[1], v[2]], ChannelProperty::Rotation => rotation[channel.node] = [v[0], v[1], v[2], v[3]], } } } 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 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 chan( property: ChannelProperty, interpolation: Interpolation, times: &[f32], values: &[f32], ) -> AnimChannel { AnimChannel { node: 0, property, times: times.to_vec(), values: values.to_vec(), interpolation, } } #[test] fn step_linear_and_cubic_interpolation_follow_gltf() { let lin = chan( ChannelProperty::Translation, Interpolation::Linear, &[0., 1.], &[0., 0., 0., 2., 4., 6.], ); assert_eq!(lin.sample(0.25), vec![0.5, 1., 1.5]); assert_eq!(lin.sample(-1.), vec![0., 0., 0.]); assert_eq!(lin.sample(5.), vec![2., 4., 6.]); let step = Interpolation::Step; let st = chan( ChannelProperty::Translation, step, &[0., 1.], &[0., 0., 0., 2., 4., 6.], ); assert_eq!(st.sample(0.99), vec![0., 0., 0.]); assert_eq!(st.sample(1.), vec![2., 4., 6.]); // Cubic: (in, value, out) per key; zero tangents give smoothstep. let cubic = chan( ChannelProperty::Translation, Interpolation::CubicSpline, &[0., 2.], &[ 9., 9., 9., 0., 0., 0., 0., 0., 0., 0., 0., 0., 10., 10., 10., 9., 9., 9., ], ); cubic.validate().unwrap(); let v = cubic.sample(0.5); // t = 0.25 -> h01 = 0.15625 assert!((v[0] - 1.5625).abs() < 1e-5, "{v:?}"); // Tangents are scaled by the key interval: out-tangent 1/s over 2 s. let sloped = chan( ChannelProperty::Translation, Interpolation::CubicSpline, &[0., 2.], &[ 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 2., 0., 0., 0., 0., 0., ], ); let v = sloped.sample(1.); // t=.5: h01*2 + h10*2*1 = 1 + 0.25 assert!((v[0] - 1.25).abs() < 1e-5, "{v:?}"); // Cubic rotation stays unit length. let q = chan( ChannelProperty::Rotation, Interpolation::CubicSpline, &[0., 1.], &[ 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., std::f32::consts::FRAC_1_SQRT_2, std::f32::consts::FRAC_1_SQRT_2, 0., 0., 0., 0., ], ); let r = q.sample(0.5); let len: f32 = r.iter().map(|x| x * x).sum::().sqrt(); assert!((len - 1.).abs() < 1e-4); // Validation rejects malformed channels. assert!(chan( ChannelProperty::Translation, Interpolation::CubicSpline, &[0., 1.], &[0.; 6] ) .validate() .is_err()); assert!(chan( ChannelProperty::Translation, Interpolation::Linear, &[1., 0.], &[0.; 6] ) .validate() .is_err()); assert!(chan( ChannelProperty::Translation, Interpolation::Linear, &[0., f32::NAN], &[0.; 6] ) .validate() .is_err()); assert!(chan( ChannelProperty::Scale, Interpolation::Linear, &[0.], &[f32::INFINITY, 0., 0.] ) .validate() .is_err()); } #[test] fn clip_lookup_prefers_exact_name() { let clip = |name: &str| PreviewClip { name: name.into(), duration: 1., channels: Vec::new(), skipped: Vec::new(), }; let clips = vec![clip("walk_fast"), clip("walk"), clip("Attack")]; assert_eq!(find_clip(&clips, "walk").unwrap().name, "walk"); assert_eq!(find_clip(&clips, "attack").unwrap().name, "Attack"); assert!(find_clip(&clips, "run").is_none()); } fn glb_with(edit: impl Fn(&mut serde_json::Value)) -> std::path::PathBuf { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-male.glb"); let bytes = std::fs::read(src).unwrap(); let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize; let mut doc: serde_json::Value = serde_json::from_slice(&bytes[20..20 + json_len]).unwrap(); edit(&mut doc); let mut json = serde_json::to_vec(&doc).unwrap(); while !json.len().is_multiple_of(4) { json.push(b' '); } let rest = &bytes[20 + json_len..]; let mut out = Vec::new(); out.extend_from_slice(&bytes[0..8]); out.extend_from_slice(&((12 + 8 + json.len() + rest.len()) as u32).to_le_bytes()); out.extend_from_slice(&(json.len() as u32).to_le_bytes()); out.extend_from_slice(b"JSON"); out.extend_from_slice(&json); out.extend_from_slice(rest); static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); let path = std::env::temp_dir().join(format!( "ppd-anim-{}-{}.glb", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) )); std::fs::write(&path, out).unwrap(); path } #[test] fn unskinned_mesh_nodes_follow_their_joint_ancestor() { let base = load_gltf_file( &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap(); let path = glb_with(|d| { let nodes = d["nodes"].as_array_mut().unwrap(); nodes.push(serde_json::json!({"mesh": 0, "translation": [0.0, 0.5, 0.2]})); let kids = nodes[4]["children"].as_array().cloned().unwrap_or_default(); let mut kids = kids; kids.push(serde_json::json!(20)); nodes[4]["children"] = serde_json::Value::Array(kids); }); let both = load_gltf_file(&path).unwrap(); let n = base.positions.len(); assert_eq!(both.positions.len(), 2 * n); let j = both.joint_nodes.iter().position(|x| *x == 4).unwrap() as u16; assert!(both.joints[n..].iter().all(|k| k[0] == j)); assert!(both.weights[n..].iter().all(|w| w[0] == 1.0)); // At rest, skinning reproduces the node's world placement. let locals: Vec = both .nodes .iter() .map(|x| trs(x.translation, x.rotation, x.scale)) .collect(); let world = world_matrices(&both.nodes, &locals); let skin = mul(world[4], both.inverse_binds[j as usize]); for (k, p) in both.positions[n..].iter().enumerate().take(20) { let got = transform_point3(&skin, *p); // Source vertex = base mesh vertex in skin space; world = node20 * v. let v = base.positions[k]; let want = transform_point3(&world[20], v); for a in 0..3 { assert!((got[a] - want[a]).abs() < 1e-3, "{got:?} vs {want:?}"); } } let _ = std::fs::remove_file(path); } #[test] fn texture_transform_matches_extension_example() { // Spec example: lower-left quadrant, rotated 90° clockwise. let m = uv_transform([0., 1.], std::f32::consts::FRAC_PI_2, [0.5, 0.5]); let close = |a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6; assert!(close(apply_uv(&m, [0., 0.]), [0., 1.])); assert!(close(apply_uv(&m, [1., 0.]), [0., 0.5])); assert!(close(apply_uv(&m, [0., 1.]), [0.5, 1.])); // Spec example: flip T. let m = uv_transform([0., 1.], 0., [1., -1.]); assert!(close(apply_uv(&m, [0.25, 0.25]), [0.25, 0.75])); } #[test] fn gltf_texture_transform_is_baked_into_uvs() { let base = load_gltf_file( &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap(); let path = glb_with(|d| { d["images"] = serde_json::json!([{"uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"}]); d["textures"] = serde_json::json!([{"source": 0}]); for m in d["materials"].as_array_mut().unwrap() { let pbr = &mut m["pbrMetallicRoughness"]; if pbr.get("baseColorTexture").is_none() { pbr["baseColorTexture"] = serde_json::json!({"index": 0}); } pbr["baseColorTexture"]["extensions"] = serde_json::json!( {"KHR_texture_transform": {"offset": [0.5, 0.25], "scale": [2.0, 3.0]}}); } }); let moved = load_gltf_file(&path); let _ = std::fs::remove_file(&path); let moved = match moved { Ok(m) => m, Err(e) => panic!("{e}"), }; for (a, b) in base.uvs.iter().zip(&moved.uvs).take(50) { assert!((a[0] * 2.0 + 0.5 - b[0]).abs() < 1e-5); assert!((a[1] * 3.0 + 0.25 - b[1]).abs() < 1e-5); } } #[test] fn affine_inverse_round_trips() { let m = trs( [1., 2., 3.], [0.0, 0.38268343, 0.0, 0.9238795], [2., 1., 0.5], ); let i = affine_inverse(&m).unwrap(); let id = mul(m, i); for (k, v) in id.iter().enumerate() { let want = if k % 5 == 0 { 1.0 } else { 0.0 }; assert!((v - want).abs() < 1e-5); } } #[test] fn gltf_step_is_kept_bad_cubic_rejected_unnamed_clips_get_index_names() { let base = load_gltf_file( &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap(); assert!(base.clips.iter().all(|c| c .channels .iter() .all(|ch| ch.interpolation == Interpolation::Linear))); let step = glb_with(|d| { for a in d["animations"].as_array_mut().unwrap() { for s in a["samplers"].as_array_mut().unwrap() { s["interpolation"] = "STEP".into(); } a.as_object_mut().unwrap().remove("name"); } }); let loaded = load_gltf_file(&step).unwrap(); assert!(loaded.clips.iter().all(|c| c .channels .iter() .all(|ch| ch.interpolation == Interpolation::Step))); assert_eq!(loaded.clips[0].name, "animatie 0"); assert_ne!(loaded.clips[0].name, loaded.clips[1].name); // Between keys a STEP channel holds the left key. let ch = loaded.clips[0] .channels .iter() .find(|c| c.times.len() > 2) .unwrap(); let mid = (ch.times[0] + ch.times[1]) / 2.; assert_eq!(ch.sample(mid), ch.sample(ch.times[0])); // CUBICSPLINE without tangent triples is rejected explicitly. let cubic = glb_with(|d| { d["animations"][0]["samplers"][0]["interpolation"] = "CUBICSPLINE".into(); }); let err = load_gltf_file(&cubic).err().unwrap(); assert!(err.contains("ongeldig kanaal"), "{err}"); let _ = std::fs::remove_file(step); let _ = std::fs::remove_file(cubic); } 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(), 5); assert_eq!(PRODUCTION_VIEWS as usize, PRODUCTION_PITCHES.len() * 12); } #[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 - 60.0).abs() < 0.01); controls.snap_pitch_row(-4); assert!((controls.pitch_deg + 60.0).abs() < 0.01); controls.snap_pitch_row(3); 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); } }